Playback
ci / checks (push) Failing after 23m37s

This commit is contained in:
khodowany
2026-10-05 23:41:11 -05:00
parent ffdbe48258
commit 82349cf897
104 changed files with 4095 additions and 4354 deletions
+29 -22
View File
@@ -5,7 +5,7 @@ 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 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. 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 services use Ligo for the HTTP API, Luma for touch UI rendering, Sonus for audio playback, and Modulus for shared build/runtime utilities.
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. 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.
@@ -14,6 +14,7 @@ Device controls:
On the Zero, tap menu rows and switches directly; Tempo has minus/plus buttons. The main menu offers **Performance** and **Settings**. On the Zero, tap menu rows and switches directly; Tempo has minus/plus buttons. The main menu offers **Performance** and **Settings**.
On the Pico (and on the Zero while viewing a score): On the Pico (and on the Zero while viewing a score):
- Tap: next menu item or next score page. - Tap: next menu item or next score page.
- Double tap: previous menu item or previous score page. - Double tap: previous menu item or previous score page.
- Tap and hold: select the focused item, enter/leave tempo editing, or return from a score to its part menu. - Tap and hold: select the focused item, enter/leave tempo editing, or return from a score to its part menu.
@@ -34,6 +35,8 @@ Release verification:
```sh ```sh
python3 modulus.py configure --target x86_64_gcc12_release 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 test --target x86_64_gcc12_release
python3 modulus.py python -m unittest discover -s tests -p 'test_*.py'
``` ```
`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. `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.
@@ -60,7 +63,7 @@ Modulus also generates `.nvim.lua` for Neovim 0.11+ with `exrc` enabled. Review
## Package ## Package
Product packaging targets Raspberry Pi OS/Debian trixie on ARM64 and optionally builds Pico firmware. Set `PICO_SDK_PATH` when building the firmware artifact: Product packaging targets Raspberry Pi OS/Debian trixie on ARM64. The configured package builds both the Zero Docker image and Pico firmware. The build container includes the Pico SDK; when building directly outside that container, set `PICO_SDK_PATH`:
```sh ```sh
PICO_SDK_PATH=/path/to/pico-sdk python3 modulus.py package --update PICO_SDK_PATH=/path/to/pico-sdk python3 modulus.py package --update
@@ -91,19 +94,21 @@ Artifacts are published to the khodo generic package registry's required `latest
## Score Assets ## Score Assets
Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or MuseScore live in `data/`. Generated score JSON is not committed. Uncompressed MusicXML 3.1/4.0 source scores exported from Soundslice or MuseScore live in `data/songs/`. Generated score JSON is not committed.
`data/songs` supplies the original melody and harmony. The 21 scale patterns, 2,954 relative interval paths, meter-specific rhythm catalogs, and general rules in `data/composition` supply musical vocabulary. Production never reads reference solos, fitted parameters, or previous music. `data/songs/` supplies the original melody and harmony. `data/composition/` holds guitar/difficulty settings, sparse accompaniment figures, and harmonic-validation policies. `data/training/manifest.json` registers authored solos and their source/slot pairings; `data/models/solo.json` holds the frozen pooled prediction model. The former scale, interval, and rhythm catalogs and handwritten phrase composer have been removed.
Six roles generate independently: acoustic accompaniment/solo; electric accompaniment/melody/solo; vocal melody. A process pool and per-role caches support parallel and incremental builds. Canonical accompaniment repeats at corresponding source-form positions and does not react to generated solos. Original vocal and electric head content remain unchanged. Six roles generate independently: acoustic accompaniment/solo; electric accompaniment/melody/solo; vocal melody. A process pool and per-role caches support parallel and incremental builds. Each role uses the source and its own inputs without reading another generated role. Canonical accompaniment repeats at corresponding source-form positions and does not react to generated solos. Vocal melody preserves the authored notes and lyrics. Electric melody preserves source pitches and harmonizes eligible sustained notes, reattacking them at chord changes.
The solo composer creates complete source-related melodic phrases before selection. A realized theme develops through returns, answers, register changes, arpeggio motion and complete scale routes. Phrase destinations, rhythm, rests and holds are coordinated. Both guitars use strings 1–6 and frets 1–12 without open strings. Colors, lead-ins, anticipations and suspensions need explicit harmonic evidence. No dynamics, accents or humanization are added. **Registered Solo 1 is notated exactly in the acoustic part and Solo 2 exactly in the electric part.** Unregistered song/slot pairs use deterministic prediction from one pooled statistical model trained on both slots. There is no learned acoustic/electric style distinction. Prediction uses up to four recent pitch/rhythm events, source-melody features, harmony, and metrical position; it does not plan complete themes or guarantee long-range phrase development. The current corpus is four authored solos from Autumn Leaves and All of Me.
`--export-solo-xml` (CMake `VAMP_EXPORT_SOLO_XML=ON`) writes `generated_assets/solo_xml/*-solo1.xml` for acoustic and `*-solo2.xml` for electric, with actual notes and TAB. These exports do not recompose music. `python -m generator.compose --song ... --role acoustic_solo --data-dir data --output ... --solo-xml ...` builds one independent role. Training, authored score reuse, and prediction share contextual harmony validation based on actual notes, timing, and chords. Explanatory labels and justification tags are not required. Unsupported relationships fail with a diagnostic rather than silently changing notes. Both solo roles use strings 1–6 and frets 1–12 without open strings. No dynamics, accents, velocity shaping, or random timing are added.
Optional comparison lives in `python -m tests.solo_compare`, outside production. Supply `--generated-dir`, explicit `--reference ROLE=PATH`, `--form-start ROLE=MEASURE`, `--reference-pitch guitar-written`, and `--output build/solo_comparison/review`; add `--assets` and `--baseline-dir` for consistent audio comparisons. References are analysis-only. The supplied targets sound one octave below encoded pitches; All of Me aligns at measure 1 and Autumn Leaves at measure 2, preserving the lead-in. Closeness is reported using actual notes and timing; matching statistics or technical validity do not establish musical preference. `--export-solo-xml` (CMake `VAMP_EXPORT_SOLO_XML=ON`) writes `solo_xml/*-solo1.xml` for acoustic and `*-solo2.xml` for electric under the generated asset directory, with actual notes and TAB. These exports do not recompose music. `./modulus.py python -m generator.compose --song ... --role acoustic_solo --data-dir data --output ... --solo-xml ...` builds one independent role. To exercise prediction for a registered song, pass `--settings` with a JSON file containing `{"predict": true}`.
See [Independent Musical Generation](TECHNICAL_GUIDE.md#independent-musical-generation) for the active pipeline and limitations. Keep just one compact baseline and the latest comparison under `build/solo_comparison/`. Optional comparison runs through `./modulus.py python -m tests.solo_compare`. Point `--generated-dir` at the exported `solo_xml/` directory and supply explicit `--reference SONG/ROLE=PATH`, `--form-start SONG/ROLE=MEASURE`, `--reference-pitch guitar-written`, and `--output build/solo_comparison/review`. Keys match `manifest.solo`, such as `all_of_me/acoustic`. Add `--require-exact` to fail on sounding-note or rest differences, or `--baseline-dir` to compare an earlier MusicXML export. Comparison results never feed production; registered authored playback and explicit training use the paired solos separately. The supplied solos sound one octave below encoded pitches; All of Me aligns at measure 1 and Autumn Leaves at measure 2, preserving the lead-in. Matching notes or technical validity do not establish musical preference.
See [Shared Part Generation](TECHNICAL_GUIDE.md#shared-part-generation) and [Authored Repertoire and Learned Solos](TECHNICAL_GUIDE.md#authored-repertoire-and-learned-solos) for the active pipeline and limitations. Keep just one compact baseline and the latest comparison under `build/solo_comparison/`.
To inspect generated assets without building the full package, run: To inspect generated assets without building the full package, run:
@@ -114,36 +119,38 @@ To inspect generated assets without building the full package, run:
The `--check` path compares generation invariants against `tests/fixtures/performance_summary.json`. The `--check` path compares generation invariants against `tests/fixtures/performance_summary.json`.
Modulus owns the pinned build-only dependencies in its optional `assets` group, selected by `tooling.python.groups` in `modulus.json`. Configure prepares `build/build_common/python`; `./modulus.py python` and CMake use that environment and the exported Modulus modules. Builds never install Python dependencies. After dependency pins change, run configure again. C++-only libraries do not opt in, and Python is not packaged for the Pi. Modulus owns the pinned build-only dependencies in its optional `assets` group, selected by `tooling.python.groups` in `modulus.json`. Configure prepares `build/build_common/python`; `./modulus.py python` and CMake use that environment and the exported Modulus modules. Builds never install Python dependencies. After dependency pins change, run configure again. C++-only libraries do not opt in, and Python is not packaged for the Pi.
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. Cached musical roles are reused independently; 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`. CMake builds use two composition workers and two engraving workers, with separate disposable caches under `build/build_common/composition_cache` and `build/build_common/engraving_cache`. Native and ARM packaging reuse unchanged roles and pages; CI also retains the page cache between runs. Music, recording, and catalog validation still run for every generation. Set `VAMP_COMPOSITION_JOBS` or `VAMP_ENGRAVING_JOBS` (1-16) to adjust concurrency, or set `VAMP_ENGRAVING_CACHE_DIR` to an empty CMake value to disable persistent page caching. Direct generator runs default to two composition workers and one engraving worker, without persistent caches; use `--composition-cache`, `--engraving-cache`, `--composition-jobs`, `--jobs`, and `--progress` as needed.
Identical notation bodies share layout and body rasterization even when titles or score identities differ; each score retains its own header, pages, music, and catalog identity. A `cold namespace` means no cache directory exists for the current renderer fingerprint. It does not mean that every score must be rendered independently: repeated bodies can still be reused within that build, including with persistent caching disabled. Progress separates full-score hits, body hits, newly rasterized bodies, and composed pages. Identical notation bodies share layout and body rasterization even when titles or score identities differ; each score retains its own header, pages, music, and catalog identity. A `cold namespace` means no cache directory exists for the current renderer fingerprint. It does not mean that every score must be rendered independently: repeated bodies can still be reused within that build, including with persistent caching disabled. Progress separates full-score hits, body hits, newly rasterized bodies, and composed pages.
Solos use one pooled model trained on the paired scores in `data/training`. Both solo slots contribute equally; there is no learned acoustic/electric style distinction. **Registered Solo 1 is played exactly by acoustic and Solo 2 exactly by electric.** Unregistered song/slot pairs use deterministic model prediction. Exact registered playback is explicit authored reuse, not a claim of model memorization. Only Autumn Leaves and All of Me are included initially. Add a source and its authored solo, then retrain the pooled model, without changing generator code:
Training and playback use the same contextual harmony validator as predictions. It recognizes supported colors, passing notes, approaches and anticipations from the actual notes, durations and chords. No explanatory score labels or hand-authored justification tags are required. The four approved solos pass this validation; their explanatory labels have been removed without changing their music. Unsupported relationships still produce a diagnostic rather than silently changing a note.
Add a source and its authored solo without changing generator code:
```sh ```sh
./modulus.py python -m generator.train --add-source /path/to/song.xml --add-solo /path/to/solo1.xml --slot 1 ./modulus.py python -m generator.train --add-source /path/to/song.xml --add-solo /path/to/solo1.xml --slot 1
./modulus.py python -m generator.train --add-source data/songs/song.xml --add-solo /path/to/solo2.xml --slot 2 ./modulus.py python -m generator.train --add-source data/songs/song.xml --add-solo /path/to/solo2.xml --slot 2
``` ```
To retrain existing pairs, run `./modulus.py python -m generator.train --leave-one-song-out`. Ordinary builds use the saved model; they never retrain automatically. The four-example model is a starting point and does not establish musical quality on unfamiliar songs. [Training and playback details](TECHNICAL_GUIDE.md#authored-repertoire-and-learned-solos) cover pairing, timing, pitch convention, data migration and limitations. To retrain existing pairs, run `./modulus.py python -m generator.train`; add `--leave-one-song-out` for held-out likelihood diagnostics. Training writes `data/models/solo.json` and `build/training/report.json`. Ordinary builds use registered authored music or the saved model and never retrain automatically. Exact registered playback verifies authored reuse; it does not demonstrate model memorization or musical quality on unfamiliar songs. [Training and playback details](TECHNICAL_GUIDE.md#authored-repertoire-and-learned-solos) cover pairing, timing, pitch convention, data migration, and limitations.
The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model and independent parts -> part generation -> JSON, engraved PNG pages, generated_tone audio, Pico title artwork, and a read-only catalog database. Sources in `data/songs/` retain the seven-section performance form. Songs generate Acoustic, Electric, and Vocal parts in their authored key, without key variants or a key-selection menu. Performance measure numbers run continuously across the complete arrangement, starting at 1 in the lead-in; they do not restart with a new section or chorus. Pickups occupy the preceding measure. Standalone comparison XMLs use local measure numbers and retain the original arrangement numbers in `manifest.solo`. The build-time pipeline is MusicXML -> source analysis -> independent roles -> assembled parts -> semantic JSON, supplied recordings, engraved PNG pages, Pico title artwork, and a read-only catalog database. Each source produces the six-section performance form as Acoustic, Electric, and Vocal parts in its authored key, without key variants or a key-selection menu. Performance measure numbers run continuously across the complete arrangement, starting at 1 in the lead-in; they do not restart with a new section or chorus. Pickups occupy the preceding measure. Standalone comparison XMLs use local measure numbers and retain the original arrangement numbers in `manifest.solo`.
All generated monophonic guitar material uses frets 1–12 without open strings. Accompaniment uses all legal compact positions; a chord can extend to fret 20 while retaining at least one sounding anchor at fret 12 or below and the existing three-fret span restriction. Electric source-melody harmonization retains its existing placement policy. Exact source pitches and ties are preserved; unplayable or unsupported input fails explicitly rather than being silently octave-folded. All generated monophonic guitar material uses frets 1–12 without open strings. Accompaniment uses all legal compact positions; a chord can extend to fret 20 while retaining at least one sounding anchor at fret 12 or below and the existing three-fret span restriction. Electric source-melody harmonization retains its separate placement and chord-change reattack policy. Import preserves exact source pitches and ties; unplayable or unsupported input fails explicitly rather than being silently octave-folded.
There are no genre presets or runtime composition. The complete arranged score is played in reading order. The source-defined form and continuous whole-arrangement measure numbering remain unchanged. There are no genre presets or runtime composition. The complete arranged score is played in reading order. The source-defined form and continuous whole-arrangement measure numbering remain unchanged.
All parts use two music systems per page, with a single system only when the score ends on an odd system. Lines break only between measures and share consistent staff/TAB widths. A measure that cannot fit at readable spacing fails with a diagnostic instead of being split. Tall notation is scaled uniformly to fit the two rows; guitar notation and TAB stay together and aligned. TAB displays frets and ties without duplicate rests; standard notation retains the complete rhythm. The generated `.pages/*.png` files are the same page images displayed by the product. All parts use two music systems per page, with a single system only when the score ends on an odd system. Lines break only between measures and share consistent staff/TAB widths. A measure that cannot fit at readable spacing fails with a diagnostic instead of being split. Tall notation is scaled uniformly to fit the two rows; guitar notation and TAB stay together and aligned. TAB displays frets and ties without duplicate rests; standard notation retains the complete rhythm. The generated `.pages/*.png` files are the same page images displayed by the product.
Electric source-melody harmonization retains its existing chord and exact-pitch behavior. Accompaniment uses compact source-derived voicings and sparse rhythm figures, reused at corresponding form positions. Neither accompaniment reads a generated solo or the other guitar's realization. Solos use all six strings; fixed scale maps and the former handwritten solo search/catalogs have been removed. New predictions retain chord, color and resolution checks. No dynamics, accents, velocity shaping or random timing are added. Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The six-section performance arrangements remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final vocal pass.
Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The seven-section performance arrangements remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final arrangement ending. Settings provides independent **Metronome** and **Backing** toggles, both off by default, plus **Tempo**. Song -> Part -> Score navigation stays the same. With both toggles off, pages turn manually. With either enabled, opening a part starts a one-bar count-in and an automatic performance; tap/double-tap page commands are ignored, and hold exits to the part menu. Natural completion also returns there.
The Settings menu's **Playback** toggle plays the selected part once at the menu BPM, suppresses the metronome, and returns to the part menu after natural completion. Manual page turns do not seek or restart audio. Playback is off by default. Python generates shared PCM tones and exact beat timelines; C++/Sonus only loads, schedules, and mixes them. Audio sample sustain loops hold individual notes for their written duration; they never repeat musical passages. No SoundFonts or runtime synthesizer are required. The main menu contains Performance and Settings. Settings holds Playback, Metronome, and Tempo; its values apply for the current session. Navigation is Song -> Part -> Score. A performance is an eight-bar intro followed by five complete passes: Vocal Melody, Guitar Melody, Acoustic Solo, Electric Solo, and Vocal Melody. There is no separate Ending section. The current 32-bar songs contain 168 numbered bars. Optional turnaround chords are omitted from the final vocal pass.
Audio comes exclusively from supplied WAV files: `package/assets/sounds/beat1.wav` accents each measure's first beat, and `beat2.wav` plays its other beats. Backing tracks belong in `package/assets/backing/<song_id>_<bpm>.wav`, for example `all_of_me_120.wav`. The normalized song ID comes from the source filename. Files are copied unchanged; the app never synthesizes notes or stretches recordings.
Tempo choices are 60, 70, 80, 90, 100, 110, and 120 BPM. With Backing on, choices are the tempos available for every catalog song; currently that is 120 BPM. Enabling Backing selects the closest available tempo, preferring the slower choice on a tie. If no tempo is shared, Backing is unavailable. Add recordings at the other tempos and rebuild to enable them.
Backing recordings include one bar of count-in, the complete score, and the held ending with decay. The recorded count-in plays alone. When both toggles are on, metronome clicks start at the score and stop at its end, leaving the last three held bars and decay unclicked. Metronome-only mode supplies its own count-in and stops at score end. Pages follow the rendered audio clock, using each engraved page's actual measure range; the final page stays visible through the backing tail.
Code style is managed by Modulus, using compact two-space formatting. Python imports modules by their full names and qualifies imported symbols; only `from __future__ import annotations` is exempt, and imports have no aliases. 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, using compact two-space formatting. Python imports modules by their full names and qualifies imported symbols; only `from __future__ import annotations` is exempt, and imports have no aliases. 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.
@@ -153,7 +160,7 @@ See [Score Generation](TECHNICAL_GUIDE.md#score-generation) for supported MusicX
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. Configure installs the optional Modulus asset dependencies. Exact musical timing is retained through layout, with line breaks between complete measures. 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. Configure installs the optional Modulus asset dependencies. Exact musical timing is retained through layout, with line breaks between complete measures. Missing glyphs, unrepresentable page geometry, and failed notation audits stop generation before replacing existing assets.
`generator/palette.json` supplies the dark gig theme for Zero, Pico, score pages, and previews. CMake generates the shared C++ colors from the same palette used by Python. Palette changes invalidate the engraving cache without changing music, spacing, or pagination. `generator/palette.json` preserves the sheet music’s existing dark palette across Zero, Pico, score pages, and previews. Menus use the same Petaluma Script lettering, quiet rules, tune/composer hierarchy and restrained accent colors for a jazz Real Book feel. Performance status shows Count-in, Playing or Sustain, BPM, page progress and a hold-to-exit hint. CMake generates the shared C++ colors from the same palette used by Python. Palette changes invalidate the engraving cache without changing music, spacing, or pagination.
Petaluma is from Steinberg under the SIL Open Font License. Petaluma supplies music glyphs, and Petaluma Script supplies all visible text on score pages and both device interfaces. The Zero uses packaged `PetalumaScript.otf` for dynamic UI text; the Pico displays prerendered Petaluma Script artwork. Runtime packages retain `OFL.txt`; the music font and engraver stay build-only. Petaluma is from Steinberg under the SIL Open Font License. Petaluma supplies music glyphs, and Petaluma Script supplies all visible text on score pages and both device interfaces. The Zero uses packaged `PetalumaScript.otf` for dynamic UI text; the Pico displays prerendered Petaluma Script artwork. Runtime packages retain `OFL.txt`; the music font and engraver stay build-only.
+15 -7
View File
@@ -16,7 +16,7 @@ Copy the tarball to the Zero, verify its `.sha256`, unpack it anywhere, and run:
sudo zero/install_zero.sh sudo zero/install_zero.sh
``` ```
The installer loads `zero/product_vamp_zero_linux_arm64.docker.tar`, installs `/etc/product_vamp/vamp.json` if missing, configures NetworkManager AP mode, and enables the systemd units. The installer loads and probes `zero/product_vamp_zero_linux_arm64.docker.tar`, replaces Product Vamp configuration with the bundle defaults in `/etc/product_vamp/vamp.json`, recreates the control Wi-Fi profile, and enables the systemd units. Every installation resets Product Vamp data and settings; there is no automatic backup or migration. Use a local console or independent network connection because replacing the control Wi-Fi profile can disconnect the session.
Project-owned install paths, services, executables, and archives use underscores. The checkout and Git URL remain `product-vamp`. On upgrades, the installer validates and probes the incoming image before removing the old hyphenated Product Vamp paths, services, and network profile. Unrelated services, networks, and data are preserved; symlinked installation paths are rejected. Project-owned install paths, services, executables, and archives use underscores. The checkout and Git URL remain `product-vamp`. On upgrades, the installer validates and probes the incoming image before removing the old hyphenated Product Vamp paths, services, and network profile. Unrelated services, networks, and data are preserved; symlinked installation paths are rejected.
@@ -31,9 +31,13 @@ Defaults:
- Zero IP: `192.168.42.1` - Zero IP: `192.168.42.1`
- API: `http://192.168.42.1:8080` - API: `http://192.168.42.1:8080`
For API-only quick checks, set `runtime.headless` to `true` or run `vamp_zero --once` from a development build. To run the service without its display, set `runtime.headless` to `true`; audio is controlled separately by `audio.enabled`. For a readiness check that validates configuration, catalog, page/audio assets, runtime writes, and server binding without opening hardware, run `python3 modulus.py run` from the configured development checkout. This invokes `vamp_zero --once` with the development configuration and exits after the checks. On an installed Zero, probe the running service with:
Score inputs are tracked under `data/songs/`. 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. ```sh
sudo docker exec product_vamp_zero /usr/bin/vamp_zero --config /etc/product_vamp/vamp.json --health-check
```
Score inputs are tracked under `data/songs/`. The build/package step generates acoustic, electric, and vocal parts, engraved pages, supplied WAV recordings, 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
@@ -47,10 +51,14 @@ to the mounted `RPI-RP2` drive.
The default firmware connects to SSID `Vamp Control` with passphrase `vamp-control`, then talks to `192.168.42.1:8080`. The default firmware connects to SSID `Vamp Control` with passphrase `vamp-control`, then talks to `192.168.42.1:8080`.
The Pico screen is landscape. Its top bar is blue while connecting, yellow while retrying or when the Zero API is unavailable, and green when the Zero responds. The area below the bar stays blank until a score is active, then it shows the title, selected part, and page count. The Pico screen is landscape and uses the shared dark theme. The header's connection indicator is green when connected, amber while connecting or offline, and red on a Wi-Fi initialization error; status text distinguishes connecting, offline, error, and busy states. The body shows the focused menu item, song title, or setting and its value. In score view it shows the title, selected part, performance phase, BPM, page count, and hold-to-exit hint. The palette and Petaluma Script lettering match the engraved sheet music.
Controls: Controls:
- Tap: next menu item or next page. - Tap: next menu item, or next page when both audio toggles are off.
- Double tap: previous menu item or previous page. - Double tap: previous menu item, or previous page when both audio toggles are off.
- Tap and hold: select the highlighted menu row, or return from score view to the previous menu. - Tap and hold: select the highlighted menu row, enter/leave tempo editing, or return from score view to the part menu. While editing tempo, tap increases BPM and double tap decreases it.
Settings has independent Metronome and Backing toggles. Either starts automatic playback when a part opens; both off enables manual reading. Backing plays the recorded count-in and held ending. Metronome clicks accompany the score but stop before the three-bar held tail. Hold exits at any time.
Keep the two metronome samples under `package/assets/sounds/`. Put song recordings under `package/assets/backing/`, named `<normalized_song_id>_<bpm>.wav`. Supply separate files at 60, 70, 80, 90, 100, 110 and 120 BPM, each with one bar of count-in, the complete eight-bar intro plus five body passes, and its held ending. Rebuild/package after adding recordings. Backing tempo choices include only tempos supplied for every song; the current package has 120 BPM for both songs.
+35 -37
View File
@@ -10,8 +10,8 @@ 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` and `src/vamp_style.cpp`: shared visual palette. `include/vamp_pico/artwork.hpp` defines the Pico coverage-image and sprite interfaces; fixed artwork is generated in the build directory. - `include/vamp_style.hpp` and `src/vamp_style.cpp`: shared visual palette. `include/vamp_pico/artwork.hpp` defines the Pico coverage-image and sprite interfaces; fixed artwork is generated in the build directory.
- `data`: original songs, paired training solos, the frozen solo model, and harmonic/accompaniment policies. Expected-output fixtures live under `tests/`. - `data`: original songs, paired training solos, the frozen solo model, and harmonic/accompaniment policies. Expected-output fixtures live under `tests/`.
- `generator`: build-time Python rhythm planning, part generation, guitar placement, engraving, audio compilation, Pico artwork, and catalog construction. - `generator`: build-time Python source analysis, independent role generation, authored solo reuse, learned prediction, guitar placement, engraving, recording validation, Pico artwork, and catalog construction.
- `package`: generic bundle inputs, scripts, config, static font assets, and device install files. - `package`: generic bundle inputs, scripts, config, fonts, metronome samples and backing recordings, and device install files.
## Zero Runtime ## Zero Runtime
@@ -37,9 +37,9 @@ 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 `catalog.db` application identifier and required table structure, opens it read-only through Noto, and loads menu metadata. It never creates or migrates a database, scans score JSON, or repairs metadata. The main menu contains Performance and Settings. Settings holds Playback, Metronome, and Tempo; its values apply for the current session. Song -> Part -> Score navigation offers 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 required table structure, opens it read-only through Noto, and loads menu metadata. It never creates or migrates a database, scans score JSON, or repairs metadata. The main menu contains Performance and Settings. Settings holds Backing, Metronome, and Tempo; its values apply for the current session. Song -> Part -> Score navigation offers 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 catalogs contain immutable metadata shared by navigation, the HTTP service, and the UI. Song groups reference canonical records by index. SQLite retains complete bundles, while cached measure/page counts let menu queries avoid reading musical JSON. Python constructs the complete database in one transaction before deployment; metadata and embedded JSON are published together after an integrity check. The single `songs` table uses strict column types, primary-key and path uniqueness constraints, and JSON validity checks. Menu rows are ordered by title, composer, part, and ID with binary collation, using the matching `songs_menu` index; C++ groups the ordered rows in one pass without sorting them again. Noto provides read-only connections, bound score lookups, application-ID checks, and quick integrity checks. Product-specific schema and asset-path validation remain in the application. 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. SQLite retains complete bundles, while cached measure/page counts let menu queries avoid reading musical JSON. Python constructs the complete database in one transaction before deployment; metadata and embedded JSON are published together after an integrity check. The `songs` table uses strict column types, primary-key and path uniqueness constraints, and JSON validity checks. Menu rows are ordered by title, composer, part, and ID with binary collation, using the matching `songs_menu` index; C++ groups the ordered rows in one pass without sorting them again. Noto provides read-only connections, bound score lookups, application-ID checks, and quick integrity checks. Product-specific schema and asset-path validation remain in the application. 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.
`--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. `--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.
@@ -55,7 +55,7 @@ The build runs `./modulus.py python -m generator` against `data/` and writes bui
Songs live directly in `data/songs/`. Each source produces acoustic, electric, and vocal parts under `songs/`, with matching page directories. Adding, renaming, or removing a source requires no registry edit. Regeneration replaces the output tree and catalog. The two current songs generate six parts. Songs live directly in `data/songs/`. Each source produces acoustic, electric, and vocal parts under `songs/`, with matching page directories. Adding, renaming, or removing a source requires no registry edit. Regeneration replaces the output tree and catalog. The two current songs generate six parts.
The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model and rhythm plans -> part generation -> semantic JSON, audio, engraved pages, and Pico title artwork -> packaged SQLite catalog and assets. 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. Packaged music describes each generated part, not an archival copy of the XML. The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model and rhythm plans -> part generation -> semantic JSON, supplied WAVs, engraved pages, and Pico title artwork -> packaged SQLite catalog and assets. 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. Packaged music describes each generated part, not an archival copy of the XML.
### Musical Data ### Musical Data
@@ -93,17 +93,17 @@ Standard MusicXML kinds and explicit degrees remain authoritative. For Soundslic
Musical genres and player-style presets are removed. There is no default genre, alias table, generated `style` metadata, or genre selection. The strict importer rejects every obsolete `vamp:style` miscellaneous-field hint, including an empty value, instead of discarding it. The XML writer does not emit that field. Keys, modes, keyless notation, and local changes remain preserved; there is no transposition reference or `vamp:tonic` property. Visual styling, font attributes, engraving `Style`, presentation fingerprints, and ordinary `<bar-style>` notation are unrelated and remain supported. Musical genres and player-style presets are removed. There is no default genre, alias table, generated `style` metadata, or genre selection. The strict importer rejects every obsolete `vamp:style` miscellaneous-field hint, including an empty value, instead of discarding it. The XML writer does not emit that field. Keys, modes, keyless notation, and local changes remain preserved; there is no transposition reference or `vamp:tonic` property. Visual styling, font attributes, engraving `Style`, presentation fingerprints, and ordinary `<bar-style>` notation are unrelated and remain supported.
Parenthesized chord symbols mark optional turnarounds, including Soundslice's text-only `(Fm7)` and `(Bb7)` exports. Alternatively, place staff text `vamp:optional-chord` at the same onset as exactly one harmony; this text may be hidden. Earlier arrangement sections retain those chords and their parentheses; the final ending omits them. The normalized harmony stores `parenthesized` display metadata separately from the generator-only `chord_optional` flag. Orphaned/duplicate markers and unknown `vamp:` properties are errors; consumed directives never enter runtime JSON. Parenthesized chord symbols mark optional turnarounds, including Soundslice's text-only `(Fm7)` and `(Bb7)` exports. Alternatively, place staff text `vamp:optional-chord` at the same onset as exactly one harmony; this text may be hidden. Earlier arrangement sections retain those chords and their parentheses; the final vocal pass omits them. The normalized harmony stores `parenthesized` display metadata separately from the generator-only `chord_optional` flag. Orphaned/duplicate markers and unknown `vamp:` properties are errors; consumed directives never enter runtime JSON.
### Linear Reading Order ### Linear Reading Order
There is no repeat analyzer, expansion pass, or repeat/ending engraver. Performance retains the seven-section arranger, writing every section explicitly onto successive pages; neither display nor playback jumps backward. There is no repeat analyzer, expansion pass, or repeat/ending engraver. Performance writes an eight-bar intro and five full passes (vocal, guitar melody, acoustic solo, electric solo, vocal) explicitly onto successive pages; neither display nor playback jumps backward.
Repeat signs, numbered endings, D.C./D.S./coda navigation, and `time-only` ties fail import rather than being ignored or expanded. Write the intended notes and rests explicitly, including any alternate ending. Legacy repeat fields in generated musical documents are rejected; regenerate assets instead of retaining a compatibility interpreter. Full leading-rest measures remain full measures, not inferred pickups. Missing titles fall back to filenames and missing composer credits remain empty, as with the current Soundslice exports. Repeat signs, numbered endings, D.C./D.S./coda navigation, and `time-only` ties fail import rather than being ignored or expanded. Write the intended notes and rests explicitly, including any alternate ending. Legacy repeat fields in generated musical documents are rejected; regenerate assets instead of retaining a compatibility interpreter. Full leading-rest measures remain full measures, not inferred pickups. Missing titles fall back to readable, spaced filename titles and missing composer credits remain empty, as with the current Soundslice exports.
An explicitly marked opening pickup is excluded from the repeated song body. At each vocal or electric melody entry, it is right-aligned within the preceding arrangement bar. Outgoing music continues until the pickup's first sounding note, then releases as the incoming melody takes over; pickup rests do not erase earlier accompaniment. Lyrics, ties into the following downbeat, tuplets, and octave spans retain their timing. Pickup harmony changes also apply to the accompanying parts at their relocated offsets. All parts share the same bar count, and a pickup longer than its destination bar fails generation. Autumn Leaves uses a four-beat padded pickup and a 32-bar body, yielding 176 arrangement bars rather than 181. An explicitly marked opening pickup is excluded from the repeated song body. At each vocal or electric melody entry, it is right-aligned within the preceding arrangement bar. Outgoing music continues until the pickup's first sounding note, then releases as the incoming melody takes over; pickup rests do not erase earlier accompaniment. Lyrics, ties into the following downbeat, tuplets, and octave spans retain their timing. Pickup harmony changes also apply to the accompanying parts at their relocated offsets. All parts share the same bar count, and a pickup longer than its destination bar fails generation. Autumn Leaves uses a four-beat padded pickup and a 32-bar body, yielding 168 arrangement bars, including the eight-bar intro.
Ordinary ties still link adjacent matching tones across measures and systems. Tuplets, independent voices, local staff changes, and manual page navigation are unchanged. Audio sample sustain loops remain an implementation detail for holding a single note, not a score-repeat feature. Ordinary ties still link adjacent matching tones across measures and systems. Tuplets, independent voices, local staff changes, and manual page navigation are unchanged. The recording’s three-bar sustain tail has no added score section or metronome clicks.
### Runtime Schema ### Runtime Schema
@@ -111,7 +111,7 @@ A newly generated bundle has three top-level fields:
- `music`: metadata and a single staff containing measures, independent voice lanes, timed harmonies/attributes/tempos, instruments, tuplets, and octave spans. - `music`: metadata and a single staff containing measures, independent voice lanes, timed harmonies/attributes/tempos, instruments, tuplets, and octave spans.
- `presentation`: engraver name, style checksum, fixed width/height, ordered relative PNG paths with SHA-256 checksums, and the `title_image` content identifier for Pico artwork. - `presentation`: engraver name, style checksum, fixed width/height, ordered relative PNG paths with SHA-256 checksums, and the `title_image` content identifier for Pico artwork.
- `playback`: availability, exact performed beat duration/events, shared PCM sample descriptors, and a generated release envelope. Unavailable interpretations carry a reason instead of an approximate timeline. - `performance`: normalized source song ID, exact score duration, count-in length and metronome beat/accent positions. Each presentation page additionally stores `first_measure`, `measure_count`, `start_beat` and `end_beat`, derived from its actual engraved systems. These ranges must cover every measure once, without gaps.
There are no authored-key/selected-key metadata pairs or musical genre/style fields. The score's key metadata describes its actual music, and menus display that key alongside its part name. Instrument normalization transforms explicit key cancellations with the same interval as the notes. Presentation's `style_sha256` identifies visual engraving settings and fonts, not a musical genre. There are no authored-key/selected-key metadata pairs or musical genre/style fields. The score's key metadata describes its actual music, and menus display that key alongside its part name. Instrument normalization transforms explicit key cancellations with the same interval as the notes. Presentation's `style_sha256` identifies visual engraving settings and fonts, not a musical genre.
@@ -121,25 +121,21 @@ Measure durations, event onsets/durations, harmony offsets, span times, and pitc
`production.generate_role` accepts immutable canonical source analysis, a role, production data and settings. Canonical musical identity excludes display titles and filenames. Accompaniment, source melody, vocals and each solo have their own caches and can run in independent worker processes. No role reads another generated role. Assembly applies source-defined participation windows, including guitar-role exclusions and returning pickups, without changing another role's decisions. `production.generate_role` accepts immutable canonical source analysis, a role, production data and settings. Canonical musical identity excludes display titles and filenames. Accompaniment, source melody, vocals and each solo have their own caches and can run in independent worker processes. No role reads another generated role. Assembly applies source-defined participation windows, including guitar-role exclusions and returning pickups, without changing another role's decisions.
`parts.py` retains exact source notes, rests, lyrics and ties. `melody.py` harmonizes sustained electric source-melody notes using their actual chord, without changing the head's pitch or rhythm. Guitar notation applies its octave transposition once. `comping.Canonical` independently chooses compact chord voicings and rhythm from the source form and shared policy, then reuses that part at the same source-form position throughout the arrangement. There is no generated-solo response or reuse of a generated melody's grips. Sparse bars and deliberate releases are legal. `parts.py` retains exact source notes, rests, lyrics and ties. `melody.py` harmonizes eligible sustained electric source-melody notes using their actual chord, preserving source pitches while splitting and reattacking held notes at harmony changes. Guitar notation applies its octave transposition once. `comping.Canonical` independently chooses compact chord voicings and rhythm from the source form and shared policy, then reuses that part at the same source-form position throughout the arrangement. There is no generated-solo response or reuse of a generated melody's grips. Sparse bars and deliberate releases are legal.
Monophonic solos use strings 1–6 and frets 1–12, with no open strings or acoustic/electric string restrictions. Placement compares complete paths through legal positions, accounting for hand-region changes, travel time and string crossings. Ties retain their position. Chord placement retains its separate policy: frets 1–20 with a sounding anchor at fret 12 or below and the existing inclusive three-fret span. These costs approximate physical feasibility; they do not assign fingers or guarantee comfortable playing. Monophonic solos use strings 1–6 and frets 1–12, with no open strings or acoustic/electric string restrictions. Placement compares complete paths through legal positions, accounting for hand-region changes, travel time and string crossings. Ties retain their position. Chord placement retains its separate policy: frets 1–20 with a sounding anchor at fret 12 or below and the existing inclusive three-fret span. These costs approximate physical feasibility; they do not assign fingers or guarantee comfortable playing.
### Playback Boundary ### Recording Playback and Page Timing
All musical compilation and tone synthesis run in Python (`generator/playback.py`). Each transformed score receives a sorted timeline of independent tone events with rational quarter-note `onset` and `end` positions. Ties become one continuous attack. Rests and spacers remain silence, including trailing silence. Harmony labels are not auto-accompaniment: only pitches actually present in the selected arrangement sound. Cue notes are silent, hidden pitched notes sound, and pitchless rhythm slashes are silent. Vamp uses only supplied PCM16 WAVs. `package/assets/sounds/beat1.wav` and `beat2.wav` provide measure accents and ordinary beats. Backing files are `package/assets/backing/<normalized_source_id>_<bpm>.wav`. `generator/performance.py` checks containment, format, decoded length and checksums, then copies the recordings byte for byte into the staged output. A catalog `resources` row stores their paths, checksums, sample rates, channels, frame counts, song IDs and tempos. No note-event audio compiler, pitch synthesizer, release envelope or generated audition audio remains. Pitch mathematics remains for notation, TAB, composition and exact MusicXML comparisons.
The `soft_struck` profile generates shared mono 48 kHz PCM16 WAVs with a short attack/decay and a seamless sustain loop, plus a shared 50 ms release envelope. Exact sounding pitches, including supported microtones, determine samples. Instrument transposition is applied once from the generated musical model, without role- or filename-dependent octave heuristics. Samples are shared across scores and voices by content identity. There are no SoundFonts, bundled instrument samples, oscillators, or MusicXML parsing at runtime. Settings exposes `audio.backing_enabled`, `audio.metronome_enabled` and `audio.tempo_bpm`; both toggles default off and tempo defaults to 120. The fixed tempo set is 60 through 120 in steps of 10. Backing restricts it to the intersection available for all catalog songs. Turning it on snaps to the nearest available tempo, with ties going slower; an empty intersection makes it unavailable. Obsolete playback and configurable tempo-range settings are rejected. Recordings are never stretched or trimmed.
Grace groups honor explicit steal-time-previous/following or make-time; unspecified groups share 25 percent of the following event. Conflicting grace modes, trailing grace groups, unsupported unpitched sound, tied measured tremolo, and incompatible paired tremolo make playback explicitly unavailable while retaining the notation. Measured tremolo uses written subdivision and tuplet ratios. The tone range is 20-18,000 Hz. Asset validation checks containment, SHA-256, PCM format/length, loop endpoints, envelope shape, event ordering, references, and rational timing. Generation remains transactional if validation fails. The existing recordings contain one bar of count-in, 168 score bars, then three held bars and decay. Backing-only plays the complete file. Both toggles play the recorded count-in alone, then backing plus sample clicks during the score; no clicks play over the held ending. Metronome-only supplies its own one-bar count-in and completes at score end. Both off enables manual page navigation. Opening a part with either toggle on starts automatic mode. Next/previous are ignored during automatic mode; hold exits, and natural completion returns to the selected part menu. The final page remains displayed through a recording tail.
When grace groups steal from both ends of one donor, their adjustments accumulate against its original duration. Consuming the entire donor or any chord tone makes playback unavailable rather than overlapping the grace group and principal note. Preparation runs on a worker: checksums and samples are loaded before publishing a Sonus recording transport. Sonus streams backing audio through miniaudio’s background resource manager and mixes preloaded stereo clicks on the same rendered-frame clock. Callback work uses preallocated buffers, and resource retirement occurs on control threads. Stream failures stop progress and surface an error. Absolute rational beat positions are rounded to frames at 48 kHz once per position, avoiding accumulated rounding drift. The UI polls that audio clock for page boundaries and Count-in/Playing/Sustain status; wall-clock timers never determine musical progress.
At runtime the shared on-demand score loader serves both display and audio. A background worker validates and loads only the selected score's samples, converts absolute beat positions to rounded sample frames at the menu BPM, and prepares a Sonus sequence. Pitch does not change with BPM, and rounding absolute positions avoids cumulative timing drift. The I/O loop publishes the prepared sequence and polls completion without doing disk I/O or musical interpretation. Sonus mixes preallocated voices from immutable PCM and retires them on control threads; the callback never synthesizes tones or loads files. Limits are one million events, 512 distinct samples, 64 MiB decoded samples per selection, 64 simultaneous voices including releases, and 24 hours at the selected BPM. Exceeding them reports an error rather than dropping notes. Page metadata comes from actual system pagination and is preserved and revalidated in engraving caches. The UI prepares the current and next page before playback starts and retains a two-texture cache. Session and catalog identities reject stale preparation, page updates and completion after cancellation. Missing or corrupt pages/audio leave a visible error and permit hold/back. `--once` validates packaged recording checksums and all score pages without opening audio/display hardware.
`audio.playback_enabled` defaults to false. The obsolete `track_enabled` setting is rejected with a diagnostic naming its replacement. Playback suppresses the metronome without changing its stored toggle. Authored tempo data remains in the musical document but is not printed; menu BPM is constant throughout a session. Selecting a 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 catalog; the source XML and Python dependencies remain build-only.
### Authored Repertoire and Learned Solos ### Authored Repertoire and Learned Solos
@@ -160,7 +156,7 @@ Fixed scale maps no longer constrain prediction. Brief ordinary movement uses th
Prediction proposes stable and resolving colors, anticipated colors, delayed approaches with real space or intervening valid motion, and chromatic passing notes between valid endpoints, alongside the existing complete enclosures and connections. These are joint actions containing their required destinations. Disabling expressions removes these options. Prepared suspensions are recognized and validated but do not yet have a dedicated prediction action. The model still selects from its small learned distribution; validation establishes supported musical relationships, not aesthetic quality. Prediction proposes stable and resolving colors, anticipated colors, delayed approaches with real space or intervening valid motion, and chromatic passing notes between valid endpoints, alongside the existing complete enclosures and connections. These are joint actions containing their required destinations. Disabling expressions removes these options. Prepared suspensions are recognized and validated but do not yet have a dedicated prediction action. The model still selects from its small learned distribution; validation establishes supported musical relationships, not aesthetic quality.
Training checks the complete source-form harmonic context. In the repeated performance form, an unlabelled pickup inherits the final source-form chord; an explicit pickup chord or `N.C.` takes precedence. This is the same arrangement context used for playback, not an invented first chord or a rule tied to either song name. Training checks the complete source-form harmonic context. In the repeated performance form, an unlabeled pickup inherits the final source-form chord; an explicit pickup chord or `N.C.` takes precedence. This is the same arrangement context used for playback, not an invented first chord or a rule tied to either song name.
The model has only four examples from two songs. Its four-event context does not establish long-range thematic memory, nor does training prove transfer to another standard. Phrase report boundaries describe audible breaths after selection; they are not another composer or an assertion of phrase-wide theme planning. This initial implementation can still repeat local patterns or choose weak continuations on unfamiliar harmony. Exact authored playback and novel model output are reported separately. The model has only four examples from two songs. Its four-event context does not establish long-range thematic memory, nor does training prove transfer to another standard. Phrase report boundaries describe audible breaths after selection; they are not another composer or an assertion of phrase-wide theme planning. This initial implementation can still repeat local patterns or choose weak continuations on unfamiliar harmony. Exact authored playback and novel model output are reported separately.
@@ -170,6 +166,8 @@ Train explicitly:
./modulus.py python -m generator.train --leave-one-song-out ./modulus.py python -m generator.train --leave-one-song-out
``` ```
This writes the pooled model to `data/models/solo.json` and diagnostics to `build/training/report.json`. Omit `--leave-one-song-out` to train without held-out likelihood diagnostics. Ordinary asset builds never run training.
Register an additional pair and retrain without changing Python: Register an additional pair and retrain without changing Python:
```sh ```sh
@@ -179,9 +177,9 @@ Register an additional pair and retrain without changing Python:
Files are copied into `data/songs` and `data/training` without overwriting different contents. Validation checks the exact measure/harmony correspondence, contextual harmonic validity, guitar-written convention and fret range before publishing the manifest. An existing source without a registered slot uses model prediction for that slot. Use `--pitch-convention encoded` only for files whose own transposition describes sounding pitch. Updating existing examples means editing their XML/manifest deliberately and rerunning training. The report under `build/training/report.json` records automatically recognized harmonic treatments, teacher-forced likelihood and optional leave-one-song-out likelihood; none is a listening assessment or unassisted-generation accuracy. Files are copied into `data/songs` and `data/training` without overwriting different contents. Validation checks the exact measure/harmony correspondence, contextual harmonic validity, guitar-written convention and fret range before publishing the manifest. An existing source without a registered slot uses model prediction for that slot. Use `--pitch-convention encoded` only for files whose own transposition describes sounding pitch. Updating existing examples means editing their XML/manifest deliberately and rerunning training. The report under `build/training/report.json` records automatically recognized harmonic treatments, teacher-forced likelihood and optional leave-one-song-out likelihood; none is a listening assessment or unassisted-generation accuracy.
Production input traces distinguish authored reuse from learned prediction. Caches include source, role, relevant data and code; authored cache keys include the selected XML's content and convention. Build and package inputs now include the model, manifest and authored repertoire. Changes to another solo do not change an already registered part's notes. Updating the pooled model can legitimately change predictions for any unregistered part. Build-time composition defaults to two independent processes; rendering and runtime remain unchanged. Production input traces distinguish authored reuse from learned prediction. Caches include source, role, relevant data and code; authored cache keys include the selected XML's content and convention. Build and package inputs include the model, manifest and authored repertoire. Changes to another solo do not change an already registered part's notes. Updating the pooled model can legitimately change predictions for any unregistered part. CMake defaults to two composition processes (`VAMP_COMPOSITION_JOBS`, 1–16) and caches each role under `build/build_common/composition_cache`. Direct generator runs use `--composition-jobs` and opt into persistent caching with `--composition-cache`; single-role builds use `--cache-dir`.
The generated `solo_xml/*-solo1.xml` and `*-solo2.xml` exports use local measure numbers, preserve pickups, and store whole-arrangement positions in `manifest.solo`. Exact reuse means complete sounding pitches, onsets, releases, repeated attacks and silence agree. XML whitespace, note splitting, measure labels and exporter-specific annotation containers need not be identical. Optional `python -m tests.solo_compare` compares these completed exports; it never feeds a production decision. Training and authored reuse are the explicitly permitted dependencies on the supplied music. The generated `solo_xml/*-solo1.xml` and `*-solo2.xml` exports use local measure numbers, preserve pickups, and store whole-arrangement positions in `manifest.solo`. Exact reuse means complete sounding pitches, onsets, releases, repeated attacks and silence agree. XML whitespace, note splitting, measure labels and exporter-specific annotation containers need not be identical. Optional `./modulus.py python -m tests.solo_compare` compares these completed exports; it never feeds a production decision. Its `--generated-dir` is the directory containing `manifest.solo`, and `--reference`/`--form-start` use the manifest's `SONG/ROLE` keys. `--require-exact` fails if any requested reference differs in complete sounding events or rests. Training and authored reuse are the explicitly permitted dependencies on the supplied music.
Migration inventory: Migration inventory:
@@ -207,7 +205,7 @@ Forced prediction, without reading the training XMLs, completed all four source/
`generator/engraving/` is a custom Python engraver, not a wrapper around MuseScore or another notation engine. `generator.engraving.layout.layout_score(document, style, fonts)` produces immutable layout pages with exact source intervals, notation anchors and drawing primitives. `generator.engraving.raster.rasterize_page(page, fonts)` draws one page at four-times resolution and downsamples it. `generator.engraving.pipeline.engrave_tree(root, fonts)` is the asset-pipeline entry point. `fonts.Fonts`, `types.Style`, and `types.EngravingError` live in their respective engraving modules; page constants and `check_page` live in `validation`. Package initializers do not re-export these APIs. `generator/musicxml_writer.py` remains a round-trip/export utility and is not involved in engraving. `generator/engraving/` is a custom Python engraver, not a wrapper around MuseScore or another notation engine. `generator.engraving.layout.layout_score(document, style, fonts)` produces immutable layout pages with exact source intervals, notation anchors and drawing primitives. `generator.engraving.raster.rasterize_page(page, fonts)` draws one page at four-times resolution and downsamples it. `generator.engraving.pipeline.engrave_tree(root, fonts)` is the asset-pipeline entry point. `fonts.Fonts`, `types.Style`, and `types.EngravingError` live in their respective engraving modules; page constants and `check_page` live in `validation`. Package initializers do not re-export these APIs. `generator/musicxml_writer.py` remains a round-trip/export utility and is not involved in engraving.
Engraving supports a bounded process pool (`VAMP_ENGRAVING_JOBS`, default 2) and a build-only content-addressed page cache (`VAMP_ENGRAVING_CACHE_DIR`, default `build/build_common/engraving_cache`). Keys include the complete musical document, engraving implementation, fonts/metadata, style, Python and rendering dependency versions. Playback is deliberately excluded from page keys and is always rebuilt independently. Cache hits are copied into the current staged output and checked for checksums, PNG decoding, dimensions, blank pages and clipped ink. Missing or corrupt entries are regenerated. All semantic and asset validation, database construction and transactional publication remain in place. The cache is not installed and can be deleted at any time when builds are idle. CI saves it after the native build and reuses it for ARM packaging; generation prints stage timing and per-worker progress instead of remaining silent during engraving. Engraving supports a bounded process pool (`VAMP_ENGRAVING_JOBS`, default 2) and a build-only content-addressed page cache (`VAMP_ENGRAVING_CACHE_DIR`, default `build/build_common/engraving_cache`). Keys include the complete musical document, engraving implementation, fonts/metadata, style, Python and rendering dependency versions. Page timing is included in cached manifests and checked against exact measure boundaries; corrupt coverage is rebuilt. Recording files are copied independently of the page cache. Cache hits are copied into the current staged output and checked for checksums, PNG decoding, dimensions, blank pages and clipped ink. Missing or corrupt entries are regenerated. All semantic and asset validation, database construction and transactional publication remain in place. The cache is not installed and can be deleted at any time when builds are idle. CI saves it after the native build and reuses it for ARM packaging; generation prints stage timing and per-worker progress instead of remaining silent during engraving.
Reuse has two levels. A full-score hit restores complete page images for the same musical document and metadata. A notation-body group shares system layout and body pixels among documents with identical staff content, while retaining each document's title, subtitle, pagination, footer, and complete semantic bundle. Body keys include the rendering context and actual page-band geometry; incompatible placement cannot reuse those pixels. Each header/footer is composed separately, and pages whose drawing primitives cannot be separated safely use full-page rasterization. This reuse never merges score IDs, catalog records, or authored music. Reuse has two levels. A full-score hit restores complete page images for the same musical document and metadata. A notation-body group shares system layout and body pixels among documents with identical staff content, while retaining each document's title, subtitle, pagination, footer, and complete semantic bundle. Body keys include the rendering context and actual page-band geometry; incompatible placement cannot reuse those pixels. Each header/footer is composed separately, and pages whose drawing primitives cannot be separated safely use full-page rasterization. This reuse never merges score IDs, catalog records, or authored music.
@@ -223,15 +221,15 @@ Line breaks occur only at measure boundaries. Each line fits as many complete me
`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 and spans. It enforces consistent notation size throughout a part and header/footer clearance, and 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 and spans. It enforces consistent notation size throughout a part and header/footer clearance, and 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 preserve the complete `music` object. Presentation identifies the `Vamp Python` engraver; its style fingerprint includes the style settings, both font files, font-specific metadata and the glyph-name mapping. Scores, playback manifests, and the catalog have no numbered schema labels. Required fields, types, relationships, dimensions, and checksums determine whether generated data is usable. Incompatible data is rejected, never migrated or guessed. Bundles preserve the complete `music` object. Presentation identifies the `Vamp Python` engraver; its style fingerprint includes the style settings, both font files, font-specific metadata and the glyph-name mapping. Scores, performance metadata, and the catalog have no numbered schema labels. Required fields, types, relationships, dimensions, and checksums determine whether generated data is usable. Incompatible data is rejected, never migrated or guessed.
Pages are 976x484 pixels with 10-pixel guitar or 12-pixel vocal staff spacing, 28-pixel horizontal margins and 18-pixel vertical margins. The two acoustic rest lanes sit closer to the staff, with collision offsets preserving their separation. 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 nominal 10-pixel guitar or 12-pixel vocal staff spacing, 28-pixel horizontal margins and 18-pixel vertical margins. The two acoustic rest lanes sit closer to the staff, with collision offsets preserving their separation. Systems and pages contain complete measures; tied notes and notation spans may continue across their boundaries. Presentation fragments preserve exact timing and never change the semantic JSON. A measure that cannot fit at readable spacing fails with source and measure context; the uniform part-wide scaling described above fits pairs of complete systems vertically. 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 Publication ### Validation And Publication
Generation validates sources and complete staged output before replacing the previous asset directory. Replacement is always complete; deleted sources cannot leave stale JSON, pages, audio, or Pico title assets. 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 and title-image identifiers, PNG pages, generated PCM WAVs, Pico title images, 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. Generation validates sources and complete staged output before replacing the previous asset directory. Replacement is always complete; deleted sources cannot leave stale JSON, pages, audio, or Pico title assets. 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 and title-image identifiers, PNG pages, supplied WAV recordings, Pico title images, 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.
The page cache uses `<renderer fingerprint>/fullscore/<score fingerprint>/` and `<renderer fingerprint>/notation/<band fingerprint>/` directories, without a manual cache revision. `./modulus.py python -m generator --renderer-fingerprint` reports the same identity used by rendering and CI: source bytes, fonts, visual style, Python implementation/version, and rendering-library versions. Python compiler/build timestamps are excluded. CI keys append all generator and active XML inputs, including rhythms, and restore earlier snapshots only within the same renderer directory; only that directory is saved after a successful build. The page cache uses `<renderer fingerprint>/fullscore/<score fingerprint>/` and `<renderer fingerprint>/notation/<band fingerprint>/` directories, without a manual cache revision. `./modulus.py python -m generator --renderer-fingerprint` reports the same identity used by rendering and CI: source bytes, fonts, visual style, Python implementation/version, and rendering-library versions. Python compiler/build timestamps are excluded. CI keys append generator code, the palette, source songs, composition policies, the frozen model, the training manifest, and authored solo XMLs. Earlier snapshots are restored only within the same renderer directory; only that directory is saved after a successful build.
`cold namespace` means that renderer's persistent directory does not yet exist. A renderer or environment change starts a new namespace; deleting a namespace has the same effect. It does not disable reuse within the current run: shared notation is still laid out once per group and its body pixels can be reused as other titles are composed, even without a persistent cache. Conversely, a warm directory does not guarantee that a particular score is cached. Progress reports full-score hits, body hits, newly rasterized body bands, composed pages, missing/rejected entries, and phase timings. Worker phase timings are summed and may exceed wall time. Obsolete namespaces are not migrated and can be removed when builds are idle. `cold namespace` means that renderer's persistent directory does not yet exist. A renderer or environment change starts a new namespace; deleting a namespace has the same effect. It does not disable reuse within the current run: shared notation is still laid out once per group and its body pixels can be reused as other titles are composed, even without a persistent cache. Conversely, a warm directory does not guarantee that a particular score is cached. Progress reports full-score hits, body hits, newly rasterized body bands, composed pages, missing/rejected entries, and phase timings. Worker phase timings are summed and may exceed wall time. Obsolete namespaces are not migrated and can be removed when builds are idle.
@@ -247,13 +245,13 @@ Generator checks compare the generated performance summary against `tests/fixtur
The Pico firmware uses the Pico SDK C++ stack and the `pico2_w` board definition. It initializes the ST7789T3 display over SPI, reads the CST816D touch controller over I2C, joins the Zero AP, polls `/api/remote/display`, and sends HTTP POST commands for gestures. The Pico firmware uses the Pico SDK C++ stack and the `pico2_w` board definition. It initializes the ST7789T3 display over SPI, reads the CST816D touch controller over I2C, joins the Zero AP, polls `/api/remote/display`, and sends HTTP POST commands for gestures.
The display is rotated 90 degrees clockwise into a 320x240 landscape UI. The top status bar uses the shared palette: The display is rotated 90 degrees clockwise into a 320x240 landscape UI. A small connection indicator in the header uses the shared palette:
- Blue: connecting. - Amber (`retrying`): connecting, offline, or a remote request failed.
- Yellow: retrying or remote request failed. - Green (`connected`): connected to the Zero API.
- Green: connected to the Zero API. - Red (`danger`): Wi-Fi initialization failed.
The body shows the focused menu item, song title, or setting and its current value. In score view it shows the active title, part, and page count. Both devices use a light, score-like palette. Touch controls are tap for next, double tap for previous, and tap-and-hold for select. All visible text uses Petaluma Script, matching score-page text and the Zero UI. The body shows the focused menu item, song title, or setting and its current value. In score view it shows the active title, part, and page count. A separate status label displays connecting, offline, error, or busy when applicable. Both devices and score pages use the dark palette in `generator/palette.json`. Touch controls are tap for next, double tap for previous, and tap-and-hold for select. Holding Tempo enters or leaves editing; taps then increase BPM and double taps decrease it. All visible text uses Petaluma Script, matching score-page text and the Zero UI.
`generator.pico_artwork` shapes text on the build host with the bundled `PetalumaScript.otf` and Pillow's Raqm support. Fixed menu, part, connection/status, and settings labels, digits, and `/` become four-bit coverage sprites. The Pico CMake rule writes `generated/pico/static.p4` and embeds it through `generated/pico/artwork.S`. The Zero does not link this sprite atlas; Luma renders its dynamic UI text from the packaged OTF. `generator.pico_artwork` shapes text on the build host with the bundled `PetalumaScript.otf` and Pillow's Raqm support. Fixed menu, part, connection/status, and settings labels, digits, and `/` become four-bit coverage sprites. The Pico CMake rule writes `generated/pico/static.p4` and embeds it through `generated/pico/artwork.S`. The Zero does not link this sprite atlas; Luma renders its dynamic UI text from the packaged OTF.
@@ -284,7 +282,7 @@ 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/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`, complete score bundles, and menu metadata. Runtime validates both required strict tables, their column names/types, non-null requirements, and primary keys directly. 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, recording, and engraving validation. The database carries application ID `0x56414D50`, complete score bundles, and menu metadata in a strict `songs` table with a `songs_menu` index; a strict `resources` table contains the supplied-audio manifest. Runtime validates the table's column names/types, non-null requirements, and primary key directly. 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/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. 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.
@@ -292,7 +290,7 @@ Every run of `zero/install_zero.sh` is a clean installation. It stages configura
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. 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 stable target-specific archives with `python3 modulus.py package`, then `package/scripts/publish_latest_generic_package.py` uploads exactly the configured targets and their checksums. Gitea requires a registry slot, so publishing replaces `latest` and removes obsolete remote entries. There is no application release number, artifact version argument, or versioned install directory. The manifest, heartbeat, status endpoint, and `--build-info` retain the Git commit for diagnostics. Conan references, compiler/dependency pins, and MusicXML protocol identifiers remain external tool requirements. CI builds stable target-specific archives with `python3 modulus.py package`, then `python3 modulus.py publish` uses Modulus's artifact publisher to upload exactly the configured targets and their checksums. Gitea requires a registry slot, so publishing replaces `latest` and removes obsolete remote entries. There is no application release number, artifact version argument, or versioned install directory. The manifest, runtime heartbeat file, and `--build-info` retain the Git commit for diagnostics. The health endpoint reports readiness; remote display responses contain device navigation state. Conan references, compiler/dependency pins, and MusicXML protocol identifiers remain external tool requirements.
## Shared Protocol ## Shared Protocol
+6 -4
View File
@@ -24,8 +24,8 @@ check_cxx_source_compiles("
#include <sonus.hpp> #include <sonus.hpp>
#include <luma.hpp> #include <luma.hpp>
#include <utility> #include <utility>
static_assert(requires(sonus::engine& engine, sonus::prepared_sequence sequence) { static_assert(requires(sonus::engine& engine, sonus::prepared_transport transport) {
engine.play_sequence(std::move(sequence)); engine.play_transport(std::move(transport));
}); });
static_assert(requires(luma::application& application, std::string path) { static_assert(requires(luma::application& application, std::string path) {
application.load_texture_uncached(path); application.load_texture_uncached(path);
@@ -34,7 +34,7 @@ check_cxx_source_compiles("
" VAMP_DEPENDENCY_APIS_AVAILABLE) " VAMP_DEPENDENCY_APIS_AVAILABLE)
cmake_pop_check_state() cmake_pop_check_state()
if(NOT VAMP_DEPENDENCY_APIS_AVAILABLE) if(NOT VAMP_DEPENDENCY_APIS_AVAILABLE)
message(FATAL_ERROR "Product Vamp requires current Sonus sequence and Luma uncached-texture APIs. Run ./modulus.py configure --update after publishing the updated dependencies, or create their Conan packages locally before configuring --no-remote. See CMake's configure log for the compiler diagnostic.") message(FATAL_ERROR "Product Vamp requires current Sonus recording transport and Luma uncached-texture APIs. Run ./modulus.py configure --update after publishing the updated dependencies, or create their Conan packages locally before configuring --no-remote. See CMake's configure log for the compiler diagnostic.")
endif() endif()
execute_process( execute_process(
@@ -90,7 +90,7 @@ set(VAMP_CORE_SOURCES
src/vamp_zero/navigation.cpp src/vamp_zero/navigation.cpp
src/vamp_zero/notation.cpp src/vamp_zero/notation.cpp
src/vamp_zero/page_control.cpp src/vamp_zero/page_control.cpp
src/vamp_zero/playback.cpp src/vamp_zero/performance.cpp
src/vamp_zero/control.cpp src/vamp_zero/control.cpp
src/vamp_zero/score.cpp src/vamp_zero/score.cpp
src/vamp_zero/score_loader.cpp src/vamp_zero/score_loader.cpp
@@ -148,6 +148,8 @@ file(GLOB_RECURSE VAMP_GENERATOR_INPUTS CONFIGURE_DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/generator/theme.py ${CMAKE_CURRENT_SOURCE_DIR}/generator/theme.py
${CMAKE_CURRENT_SOURCE_DIR}/cmake/build_python.cmake ${CMAKE_CURRENT_SOURCE_DIR}/cmake/build_python.cmake
${CMAKE_CURRENT_SOURCE_DIR}/cmake/theme.cmake ${CMAKE_CURRENT_SOURCE_DIR}/cmake/theme.cmake
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/sounds/*.wav
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/backing/*.wav
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/* ${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/*
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/**/* ${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/**/*
) )
+16 -11
View File
@@ -17,9 +17,12 @@ import generator.engraving.pipeline
import generator.engraving.types import generator.engraving.types
import generator.parts import generator.parts
import generator.pico_artwork import generator.pico_artwork
import generator.playback import generator.performance
import generator.score import generator.score
import generator.summary import generator.summary
import generator.solo.export
import platform
import os
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt") RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
@@ -65,12 +68,15 @@ def generate_assets(args: argparse.Namespace) -> None:
raise ValueError('composition cache and generated assets must not overlap') raise ValueError('composition cache and generated assets must not overlap')
progress = getattr(args, 'progress', False) progress = getattr(args, 'progress', False)
started = time.monotonic() started = time.monotonic()
timings=[];stages=[];last_stage=started timings = []
stages = []
last_stage = started
def report(message): def report(message):
nonlocal last_stage nonlocal last_stage
now = time.monotonic() now = time.monotonic()
stages.append({'completed_before':message,'seconds':now-last_stage});last_stage=now stages.append({'completed_before': message, 'seconds': now - last_stage})
last_stage = now
if progress: if progress:
print(f'Assets [{time.monotonic() - started:.0f}s]: {message}', flush=True) print(f'Assets [{time.monotonic() - started:.0f}s]: {message}', flush=True)
@@ -82,8 +88,7 @@ def generate_assets(args: argparse.Namespace) -> None:
staged.mkdir(parents=True, exist_ok=True) staged.mkdir(parents=True, exist_ok=True)
copy_runtime_fonts(static_assets_dir, staged) copy_runtime_fonts(static_assets_dir, staged)
report('generating performance parts') report('generating performance parts')
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', composition_jobs=getattr(args, 'composition_jobs', 2), cache_dir=getattr(args, 'composition_cache', None), progress=progress, timings=timings)
composition_jobs=getattr(args,'composition_jobs',2), cache_dir=getattr(args,'composition_cache',None), progress=progress,timings=timings)
if getattr(args, 'trace_level', 'summary') == 'summary': if getattr(args, 'trace_level', 'summary') == 'summary':
for path in (staged / 'analysis').glob('*.roles'): for path in (staged / 'analysis').glob('*.roles'):
roles = json.loads(path.read_text()) roles = json.loads(path.read_text())
@@ -92,13 +97,14 @@ def generate_assets(args: argparse.Namespace) -> None:
trace.pop('decisions', None) trace.pop('decisions', None)
path.write_text(json.dumps(roles, sort_keys=True, indent=2)) path.write_text(json.dumps(roles, sort_keys=True, indent=2))
if getattr(args, 'export_solo_xml', False): if getattr(args, 'export_solo_xml', False):
from generator.solo.export import export_tree generator.solo.export.export_tree(staged)
export_tree(staged)
generator.score.validate_generated_tree(staged) generator.score.validate_generated_tree(staged)
if args.check: if args.check:
generator.summary.validate_expected_performance_summary(pathlib.Path(__file__).resolve().parents[1] / 'tests' / 'fixtures' / 'performance_summary.json', staged / 'songs') generator.summary.validate_expected_performance_summary(pathlib.Path(__file__).resolve().parents[1] / 'tests' / 'fixtures' / 'performance_summary.json', staged / 'songs')
report('compiling playback') report('copying supplied recordings')
generator.playback.compile_tree(staged) song_ids = {generator.score.slugify(path.stem) for path in (data_dir / 'songs').glob('*.xml')}
audio_assets = generator.performance.copy_audio(static_assets_dir, staged, song_ids)
generator.performance.validate_recording_lengths(staged, audio_assets)
report('engraving pages') report('engraving pages')
validated_pages = generator.engraving.pipeline.engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma', cache_dir=cache_dir, jobs=getattr(args, 'jobs', 1), progress=progress) validated_pages = generator.engraving.pipeline.engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma', cache_dir=cache_dir, jobs=getattr(args, 'jobs', 1), progress=progress)
report('rendering Pico titles') report('rendering Pico titles')
@@ -106,13 +112,12 @@ def generate_assets(args: argparse.Namespace) -> None:
report('validating score, page, and audio assets') report('validating score, page, and audio assets')
generator.score.validate_generated_tree(staged, validated_pages) generator.score.validate_generated_tree(staged, validated_pages)
report('building catalog database') report('building catalog database')
generator.catalog.build_catalog(staged) generator.catalog.build_catalog(staged, audio_assets=audio_assets)
with modulus.filesystem.publish_directories([(staged, output_dir)]): with modulus.filesystem.publish_directories([(staged, output_dir)]):
pass pass
report('publishing complete') report('publishing complete')
profile_path = getattr(args, 'profile_output', None) profile_path = getattr(args, 'profile_output', None)
if profile_path: if profile_path:
import platform,os
profile_path.parent.mkdir(parents=True, exist_ok=True) profile_path.parent.mkdir(parents=True, exist_ok=True)
profile_path.write_text(json.dumps({'total_seconds': time.monotonic() - started, 'stages': stages, 'roles': timings, profile_path.write_text(json.dumps({'total_seconds': time.monotonic() - started, 'stages': stages, 'roles': timings,
'platform': platform.platform(), 'cpus': os.cpu_count(), 'workers': getattr(args, 'composition_jobs', 2)}, indent=2)) 'platform': platform.platform(), 'cpus': os.cpu_count(), 'workers': getattr(args, 'composition_jobs', 2)}, indent=2))
+10 -8
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import copy import copy
from fractions import Fraction as F import fractions
import generator.accompaniment import generator.accompaniment
import generator.acoustic import generator.acoustic
@@ -13,32 +13,34 @@ import generator.fretboard
import generator.model import generator.model
import generator.parts import generator.parts
import generator.score import generator.score
from generator.arrange import form import generator.arrange.form
import generator.arrange.pickup import generator.arrange.pickup
def harmonic_key(measure, previous): def harmonic_key(measure, previous):
def key(chord): def key(chord):
return repr({k: chord.get(k) for k in ('root', 'kind', 'degrees', 'bass', 'inversion')}) if chord else '' return repr({k: chord.get(k) for k in ('root', 'kind', 'degrees', 'bass', 'inversion')}) if chord else ''
active = generator.accompaniment.active_chord(measure, F(), previous)
active = generator.accompaniment.active_chord(measure, fractions.Fraction(), previous)
return (key(active), tuple((generator.model.fraction(h['onset']), key(h)) for h in measure['harmonies'] return (key(active), tuple((generator.model.fraction(h['onset']), key(h)) for h in measure['harmonies']
if generator.model.fraction(h['onset']) > 0), measure['duration'], measure['time_signature']) if generator.model.fraction(h['onset']) > 0), measure['duration'], measure['time_signature'])
def audit(source, documents): def audit(source, documents):
"""Compare music, not generated IDs, at every reused form position.""" """Compare music, not generated IDs, at every reused form position."""
authored = generator.score.source_staff(source)['measures'] authored = generator.score.source_staff(source)['measures']
result = {} result = {}
for part, document in documents.items(): for part, document in documents.items():
if part == 'vocal': continue if part == 'vocal':
sections = form.full_arrangement_sections(authored, part) continue
sections = generator.arrange.form.full_arrangement_sections(authored, part)
measures = document['staves'][0]['measures'] measures = document['staves'][0]['measures']
carried = generator.accompaniment.carried_chords(measures) carried = generator.accompaniment.carried_chords(measures)
seen, exceptions, offset, reused = {}, [], 0, 0 seen, exceptions, offset, reused = {}, [], 0, 0
for si, section in enumerate(sections): for si, section in enumerate(sections):
following = sections[si + 1] if si + 1 < len(sections) else None following = sections[si + 1] if si + 1 < len(sections) else None
for j in range(section.measure_count): for j in range(section.measure_count):
if section.role != 'accompaniment': continue if section.role != 'accompaniment':
continue
m = measures[offset + j] m = measures[offset + j]
if j == section.measure_count - 1 and following and following.role == 'melody' and generator.arrange.pickup.enters(following, authored): if j == section.measure_count - 1 and following and following.role == 'melody' and generator.arrange.pickup.enters(following, authored):
exceptions.append({'measure': m['number'], 'reason': 'protected melody pickup'}) exceptions.append({'measure': m['number'], 'reason': 'protected melody pickup'})
+2 -3
View File
@@ -28,10 +28,10 @@ def section_role(part_type: str, section_index: int) -> typing.Literal["accompan
return "slash" return "slash"
if section_index == 2: if section_index == 2:
return "melody" return "melody"
if section_index in {1, 3, 5, 6}: if section_index in {1, 3, 5}:
return "accompaniment" return "accompaniment"
return "rest" return "rest"
return "vocal" if section_index in {1, 5, 6} else "rest" return "vocal" if section_index in {1, 5} else "rest"
def full_arrangement_sections(measures: list[dict], part_type: str) -> list[ArrangementSection]: def full_arrangement_sections(measures: list[dict], part_type: str) -> list[ArrangementSection]:
if not measures: if not measures:
@@ -49,7 +49,6 @@ def full_arrangement_sections(measures: list[dict], part_type: str) -> list[Arra
("Acoustic Solo", body_start, body_measure_count, 3), ("Acoustic Solo", body_start, body_measure_count, 3),
("Electric Solo", body_start, body_measure_count, 4), ("Electric Solo", body_start, body_measure_count, 4),
("Vocal Melody", body_start, body_measure_count, 5), ("Vocal Melody", body_start, body_measure_count, 5),
("Ending", edge_source_measure, edge_measure_count, 6),
) )
return [ return [
ArrangementSection(label=label, source_measure_index=start, measure_count=count, section_index=index, role=section_role(part_type, index)) ArrangementSection(label=label, source_measure_index=start, measure_count=count, section_index=index, role=section_role(part_type, index))
+7 -6
View File
@@ -19,15 +19,17 @@ import generator.musicxml
import generator.parts import generator.parts
import generator.score import generator.score
import generator.solo.arrangement import generator.solo.arrangement
import generator.comping
import generator.vocabulary
import generator.production
FINAL_SECTION_INDEX = 6 FINAL_SECTION_INDEX = 5
def arrange_document(document: dict, part_type: str) -> dict: def arrange_document(document: dict, part_type: str) -> dict:
"""Canonical source-only template; solos are inserted by production roles.""" """Canonical source-only template; solos are inserted by production roles."""
from generator import comping, vocabulary
document = generator.parts.prepare_document(document) document = generator.parts.prepare_document(document)
with vocabulary.using(vocabulary.load(),part_type+'_accompaniment'): with generator.vocabulary.using(generator.vocabulary.load(), part_type + '_accompaniment'):
backing=comping.Canonical(document,part_type,'canonical') if part_type!='vocal' else None backing = generator.comping.Canonical(document, part_type, 'canonical') if part_type != 'vocal' else None
return _arrange_document(document, part_type, accompaniment=backing) return _arrange_document(document, part_type, accompaniment=backing)
def section_source(source: list[dict], section, next_section=None) -> list[dict]: def section_source(source: list[dict], section, next_section=None) -> list[dict]:
@@ -115,5 +117,4 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
return result return result
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, composition_jobs=2, cache_dir=None, progress=False, timings=None) -> int: def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, composition_jobs=2, cache_dir=None, progress=False, timings=None) -> int:
from generator.production import generate_tree return generator.production.generate_tree(source_dir, output_dir, jobs=composition_jobs, cache_dir=cache_dir, progress=progress, timings=timings)
return generate_tree(source_dir, output_dir, jobs=composition_jobs, cache_dir=cache_dir, progress=progress,timings=timings)
+21 -3
View File
@@ -6,6 +6,11 @@ import pathlib
import re import re
import sqlite3 import sqlite3
import tempfile import tempfile
import sys
if __name__ == '__main__' and __package__ is None:
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
import generator.performance
APPLICATION_ID = 0x56414D50 APPLICATION_ID = 0x56414D50
SONG_COLUMNS = """ SONG_COLUMNS = """
@@ -45,9 +50,20 @@ def metadata(path: pathlib.Path, root: pathlib.Path) -> dict:
source_path=relative.as_posix(), source_json=text, source_path=relative.as_posix(), source_json=text,
measure_count=len(staff['measures']), total_pages=len(presentation['pages']), title_image=title_image, part=staff['name']) measure_count=len(staff['measures']), total_pages=len(presentation['pages']), title_image=title_image, part=staff['name'])
def populate(connection: sqlite3.Connection, root: pathlib.Path) -> None: def populate(connection: sqlite3.Connection, root: pathlib.Path, audio_assets=None) -> None:
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')
if audio_assets is None:
audio_assets = []
for path in sorted(root.glob('sounds/*.wav')):
if path.name not in ('beat1.wav', 'beat2.wav'):
raise ValueError(f'{path}: unknown metronome sample')
audio_assets.append(generator.performance.describe_wav(path, root))
for path in sorted(root.glob('backing/*.wav')):
match = re.fullmatch(r'([a-z0-9]+(?:_[a-z0-9]+)*)_(60|70|80|90|100|110|120)\.wav', path.name)
if not match:
raise ValueError(f'{path}: invalid backing filename')
audio_assets.append(generator.performance.describe_wav(path, root, song_id=match[1], bpm=int(match[2])))
folder = root / 'songs' folder = root / 'songs'
if (folder.exists() or folder.is_symlink()) and not folder.is_dir(): if (folder.exists() or folder.is_symlink()) and not folder.is_dir():
raise ValueError(f'{folder}: score asset directory not found') raise ValueError(f'{folder}: score asset directory not found')
@@ -56,6 +72,8 @@ def populate(connection: sqlite3.Connection, root: pathlib.Path) -> None:
connection.execute(f'PRAGMA application_id = {APPLICATION_ID}') connection.execute(f'PRAGMA application_id = {APPLICATION_ID}')
connection.execute(f'CREATE TABLE songs ({SONG_COLUMNS}, part TEXT NOT NULL) STRICT') connection.execute(f'CREATE TABLE songs ({SONG_COLUMNS}, part TEXT NOT NULL) STRICT')
connection.execute('CREATE INDEX songs_menu ON songs(title, composer, part, id)') connection.execute('CREATE INDEX songs_menu ON songs(title, composer, part, id)')
connection.execute('CREATE TABLE resources (id TEXT PRIMARY KEY, value TEXT NOT NULL CHECK(json_valid(value))) STRICT')
connection.execute('INSERT INTO resources VALUES (?, ?)', ('audio', json.dumps(audio_assets)))
for path in sorted(folder.rglob('*.json')): for path in sorted(folder.rglob('*.json')):
try: try:
if path.parent != folder: if path.parent != folder:
@@ -67,7 +85,7 @@ def populate(connection: sqlite3.Connection, root: pathlib.Path) -> None:
if connection.execute('PRAGMA integrity_check').fetchall() != [('ok',)]: if connection.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
raise ValueError('catalog integrity check failed') raise ValueError('catalog integrity check failed')
def build_catalog(root: pathlib.Path, destination: pathlib.Path | None = None) -> pathlib.Path: def build_catalog(root: pathlib.Path, destination: pathlib.Path | None = None, *, audio_assets=None) -> pathlib.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 / 'catalog.db' destination = destination or root / 'catalog.db'
@@ -76,7 +94,7 @@ def build_catalog(root: pathlib.Path, destination: pathlib.Path | None = None) -
staged = pathlib.Path(temporary) / 'catalog.db' staged = pathlib.Path(temporary) / 'catalog.db'
connection = sqlite3.connect(staged) connection = sqlite3.connect(staged)
try: try:
populate(connection, root) populate(connection, root, audio_assets)
finally: finally:
connection.close() connection.close()
staged.replace(destination) staged.replace(destination)
+50 -42
View File
@@ -3,71 +3,74 @@ from __future__ import annotations
import copy import copy
import functools import functools
import json import json
from fractions import Fraction as F import fractions
from generator import accompaniment, fretboard, guitar, harmony, model, parts, score, vocabulary
from generator.solo.policy import stable_rank
import generator.accompaniment
import generator.fretboard
import generator.guitar
import generator.harmony
import generator.model
import generator.parts
import generator.score
import generator.vocabulary
import generator.solo.policy
def semantic_key(chord): def semantic_key(chord):
return json.dumps({k: chord[k] for k in ('root', 'kind', 'degrees', 'bass', 'inversion') if k in chord}, sort_keys=True) return json.dumps({k: chord[k] for k in ('root', 'kind', 'degrees', 'bass', 'inversion') if k in chord}, sort_keys=True)
@functools.lru_cache(maxsize=2048) @functools.lru_cache(maxsize=2048)
def voicings(key, bass): def voicings(key, bass):
chord = json.loads(key) chord = json.loads(key)
degrees = harmony.degrees(chord) degrees = generator.harmony.degrees(chord)
root = int(model.NATURAL[chord['root']['letter']] + model.alteration(chord['root']['alter'])) root = int(generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter']))
classes = {d: (root + harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()} classes = {d: (root + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
required = {p for d, p in classes.items() if d in (2, 3, 4, 6, 7) or d == 5 and degrees[d] or d > 7} required = {p for d, p in classes.items() if d in (2, 3, 4, 6, 7) or d == 5 and degrees[d] or d > 7}
full = fretboard.chord_requirements(chord) full = generator.fretboard.chord_requirements(chord)
required.update(full.basses) required.update(full.basses)
if bass and 1 in classes: if bass and 1 in classes:
required.add(classes[1]) required.add(classes[1])
if len(required) < 2: if len(required) < 2:
required.update(classes[d] for d in (1, 5) if d in classes) required.update(classes[d] for d in (1, 5) if d in classes)
allowed = set(full.allowed) allowed = set(full.allowed)
requirements = fretboard.ChordRequirements(tuple(sorted(required)), tuple(sorted(allowed)), tuple(sorted(allowed)), full.basses) requirements = generator.fretboard.ChordRequirements(tuple(sorted(required)), tuple(sorted(allowed)), tuple(sorted(allowed)), full.basses)
options = guitar.chord_positions(requirements, prune=False) options = generator.guitar.chord_positions(requirements, prune=False)
size = max(2, len(required)) size = max(2, len(required))
options = [v for v in options if len(v) == size and fretboard.playable_attack((p, F(1)) for p in v)] options = [v for v in options if len(v) == size and generator.fretboard.playable_attack((p, fractions.Fraction(1)) for p in v)]
if not options: if not options:
raise ValueError(harmony.symbol(chord) + ': no legal sparse voicing preserving essential written degrees') raise ValueError(generator.harmony.symbol(chord) + ': no legal sparse voicing preserving essential written degrees')
# Retain distinct hand regions and inversions, independent of enumeration. # Retain distinct hand regions and inversions, independent of enumeration.
best = {} best = {}
for v in options: for v in options:
family = (min(p.fret for p in v), min(p.midi for p in v) % 12) family = (min(p.fret for p in v), min(p.midi for p in v) % 12)
rank = (guitar.compactness(v), stable_rank('comp-voicing', v)) rank = (generator.guitar.compactness(v), generator.solo.policy.stable_rank('comp-voicing', v))
if family not in best or rank < best[family][0]: if family not in best or rank < best[family][0]:
best[family] = rank, v best[family] = rank, v
return tuple(v for _, v in sorted(best.values())) return tuple(v for _, v in sorted(best.values()))
class Canonical: class Canonical:
def __init__(self, document, part, seed): def __init__(self, document, part, seed):
self.part, self.seed = part, seed self.part, self.seed = part, seed
self.policy = vocabulary.role(part + '_accompaniment') self.policy = generator.vocabulary.role(part + '_accompaniment')
self.cache, self.previous, self.report = {}, (), [] self.cache, self.previous, self.report = {}, (), []
measures = score.source_staff(document)['measures'] measures = generator.score.source_staff(document)['measures']
self.first_index = score.body_start_measure_index(measures) self.first_index = generator.score.body_start_measure_index(measures)
self.last_index = len(measures) - 1 self.last_index = len(measures) - 1
carried = accompaniment.carried_chords(measures) carried = generator.accompaniment.carried_chords(measures)
for index in range(score.body_start_measure_index(measures), len(measures)): for index in range(generator.score.body_start_measure_index(measures), len(measures)):
source = copy.deepcopy(measures[index]) source = copy.deepcopy(measures[index])
for h in source['harmonies']: for h in source['harmonies']:
h.pop('chord_optional', None) h.pop('chord_optional', None)
self(source, carried[index], index) self(source, carried[index], index)
def __call__(self, source, prior, index): def __call__(self, source, prior, index):
key = (index, tuple((model.fraction(h['onset']), semantic_key(h)) for h in source['harmonies']), semantic_key(prior) if prior else '', source['duration'].__repr__()) key = (index, tuple((generator.model.fraction(h['onset']), semantic_key(h)) for h in source['harmonies']), semantic_key(prior) if prior else '', source['duration'].__repr__())
if key in self.cache: if key in self.cache:
return copy.deepcopy(self.cache[key]) return copy.deepcopy(self.cache[key])
length = model.fraction(source['duration']) length = generator.model.fraction(source['duration'])
boundaries = sorted({F(), length, *(model.fraction(h['onset']) for h in source['harmonies'])}) boundaries = sorted({fractions.Fraction(), length, *(generator.model.fraction(h['onset']) for h in source['harmonies'])})
attacks = [] attacks = []
source_attacks = {model.fraction(n['onset']) for n in score.measure_notes(source) if n['pitches'] and not all('tie_from' in p for p in n['pitches'])} source_attacks = {generator.model.fraction(n['onset']) for n in generator.score.measure_notes(source) if n['pitches'] and not all('tie_from' in p for p in n['pitches'])}
for start, end in zip(boundaries, boundaries[1:]): for start, end in zip(boundaries, boundaries[1:]):
chord = accompaniment.active_chord(source, start, prior) chord = generator.accompaniment.active_chord(source, start, prior)
if not chord: if not chord:
continue continue
duration = end - start duration = end - start
@@ -78,46 +81,51 @@ class Canonical:
center = self.policy.get('center', 55) center = self.policy.get('center', 55)
selected = min(options, key=lambda v: ( selected = min(options, key=lambda v: (
.08 * abs(sum(p.midi for p in v) / len(v) - center) .08 * abs(sum(p.midi for p in v) / len(v) - center)
+ (.15 * guitar.movement(self.previous, v) if self.previous else 0) + (.15 * generator.guitar.movement(self.previous, v) if self.previous else 0)
+ .1 * guitar.compactness(v), stable_rank(self.seed, (index, start, v)))) + .1 * generator.guitar.compactness(v), generator.solo.policy.stable_rank(self.seed, (index, start, v))))
self.previous = selected self.previous = selected
patterns = [] patterns = []
for figure in vocabulary.data('accompaniment')['comping']: for figure in generator.vocabulary.data('accompaniment')['comping']:
events = tuple((start+F(a), min(end, start+F(a)+F(d))) for a, d in figure['attacks'] if F(a) < duration) events = tuple((start + fractions.Fraction(a), min(end, start + fractions.Fraction(a) + fractions.Fraction(d))) for a, d in figure['attacks'] if fractions.Fraction(a) < duration)
# Space is available on held harmony, never an accidental empty winner. # Space is available on held harmony, never an accidental empty winner.
if not events and (not prior or not accompaniment.same_chord(chord, prior) or start): if not events and (not prior or not generator.accompaniment.same_chord(chord, prior) or start):
continue continue
conflicts = sum(a in source_attacks for a, _ in events) conflicts = sum(a in source_attacks for a, _ in events)
target = 1 if duration <= 3 else 1.5 target = 1 if duration <= 3 else 1.5
cost = .2 * abs(len(events) - target) + .08 * conflicts cost = .2 * abs(len(events) - target) + .08 * conflicts
if figure['id'] == 'sustain' and duration < 3: if figure['id'] == 'sustain' and duration < 3:
cost += .2 cost += .2
patterns.append((cost, stable_rank(self.seed, (index, start, figure['id'])), events, figure['id'])) patterns.append((cost, generator.solo.policy.stable_rank(self.seed, (index, start, figure['id'])), events, figure['id']))
# Source-specific variation within a broad acceptability band, not a loop. # Source-specific variation within a broad acceptability band, not a loop.
low = min(p[0] for p in patterns) low = min(p[0] for p in patterns)
_, _, events, label = min((p for p in patterns if p[0] <= low + .25), key=lambda p: p[1]) _, _, events, label = min((p for p in patterns if p[0] <= low + .25), key=lambda p: p[1])
for a, b in events: for a, b in events:
attacks.append((a, b, selected, chord)) attacks.append((a, b, selected, chord))
self.report.append({'source_index': index, 'onset': str(start), 'chord': harmony.symbol(chord), 'figure': label, 'tones': len(selected), 'bass': bass, self.report.append({'source_index': index, 'onset': str(start), 'chord': generator.harmony.symbol(chord), 'figure': label, 'tones': len(selected), 'bass': bass,
'fuller_voicing_reason': 'explicit written degrees/bass' if len(selected) > 3 else ''}) 'fuller_voicing_reason': 'explicit written degrees/bass' if len(selected) > 3 else ''})
notes, cursor = [], F() notes, cursor = [], fractions.Fraction()
def emit(a, b, positions=(), chord=None): def emit(a, b, positions=(), chord=None):
nonlocal cursor nonlocal cursor
previous = None previous = None
for duration, written in accompaniment.written_durations(b-a): for duration, written in generator.accompaniment.written_durations(b - a):
pitches = fretboard.chord_pitches(chord, positions) if positions else [] pitches = generator.fretboard.chord_pitches(chord, positions) if positions else []
n = model.make_event(f'comp_{self.part}_{index}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written) n = generator.model.make_event(f'comp_{self.part}_{index}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written)
if previous: if previous:
for old, new in zip(previous['pitches'], n['pitches']): for old, new in zip(previous['pitches'], n['pitches']):
old['tie_to'], new['tie_from'] = new['id'], old['id'] old['tie_to'], new['tie_from'] = new['id'], old['id']
notes.append(n); previous = n; a += duration notes.append(n)
previous = n
a += duration
cursor = b cursor = b
for start, end, positions, chord in attacks: for start, end, positions, chord in attacks:
if cursor < start: emit(cursor, start) if cursor < start:
emit(cursor, start)
emit(start, end, positions, chord) emit(start, end, positions, chord)
if cursor < length: emit(cursor, length) if cursor < length:
result = parts.build_measure(source, 'canonical', 'rest', self.part, prior) emit(cursor, length)
result = generator.parts.build_measure(source, 'canonical', 'rest', self.part, prior)
result['voices'] = [{'id': 'voice_arranged', 'notes': notes}] result['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
for note in notes: note['voice'] = 'voice_arranged' for note in notes:
note['voice'] = 'voice_arranged'
self.cache[key] = copy.deepcopy(result) self.cache[key] = copy.deepcopy(result)
return result return result
+17 -14
View File
@@ -2,30 +2,33 @@
import argparse import argparse
import json import json
import time import time
from pathlib import Path import pathlib
from generator import musicxml, parts, production import generator.musicxml
import generator.parts
import generator.production
import generator.solo.export
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--song',type=Path,required=True) parser.add_argument('--song', type=pathlib.Path, required=True)
parser.add_argument('--role',choices=production.ROLES,required=True) parser.add_argument('--role', choices=generator.production.ROLES, required=True)
parser.add_argument('--data-dir',type=Path,default=Path('data')) parser.add_argument('--data-dir', type=pathlib.Path, default=pathlib.Path('data'))
parser.add_argument('--output',type=Path,required=True) parser.add_argument('--output', type=pathlib.Path, required=True)
parser.add_argument('--cache-dir',type=Path) parser.add_argument('--cache-dir', type=pathlib.Path)
parser.add_argument('--solo-xml',type=Path,help='Also export a completed solo role as MusicXML.') parser.add_argument('--solo-xml', type=pathlib.Path, help='Also export a completed solo role as MusicXML.')
parser.add_argument('--settings',type=Path) parser.add_argument('--settings', type=pathlib.Path)
args = parser.parse_args() args = parser.parse_args()
if args.solo_xml and not args.role.endswith('_solo'): if args.solo_xml and not args.role.endswith('_solo'):
parser.error('--solo-xml requires a solo role') parser.error('--solo-xml requires a solo role')
source=production.SourceAnalysis.read(parts.prepare_document(musicxml.load_musicxml(args.song))) source = generator.production.SourceAnalysis.read(generator.parts.prepare_document(generator.musicxml.load_musicxml(args.song)))
start = time.monotonic() start = time.monotonic()
result=production.generate_role(source,args.role,args.data_dir,json.loads(args.settings.read_text()) if args.settings else {},args.cache_dir) result = generator.production.generate_role(source, args.role, args.data_dir, json.loads(args.settings.read_text()) if args.settings else {}, args.cache_dir)
args.output.parent.mkdir(parents=True, exist_ok=True) args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, indent=2)) args.output.write_text(json.dumps(result, indent=2))
if args.solo_xml: if args.solo_xml:
from generator.solo.export import write_role manifest = generator.solo.export.write_role(result, args.solo_xml)
manifest=write_role(result,args.solo_xml)
args.solo_xml.with_suffix('.manifest.solo').write_text(json.dumps(manifest, indent=2) + '\n') args.solo_xml.with_suffix('.manifest.solo').write_text(json.dumps(manifest, indent=2) + '\n')
print(f'{args.role}: {time.monotonic() - start:.2f}s wall; cached={result["cache_hit"]}; role generation/cache lookup {result["seconds"]:.2f}s') print(f'{args.role}: {time.monotonic() - start:.2f}s wall; cached={result["cache_hit"]}; role generation/cache lookup {result["seconds"]:.2f}s')
if __name__=='__main__':main() if __name__ == '__main__':
main()
+3 -3
View File
@@ -22,7 +22,7 @@ def renderer_identity(fonts, style) -> dict:
root = pathlib.Path(__file__).resolve().parents[1] root = pathlib.Path(__file__).resolve().parents[1]
versions = {name: importlib.metadata.version(name) for name in ('Pillow', 'fonttools', 'aggdraw')} versions = {name: importlib.metadata.version(name) for name in ('Pillow', 'fonttools', 'aggdraw')}
versions.update({name: PIL.features.version(name) for name in PIL.features.get_supported()}) versions.update({name: PIL.features.version(name) for name in PIL.features.get_supported()})
sources = [*root.joinpath('engraving').glob('*.py'), root / 'model.py', root / 'harmony.py', root / 'theme.py', root / 'palette.json'] sources = [*root.joinpath('engraving').glob('*.py'), root / 'model.py', root / 'performance.py', root / 'harmony.py', root / 'theme.py', root / 'palette.json']
return {'fonts_style': fonts.fingerprint(style), 'python': {'implementation': platform.python_implementation(), 'version': platform.python_version()}, 'dependencies': versions, return {'fonts_style': fonts.fingerprint(style), 'python': {'implementation': platform.python_implementation(), 'version': platform.python_version()}, 'dependencies': versions,
'sources': {path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(sources)}} 'sources': {path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(sources)}}
@@ -36,7 +36,7 @@ def renderer_namespace(cache: pathlib.Path, renderer: str) -> pathlib.Path:
return namespace return namespace
def score_fingerprint(document: dict, renderer: str) -> str: def score_fingerprint(document: dict, renderer: str) -> str:
music = {key: value for key, value in document.items() if key != 'playback'} music = document
return hashlib.sha256(json.dumps([renderer, music], sort_keys=True, ensure_ascii=False).encode()).hexdigest() return hashlib.sha256(json.dumps([renderer, music], sort_keys=True, ensure_ascii=False).encode()).hexdigest()
def notation_fingerprint(document: dict, renderer: str) -> str: def notation_fingerprint(document: dict, renderer: str) -> str:
@@ -61,7 +61,7 @@ def restore_pages(entry: pathlib.Path, destination: pathlib.Path, validated_page
return None return None
destination.mkdir(parents=True, exist_ok=True) destination.mkdir(parents=True, exist_ok=True)
for index, page in enumerate(pages, 1): for index, page in enumerate(pages, 1):
if not isinstance(page, dict) or set(page) != {'path', 'sha256'} or page['path'] != f'{index:04}.png' or not isinstance(page['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', page['sha256']): if not isinstance(page, dict) or set(page) != {'path', 'sha256', 'first_measure', 'measure_count', 'start_beat', 'end_beat'} or page['path'] != f'{index:04}.png' or not isinstance(page['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', page['sha256']):
return None return None
path = entry / page['path'] path = entry / page['path']
if path.is_symlink() or not path.is_file(): if path.is_symlink() or not path.is_file():
+10 -4
View File
@@ -22,6 +22,7 @@ import generator.engraving.raster
import generator.engraving.types import generator.engraving.types
import generator.engraving.validation import generator.engraving.validation
import generator.model import generator.model
import generator.performance
@dataclasses.dataclass @dataclasses.dataclass
class GroupResult: class GroupResult:
@@ -101,11 +102,11 @@ def clear_pages(destination):
def write_bundle(path, document, pages, fingerprint): def write_bundle(path, document, pages, fingerprint):
entries = [{**page, 'path': path.with_suffix('.pages').name + '/' + page['path']} for page in pages] entries = [{**page, 'path': path.with_suffix('.pages').name + '/' + page['path']} for page in pages]
music = {key: value for key, value in document.items() if key != 'playback'} music = document
bundle = {'music': music, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'style_sha256': fingerprint, bundle = {'music': music, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'style_sha256': fingerprint,
'width': generator.engraving.validation.PAGE_WIDTH, 'height': generator.engraving.validation.PAGE_HEIGHT, 'pages': entries}} 'width': generator.engraving.validation.PAGE_WIDTH, 'height': generator.engraving.validation.PAGE_HEIGHT, 'pages': entries}}
if 'playback' in document: generator.performance.validate_pages(document, pages)
bundle['playback'] = document['playback'] bundle['performance'] = generator.performance.timing(document)
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, progress=False): def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, progress=False):
@@ -165,7 +166,7 @@ def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, prog
image.save(target, format='PNG') image.save(target, format='PNG')
result.timings['raster'] += time.monotonic() - started result.timings['raster'] += time.monotonic() - started
result.renderedpages += 1 result.renderedpages += 1
pages.append({'path': target.name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()}) pages.append({'path': target.name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest(), **generator.performance.page_timing(document, page)})
scores[score_key] = (destination, pages) scores[score_key] = (destination, pages)
write_bundle(path, document, pages, fingerprint) write_bundle(path, document, pages, fingerprint)
result.scores.append((path, score_key, pages)) result.scores.append((path, score_key, pages))
@@ -210,6 +211,11 @@ def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.
entry = cache_dir / 'fullscore' / score_key if cache_dir else None entry = cache_dir / 'fullscore' / score_key if cache_dir else None
present = entry is not None and (entry.exists() or entry.is_symlink()) present = entry is not None and (entry.exists() or entry.is_symlink())
pages = generator.engraving.cache.restore_pages(entry, destination, validated_pages) if entry else None pages = generator.engraving.cache.restore_pages(entry, destination, validated_pages) if entry else None
if pages is not None:
try:
generator.performance.validate_pages(document, pages)
except (ValueError, TypeError, KeyError):
pages = None
if pages is None: if pages is None:
rejected += int(present) rejected += int(present)
missing += int(entry is not None and not present) missing += int(entry is not None and not present)
+4 -4
View File
@@ -4,7 +4,7 @@ import dataclasses
import fractions import fractions
import generator.model import generator.model
import generator.score
def solo_source(staff): def solo_source(staff):
"""Source-form pickup context, shared by training and arranged choruses. """Source-form pickup context, shared by training and arranged choruses.
@@ -14,9 +14,9 @@ def solo_source(staff):
retain their ordinary initial-harmony checks. Explicit pickup harmony retain their ordinary initial-harmony checks. Explicit pickup harmony
(including N.C.) still takes precedence in read(). (including N.C.) still takes precedence in read().
""" """
from generator.score import body_start_measure_index measures = staff['measures']
measures=staff['measures'];initial=None initial = None
if body_start_measure_index(measures): if generator.score.body_start_measure_index(measures):
initial = next((h for m in reversed(measures) for h in reversed(m['harmonies'])), None) initial = next((h for m in reversed(measures) for h in reversed(m['harmonies'])), None)
return read(staff, initial=initial) return read(staff, initial=initial)
+19 -15
View File
@@ -3,35 +3,39 @@ from __future__ import annotations
import contextlib import contextlib
import contextvars import contextvars
import os import os
from pathlib import Path import pathlib
import sys import sys
ACTIVE = contextvars.ContextVar('composition_reads', default=None) ACTIVE = contextvars.ContextVar('composition_reads', default=None)
def _read(event, args): def _read(event, args):
scope = ACTIVE.get() scope = ACTIVE.get()
if scope is None or event!='open':return if scope is None or event != 'open':
return
name, mode, flags = args name, mode, flags = args
if not isinstance(name,(str,bytes,os.PathLike)):return if not isinstance(name, (str, bytes, os.PathLike)):
return
# r+ and w+ can read; they must obey the same boundary as a plain read. # r+ and w+ can read; they must obey the same boundary as a plain read.
if flags & os.O_WRONLY:return if flags & os.O_WRONLY:
path=Path(os.fsdecode(name)).resolve() return
path = pathlib.Path(os.fsdecode(name)).resolve()
data, code, cache, reads = scope data, code, cache, reads = scope
python_runtime=any(path.is_relative_to(Path(root).resolve()) for root in (sys.base_prefix,sys.prefix)) python_runtime = any(path.is_relative_to(pathlib.Path(root).resolve()) for root in (sys.base_prefix, sys.prefix))
allowed = path.is_relative_to(data) or path.is_relative_to(code) or python_runtime allowed = path.is_relative_to(data) or path.is_relative_to(code) or python_runtime
if cache and path.is_relative_to(cache):allowed=True if cache and path.is_relative_to(cache):
if not allowed:raise ValueError('Forbidden composition input: '+str(path)) allowed = True
if not allowed:
raise ValueError('Forbidden composition input: ' + str(path))
reads.add(str(path)) reads.add(str(path))
sys.addaudithook(_read) sys.addaudithook(_read)
@contextlib.contextmanager @contextlib.contextmanager
def composition(data, role, cache=None): def composition(data, role, cache=None):
reads = set() reads = set()
code=Path(__file__).resolve().parent code = pathlib.Path(__file__).resolve().parent
token=ACTIVE.set((Path(data).resolve(),code,Path(cache).resolve()/role if cache else None,reads)) token = ACTIVE.set((pathlib.Path(data).resolve(), code, pathlib.Path(cache).resolve() / role if cache else None, reads))
try:yield reads try:
finally:ACTIVE.reset(token) yield reads
finally:
ACTIVE.reset(token)
+2 -2
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
import copy import copy
import fractions import fractions
import typing import typing
import generator.fretboard
import generator.pitch
MAX_INTEGER = (1 << 63) - 1 MAX_INTEGER = (1 << 63) - 1
NOTE_TYPES = ('maxima', 'long', 'breve', 'whole', 'half', 'quarter', 'eighth', '16th', '32nd', '64th', '128th', '256th', '512th', '1024th') NOTE_TYPES = ('maxima', 'long', 'breve', 'whole', 'half', 'quarter', 'eighth', '16th', '32nd', '64th', '128th', '256th', '512th', '1024th')
@@ -206,8 +208,6 @@ def validate_document(document: dict) -> None:
else: else:
pitch_number(p) pitch_number(p)
if staves[0].get('tablature'): if staves[0].get('tablature'):
import generator.fretboard
import generator.pitch
tab = p.get('tablature', {}) tab = p.get('tablature', {})
string, fret = tab.get('string'), tab.get('fret') string, fret = tab.get('string'), tab.get('fret')
if type(string) is not int or type(fret) is not int or string not in dict(generator.fretboard.STANDARD_TUNING) or not generator.fretboard.MIN_FRET <= fret <= generator.fretboard.MAX_FRET: if type(string) is not int or type(fret) is not int or string not in dict(generator.fretboard.STANDARD_TUNING) or not generator.fretboard.MIN_FRET <= fret <= generator.fretboard.MAX_FRET:
+1 -1
View File
@@ -521,7 +521,7 @@ class Importer:
name = item.get('name', '') name = item.get('name', '')
if name.startswith('vamp:'): if name.startswith('vamp:'):
self.fail(f'unsupported Vamp property {name!r}') self.fail(f'unsupported Vamp property {name!r}')
metadata = {'title': root.findtext('work/work-title') or root.findtext('movement-title') or self.path.stem, 'composer': ' / '.join(c.text for c in root.findall('identification/creator') if c.get('type') == 'composer' and c.text)} metadata = {'title': root.findtext('work/work-title') or root.findtext('movement-title') or self.path.stem.replace('_', ' ').replace('-', ' ').title(), 'composer': ' / '.join(c.text for c in root.findall('identification/creator') if c.get('type') == 'composer' and c.text)}
measures = [] measures = []
self.children(parts[0], {'measure'}) self.children(parts[0], {'measure'})
for index, bar in enumerate(parts[0]): for index, bar in enumerate(parts[0]):
+110
View File
@@ -0,0 +1,110 @@
"""Page timing and supplied recordings. No note synthesis or audio transformation."""
from __future__ import annotations
import fractions
import hashlib
import json
import pathlib
import re
import shutil
import wave
import generator.model
TEMPOS = tuple(range(60, 121, 10))
def boundaries(document):
result = [fractions.Fraction()]
for measure in document['staves'][0]['measures']:
result.append(result[-1] + generator.model.fraction(measure['duration']))
return result
def page_timing(document, page):
times = boundaries(document)
start, end = page.systems[0].start, page.systems[-1].end
first, last = times.index(start), times.index(end)
return dict(first_measure=first + 1, measure_count=last - first, start_beat=generator.model.rational(start), end_beat=generator.model.rational(end))
def validate_pages(document, pages):
times = boundaries(document)
cursor = 0
for page in pages:
if not isinstance(page, dict):
raise ValueError('invalid page descriptor')
count = page.get('measure_count')
if type(count) is not int or count <= 0 or page.get('first_measure') != cursor + 1 or cursor + count >= len(times):
raise ValueError('invalid page measure coverage')
if generator.model.fraction(page.get('start_beat')) != times[cursor] or generator.model.fraction(page.get('end_beat')) != times[cursor + count]:
raise ValueError('page timing does not match its measures')
cursor += count
if cursor != len(times) - 1:
raise ValueError('incomplete page measure coverage')
def timing(document):
times = boundaries(document)
measures = document['staves'][0]['measures']
# Metronome accents follow each actual measure, including meter changes.
clicks = []
for start, measure in zip(times, measures):
duration = generator.model.fraction(measure['duration'])
beat = fractions.Fraction()
while beat < duration:
clicks.append(dict(beat=generator.model.rational(start + beat), downbeat=beat == 0))
beat += 1
return dict(song_id=document['metadata'].get('song_id', ''),
duration=generator.model.rational(times[-1]),
count_in_beats=4, clicks=clicks)
def describe_wav(path, root, *, song_id='', bpm=0):
if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root.resolve()):
raise ValueError(f'{path}: expected a contained regular WAV')
try:
with wave.open(str(path), 'rb') as audio:
channels, rate, frames = audio.getnchannels(), audio.getframerate(), audio.getnframes()
if audio.getcomptype() != 'NONE' or audio.getsampwidth() != 2 or channels not in (1, 2) or not 8000 <= rate <= 192000 or frames <= 0:
raise ValueError('expected nonempty mono/stereo PCM16 WAV')
remaining = frames
while remaining:
count = min(remaining, 65536)
if len(audio.readframes(count)) != count * channels * 2:
raise ValueError('truncated WAV data')
remaining -= count
except (wave.Error, EOFError) as error:
raise ValueError(f'{path}: invalid WAV: {error}') from error
with path.open('rb') as source:
digest = hashlib.file_digest(source, 'sha256').hexdigest()
return dict(path=path.relative_to(root).as_posix(), sha256=digest, channels=channels, sample_rate=rate, frames=frames, song_id=song_id, bpm=bpm)
def copy_audio(static, destination, song_ids):
entries = []
inputs = [(static / 'sounds' / name, '', 0) for name in ('beat1.wav', 'beat2.wav')]
for path in sorted((static / 'backing').glob('*.wav')):
match = re.fullmatch(r'([a-z0-9]+(?:_[a-z0-9]+)*)_(60|70|80|90|100|110|120)\.wav', path.name)
if not match:
raise ValueError(f'{path}: unsupported backing filename or tempo')
if match[1] not in song_ids:
continue
inputs.append((path, match[1], int(match[2])))
for source, song_id, bpm in inputs:
description = describe_wav(source, static, song_id=song_id, bpm=bpm)
target = destination / description['path']
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, target)
entries.append(description)
return entries
def validate_recording_lengths(root, entries):
durations = {}
for path in sorted((root / 'songs').glob('*.json')):
document = json.loads(path.read_text())
performance = timing(document)
song_id = performance['song_id']
duration = generator.model.fraction(performance['duration']) + performance['count_in_beats']
if song_id in durations and durations[song_id] != duration:
raise ValueError(f'{song_id}: parts disagree about performance duration')
durations[song_id] = duration
for entry in entries:
if entry['song_id']:
seconds = fractions.Fraction(entry['frames'], entry['sample_rate'])
if seconds < durations[entry['song_id']] * 60 / entry['bpm']:
raise ValueError(f"{entry['path']}: backing recording is shorter than count-in and score")
+1 -1
View File
@@ -20,7 +20,7 @@ TITLE_HEADER = struct.pack('<4sHH', b'VPTI', TITLE_WIDTH, TITLE_HEIGHT)
TITLE_BYTES = len(TITLE_HEADER) + TITLE_WIDTH * TITLE_HEIGHT // 2 TITLE_BYTES = len(TITLE_HEADER) + TITLE_WIDTH * TITLE_HEIGHT // 2
MAX_TITLES = 8192 MAX_TITLES = 8192
LABELS = ('VAMP', 'CONNECTING', 'OFFLINE', 'BUSY', 'READY', 'LOADING', 'WIFI', 'ERROR', 'PAGE', 'Acoustic', 'Electric', 'Vocal', 'UNAVAILABLE', LABELS = ('VAMP', 'CONNECTING', 'OFFLINE', 'BUSY', 'READY', 'LOADING', 'WIFI', 'ERROR', 'PAGE', 'Acoustic', 'Electric', 'Vocal', 'UNAVAILABLE',
'Main Menu', 'Performance', 'Settings', 'Playback', 'Metronome', 'Tempo', 'Back', 'On', 'Off', 'BPM') 'Main Menu', 'Performance', 'Settings', 'Backing', 'Metronome', 'Tempo', 'Back', 'On', 'Off', 'BPM', 'Manual', 'Preparing', 'Count-in', 'Playing', 'Sustain', 'Hold to exit')
SPRITES = LABELS + tuple('0123456789/') SPRITES = LABELS + tuple('0123456789/')
DIGIT_WIDTH = 22 DIGIT_WIDTH = 22
DEFAULT_FONT_ROOT = pathlib.Path(__file__).resolve().parents[1] / 'package/assets/fonts/petaluma' DEFAULT_FONT_ROOT = pathlib.Path(__file__).resolve().parents[1] / 'package/assets/fonts/petaluma'
+1 -1
View File
@@ -89,6 +89,6 @@ def concert_document(document: dict, *, octave_transposing: bool = False) -> dic
def sounding_pitch(pitch: dict, state: dict): def sounding_pitch(pitch: dict, state: dict):
if 'unpitched' in pitch: if 'unpitched' in pitch:
raise ValueError('unpitched notes have no fretted or generated_tone interpretation') raise ValueError('unpitched notes have no fretted pitch interpretation')
transpose = state['transpose'] transpose = state['transpose']
return generator.model.pitch_number(pitch) + transpose['chromatic'] + 12 * transpose['octave_change'] return generator.model.pitch_number(pitch) + transpose['chromatic'] + 12 * transpose['octave_change']
-260
View File
@@ -1,260 +0,0 @@
from __future__ import annotations
import array
import fractions
import hashlib
import json
import math
import pathlib
import sys
import wave
import generator.model
import generator.pitch
SAMPLE_RATE = 48000
PROFILE = 'soft_struck'
MAX_EVENTS = 1000000
class PlaybackUnavailable(ValueError):
pass
def sounding_pitch(tone: dict, state: dict) -> fractions.Fraction:
if 'unpitched' in tone:
raise PlaybackUnavailable('unpitched notation has no generated_tone interpretation')
value = generator.pitch.sounding_pitch(tone, state)
frequency = 440 * 2 ** (float(value - 69) / 12)
if not 20 <= frequency <= 18000:
raise PlaybackUnavailable(f'sounding pitch {value} is outside the generated_tone range (20-18000 Hz)')
return value
def compile_timeline(document: dict) -> dict:
events, notes, linked = [], {}, {}
absolute = fractions.Fraction()
for measure in document['staves'][0]['measures']:
for voice in measure['voices']:
state = {key: measure[key] for key in ('transpose', 'clef', 'staff_details')}
changes = iter(sorted(measure.get('attributes', []), key=lambda a: generator.model.fraction(a['onset'])))
change = next(changes, None)
for note in voice['notes']:
onset = generator.model.fraction(note['onset'])
while change is not None and generator.model.fraction(change['onset']) <= onset:
state.update({key: change[key] for key in state if key in change})
change = next(changes, None)
notes[note['id']] = (note, absolute + onset, dict(state), voice['id'])
absolute += generator.model.fraction(measure['duration'])
adjustments = {}
insertions = {}
lanes = {}
for item in notes.values():
lanes.setdefault(item[3], []).append(item)
for lane in lanes.values():
pending = []
previous = None
for note, onset, state, voice in lane:
if note.get('grace'):
pending.append((note, onset, state, voice))
continue
if pending:
cursor = onset
specified = [n['grace'] for n, _, _, _ in pending]
modes = {key for g in specified for key in ('make-time', 'steal-time-previous', 'steal-time-following') if key in g}
if len(modes) > 1:
raise PlaybackUnavailable('grace group mixes conflicting timing modes')
mode = next(iter(modes), 'steal-time-following')
if mode == 'make-time':
lengths = [generator.model.fraction(g.get(mode, generator.model.rational(0))) for g in specified]
total = sum(lengths, fractions.Fraction())
insertions[onset] = max(insertions.get(onset, fractions.Fraction()), total)
else:
donor = previous[0] if mode == 'steal-time-previous' and previous else note if mode == 'steal-time-following' else None
if donor is None:
raise PlaybackUnavailable('grace notes have no preceding duration to steal')
percentages = [generator.model.fraction(g[mode]) if mode in g else fractions.Fraction(25, len(pending)) for g in specified]
if not 0 < sum(percentages) < 100:
raise PlaybackUnavailable('grace group must steal between zero and 100 percent')
lengths = [generator.model.fraction(donor['duration']) * amount / 100 for amount in percentages]
total = sum(lengths, fractions.Fraction())
delay, extension = adjustments.get(donor['id'], (fractions.Fraction(), fractions.Fraction()))
if mode == 'steal-time-previous':
cursor -= total
else:
delay += total
extension -= total
if generator.model.fraction(donor['duration']) + extension <= 0 or any(generator.model.fraction(p['duration']) + extension <= 0 for p in donor['pitches']):
raise PlaybackUnavailable(f'{donor["id"]}: grace timing consumes a donor or chord tone')
adjustments[donor['id']] = (delay, extension)
for item, length in zip(pending, lengths):
value, _, pitch_state, lane_id = item
adjustments[value['id']] = (cursor - item[1], length)
cursor += length
pending = []
previous = (note, onset)
if pending:
raise PlaybackUnavailable('trailing grace group has no following anchor')
for identifier, (note, onset, state, voice) in notes.items():
if note.get('cue'):
continue
delay, extension = adjustments.get(identifier, (fractions.Fraction(), fractions.Fraction()))
performed = onset + delay + sum((length for time, length in insertions.items() if time < onset or time == onset and not note.get('grace')), fractions.Fraction())
tremolo = note.get('tremolo')
if tremolo and tremolo['type'] == 'stop':
continue
for pitch in note['pitches']:
if pitch.get('cue'):
continue
length = generator.model.fraction(pitch['duration']) + extension
if not note.get('grace'):
length += sum((amount for time, amount in insertions.items() if onset < time < onset + generator.model.fraction(pitch['duration'])), fractions.Fraction())
if length <= 0:
raise PlaybackUnavailable(f'{identifier}: grace timing consumes a chord tone')
value = sounding_pitch(pitch, state)
if pitch.get('tie_from'):
previous = linked.get(pitch['tie_from'])
if previous is None or generator.model.fraction(previous['onset']) + generator.model.fraction(previous['duration']) != performed or generator.model.fraction(previous['pitch']) != value:
raise PlaybackUnavailable(f'{identifier}: tie has inconsistent performed timing or pitch')
previous['duration'] = generator.model.rational(generator.model.fraction(previous['duration']) + length)
linked[pitch['id']] = previous
continue
if tremolo and tremolo['marks']:
if pitch.get('tie_to'):
raise PlaybackUnavailable('tied measured tremolo is not yet qualified for playback')
written = pitch['written']
step = min(fractions.Fraction(1), generator.model.TYPE_LENGTHS[written['type']]) * generator.model.fraction(written.get('ratio', generator.model.rational(1))) / 2 ** tremolo['marks']
if tremolo['type'] == 'start':
partner = notes[tremolo['partner']]
if partner[1] != onset + generator.model.fraction(note['duration']) or len(partner[0]['pitches']) != len(note['pitches']):
raise PlaybackUnavailable('paired tremolo requires contiguous equal-size chords')
step *= 2
length += generator.model.fraction(partner[0]['pitches'][note['pitches'].index(pitch)]['duration'])
cursor, attack = fractions.Fraction(), 0
while cursor < length:
amount = value
if tremolo['type'] == 'start' and attack % 2:
other = partner[0]['pitches'][note['pitches'].index(pitch)]
amount = sounding_pitch(other, partner[2])
events.append({'id': f'{pitch["id"]}_{attack}', 'pitch': generator.model.rational(amount), 'onset': generator.model.rational(performed + cursor), 'duration': generator.model.rational(min(step, length - cursor))})
cursor += step
attack += 1
if len(events) > MAX_EVENTS:
raise PlaybackUnavailable('playback exceeds one million tone events')
else:
event = {'id': pitch['id'], 'pitch': generator.model.rational(value), 'onset': generator.model.rational(performed), 'duration': generator.model.rational(length)}
events.append(event)
linked[pitch['id']] = event
if len(events) > MAX_EVENTS:
raise PlaybackUnavailable('playback exceeds one million tone events')
events.sort(key=lambda e: (generator.model.fraction(e['onset']), e['id']))
return {'available': True, 'duration': generator.model.rational(absolute + sum(insertions.values(), fractions.Fraction())), 'events': events}
def write_pcm(path: pathlib.Path, values) -> str:
samples = array.array('h', (round(max(-1, min(1, sample)) * 32767) for sample in values))
if sys.byteorder != 'little':
samples.byteswap()
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), 'wb') as output:
output.setparams((1, 2, SAMPLE_RATE, 0, 'NONE', 'not compressed'))
output.writeframes(samples.tobytes())
return hashlib.sha256(path.read_bytes()).hexdigest()
def generate_tone(root: pathlib.Path, pitch: fractions.Fraction) -> dict:
frequency = 440 * 2 ** (float(pitch - 69) / 12)
period = fractions.Fraction(frequency / SAMPLE_RATE).limit_denominator(SAMPLE_RATE)
loop_length, cycles = period.denominator, period.numerator
attack = 12000
name = hashlib.sha256(f'{PROFILE}:{pitch}'.encode('ascii')).hexdigest()[:24]
relative = f'audio/{name}.wav'
harmonics = [h for h in range(1, 7) if frequency * h < SAMPLE_RATE * 0.45]
def sample(frame):
phase = 2 * math.pi * cycles * frame / loop_length
envelope = min(1, frame / 240) * (0.18 + 0.82 * (1 - min(1, frame / attack)) ** 3)
return 0.3 * envelope * sum(math.sin(phase * h) / (h * h) for h in harmonics)
checksum = write_pcm(root / relative, (sample(i) for i in range(attack + loop_length)))
return {'id': name, 'path': relative, 'sha256': checksum, 'sample_rate': SAMPLE_RATE, 'frames': attack + loop_length, 'loop_start': attack, 'loop_end': attack + loop_length}
def compile_tree(root: pathlib.Path) -> None:
tones = {}
release = {'path': 'audio/release.wav', 'sample_rate': SAMPLE_RATE, 'frames': 2400}
release['sha256'] = write_pcm(root / release['path'], ((1 - i / 2399) ** 2 for i in range(2400)))
for path in sorted(root.rglob('*.json')):
document = json.loads(path.read_text(encoding='utf-8'))
try:
playback = compile_timeline(document)
used = {}
for event in playback['events']:
event['end'] = generator.model.rational(generator.model.fraction(event['onset']) + generator.model.fraction(event.pop('duration')))
pitch = generator.model.fraction(event.pop('pitch'))
if pitch not in tones:
tones[pitch] = generate_tone(root, pitch)
tone = tones[pitch]
event['sample'] = tone['id']
used[tone['id']] = tone
playback.update({'samples': list(used.values()), 'release': release, 'profile': PROFILE})
except PlaybackUnavailable as error:
playback = {'available': False, 'reason': str(error)}
document['playback'] = playback
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def validate_assets(playback: dict, root: pathlib.Path, cache: dict | None = None) -> None:
if not isinstance(playback, dict) or type(playback.get('available')) is not bool:
raise ValueError('playback manifest requires boolean availability')
if not playback['available']:
if not isinstance(playback.get('reason'), str) or not playback['reason']:
raise ValueError('unavailable playback must explain why')
return
cache = {} if cache is None else cache
def check_sample(value):
name = value['path']
relative = pathlib.Path(name)
if not name.startswith('audio/') or relative.is_absolute() or relative.suffix != '.wav' or any(p in ('', '.', '..') for p in name.split('/')) or '\\' in name or '\x00' in name:
raise ValueError('invalid generated audio path')
path = (root / relative).resolve()
if not path.is_relative_to(root.resolve()):
raise ValueError('generated audio escapes asset root')
key = (str(path), value['sha256'], value['frames'], value['sample_rate'])
if key in cache:
return cache[key]
if value['sample_rate'] != SAMPLE_RATE or not 0 < value['frames'] <= 96000:
raise ValueError('invalid generated audio rate/length')
if path.stat().st_size != 44 + value['frames'] * 2 or hashlib.sha256(path.read_bytes()).hexdigest() != value['sha256']:
raise ValueError('generated audio checksum mismatch')
with wave.open(str(path)) as audio:
pcm = audio.readframes(value['frames'])
if (audio.getnchannels(), audio.getsampwidth(), audio.getframerate(), audio.getnframes()) != (1, 2, SAMPLE_RATE, value['frames']) or len(pcm) != value['frames'] * 2:
raise ValueError('invalid or truncated generated PCM WAV')
samples = array.array('h')
samples.frombytes(pcm)
if sys.byteorder != 'little':
samples.byteswap()
if min(samples) < -32767:
raise ValueError('generated PCM exceeds unit range')
cache[key] = samples
return samples
duration = generator.model.fraction(playback['duration'])
if duration <= 0 or len(playback['events']) > MAX_EVENTS:
raise ValueError('invalid playback timeline length')
release = check_sample(playback['release'])
if not 1 < playback['release']['frames'] <= SAMPLE_RATE // 2:
raise ValueError('invalid release envelope length')
if release[0] != 32767 or release[-1] != 0 or any(a < b for a, b in zip(release, release[1:])):
raise ValueError('release envelope must decrease from one to zero')
if len(playback['samples']) > 512 or sum(s['frames'] for s in playback['samples']) > 16 * 1024 * 1024:
raise ValueError('generated playback exceeds sample memory budget')
samples = {}
for sample in playback['samples']:
if not sample['id'] or sample['id'] in samples or not 0 <= sample['loop_start'] < sample['loop_end'] == sample['frames']:
raise ValueError('invalid sample identity or loop')
check_sample(sample)
samples[sample['id']] = sample
previous = fractions.Fraction()
identities = set()
for event in playback['events']:
onset, end = generator.model.fraction(event['onset']), generator.model.fraction(event['end'])
if event['id'] in identities or event['sample'] not in samples or not previous <= onset < end <= duration:
raise ValueError('invalid playback event identity, reference or timing')
identities.add(event['id'])
previous = onset
+155 -119
View File
@@ -12,79 +12,90 @@ import functools
import json import json
import multiprocessing import multiprocessing
import os import os
from pathlib import Path import pathlib
import tempfile import tempfile
import time import time
from fractions import Fraction as F import fractions
from generator import accompaniment, comping, fretboard, model, musicxml, parts, score, vocabulary import generator.accompaniment
from generator.arrange import form, generate, pickup import generator.comping
from generator.solo import analysis, composer, notation, report, learned import generator.fretboard
from generator.solo import authored as repertoire import generator.model
import generator.musicxml
import generator.parts
import generator.score
import generator.vocabulary
import generator.arrange.form
import generator.arrange.generate
import generator.arrange.pickup
import generator.solo.analysis
import generator.solo.composer
import generator.solo.notation
import generator.solo.report
import generator.solo.learned
import generator.solo.authored
import generator.input_audit
import generator.solo.arrangement
VERSION = 'independent-roles-1' VERSION = 'independent-roles-1'
ROLES = ('acoustic_accompaniment', 'electric_accompaniment', 'electric_melody', 'vocal_melody', 'acoustic_solo', 'electric_solo') ROLES = ('acoustic_accompaniment', 'electric_accompaniment', 'electric_melody', 'vocal_melody', 'acoustic_solo', 'electric_solo')
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class SourceAnalysis: class SourceAnalysis:
document_json: str document_json: str
musical_key: str musical_key: str
@classmethod @classmethod
def read(cls, document): def read(cls, document):
prepared = copy.deepcopy(document) prepared = copy.deepcopy(document)
staff = score.source_staff(prepared) staff = generator.score.source_staff(prepared)
for i, m in enumerate(staff['measures']): for i, m in enumerate(staff['measures']):
m['id'], m['number'] = f'source_{i}', str(i + 1) m['id'], m['number'] = f'source_{i}', str(i + 1)
staff['tuplets'] = score.reidentify(staff['measures'], staff.get('tuplets', [])) staff['tuplets'] = generator.score.reidentify(staff['measures'], staff.get('tuplets', []))
prepared['metadata'] = {k: v for k, v in prepared['metadata'].items() if k not in ('title', 'composer', 'copyright', 'software')} prepared['metadata'] = {k: v for k, v in prepared['metadata'].items() if k not in ('title', 'composer', 'copyright', 'software')}
prepared['metadata'].update(title='Source', composer='') prepared['metadata'].update(title='Source', composer='')
staff.update(id='source', name='Source') staff.update(id='source', name='Source')
melodic = analysis.analyze(prepared) melodic = generator.solo.analysis.analyze(prepared)
content = {'melody': [(str(n.start), str(n.end), dataclasses.asdict(n.pitch)) for n in melodic.melody], content = {'melody': [(str(n.start), str(n.end), dataclasses.asdict(n.pitch)) for n in melodic.melody],
'measures': [{k: m.get(k) for k in ('duration', 'time_signature', 'key', 'harmonies', 'tempos', 'section_label', 'implicit')} for m in staff['measures']]} 'measures': [{k: m.get(k) for k in ('duration', 'time_signature', 'key', 'harmonies', 'tempos', 'section_label', 'implicit')} for m in staff['measures']]}
# Fraction/string spelling is canonical and never includes filesystem IDs. # Fraction/string spelling is canonical and never includes filesystem IDs.
key = hashlib.sha256(json.dumps(content, sort_keys=True, default=str).encode()).hexdigest() key = hashlib.sha256(json.dumps(content, sort_keys=True, default=str).encode()).hexdigest()
return cls(json.dumps(prepared, sort_keys=True), key) return cls(json.dumps(prepared, sort_keys=True), key)
def document(self): def document(self):
return json.loads(self.document_json) return json.loads(self.document_json)
def windows(document, part): def windows(document, part):
source = score.source_staff(document)['measures'] source = generator.score.source_staff(document)['measures']
sections = form.full_arrangement_sections(source, part) sections = generator.arrange.form.full_arrangement_sections(source, part)
result, cursor = [], F() result, cursor = [], fractions.Fraction()
for section in sections: for section in sections:
duration = sum((model.fraction(m['duration']) for m in source[section.source_measure_index:section.source_measure_index+section.measure_count]), F()) duration = sum((generator.model.fraction(m['duration']) for m in source[section.source_measure_index:section.source_measure_index + section.measure_count]), fractions.Fraction())
role = {'slash': 'solo', 'vocal': 'melody'}.get(section.role, section.role) role = {'slash': 'solo', 'vocal': 'melody'}.get(section.role, section.role)
if section.role in ('vocal', 'melody') and pickup.enters(section, source) and result: if section.role in ('vocal', 'melody') and generator.arrange.pickup.enters(section, source) and result:
attacks = [model.fraction(n['onset']) for n in score.measure_notes(source[0]) if n['pitches']] attacks = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(source[0]) if n['pitches']]
if attacks: if attacks:
start = cursor-model.fraction(source[0]['duration'])+min(attacks) start = cursor - generator.model.fraction(source[0]['duration']) + min(attacks)
a, _, old = result[-1]; result[-1] = (a, start, old) a, _, old = result[-1]
result[-1] = (a, start, old)
result.append((start, cursor, part + '_' + role)) result.append((start, cursor, part + '_' + role))
end = cursor + duration end = cursor + duration
start = cursor start = cursor
if section.role == 'slash' and score.body_start_measure_index(source) and result: if section.role == 'slash' and generator.score.body_start_measure_index(source) and result:
# Allocate the source's complete pickup bar before composition. This # Allocate the source's complete pickup bar before composition. This
# does not inspect either generated solo; a role may leave it silent. # does not inspect either generated solo; a role may leave it silent.
start=cursor-model.fraction(source[0]['duration']) start = cursor - generator.model.fraction(source[0]['duration'])
a,_,old=result[-1];result[-1]=(a,start,old) a, _, old = result[-1]
result[-1] = (a, start, old)
result.append((start, end, part + '_' + role)) result.append((start, end, part + '_' + role))
cursor = end cursor = end
return tuple(result) return tuple(result)
def dependencies(role, resources): def dependencies(role, resources):
names = ('harmony', 'lead_ins') if role.endswith('_solo') else ('accompaniment',) if role.endswith('_accompaniment') else () names = ['harmony', 'lead_ins'] if role.endswith('_solo') else ['accompaniment'] if role.endswith('_accompaniment') else []
return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']}, return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']},
**{name: resources[name] for name in names}} **{name: resources[name] for name in names}}
def _code_key(role): def _code_key(role):
root = Path(__file__).parent root = pathlib.Path(__file__).parent
paths = [root / n for n in ('production.py', 'model.py', 'harmony.py', 'pitch.py', 'fretboard.py', 'parts.py', 'score.py', 'vocabulary.py')] paths = [root / n for n in ('production.py', 'model.py', 'harmony.py', 'pitch.py', 'fretboard.py', 'parts.py', 'score.py', 'vocabulary.py')]
paths += list((root / 'arrange').glob('*.py')) paths += list((root / 'arrange').glob('*.py'))
if role.endswith('_solo'): if role.endswith('_solo'):
@@ -94,62 +105,65 @@ def _code_key(role):
paths += [root / n for n in ('comping.py', 'accompaniment.py', 'guitar.py')] paths += [root / n for n in ('comping.py', 'accompaniment.py', 'guitar.py')]
elif role == 'electric_melody': elif role == 'electric_melody':
paths += [root / 'guitar.py', root / 'melody.py'] paths += [root / 'guitar.py', root / 'melody.py']
return vocabulary.fingerprint([(p.relative_to(root).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted(set(paths))]) return generator.vocabulary.fingerprint([(p.relative_to(root).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted(set(paths))])
def _resource_key(directory, role): def _resource_key(directory, role):
return {'model': hashlib.sha256((directory / 'models' / 'solo.json').read_bytes()).hexdigest()} if role.endswith('_solo') else {} return {'model': hashlib.sha256((directory / 'models' / 'solo.json').read_bytes()).hexdigest()} if role.endswith('_solo') else {}
@functools.lru_cache(maxsize=4) @functools.lru_cache(maxsize=4)
def _solo_model(directory, fingerprint): def _solo_model(directory, fingerprint):
return learned.Model.load(Path(directory)/'models'/'solo.json') return generator.solo.learned.Model.load(pathlib.Path(directory) / 'models' / 'solo.json')
def _mask(document, allowed): def _mask(document, allowed):
"""Apply source-defined role exclusions, without observing another part.""" """Apply source-defined role exclusions, without observing another part."""
absolute = F() absolute = fractions.Fraction()
for measure in document['staves'][0]['measures']: for measure in document['staves'][0]['measures']:
length = model.fraction(measure['duration']) length = generator.model.fraction(measure['duration'])
for voice in measure['voices']: for voice in measure['voices']:
selected = [] selected = []
for n in voice['notes']: for n in voice['notes']:
start = absolute+model.fraction(n['onset']) start = absolute + generator.model.fraction(n['onset'])
end = start+model.fraction(n['duration']) end = start + generator.model.fraction(n['duration'])
intervals = [(max(start, a), min(end, b)) for a, b in allowed if a < end and b > start] intervals = [(max(start, a), min(end, b)) for a, b in allowed if a < end and b > start]
for a, b in intervals: for a, b in intervals:
note = copy.deepcopy(n) note = copy.deepcopy(n)
if a != start: if a != start:
note['onset'] = model.rational(a-absolute) note['onset'] = generator.model.rational(a - absolute)
for pitch in note['pitches']: pitch.pop('tie_from',None) for pitch in note['pitches']:
pickup.truncate(note,b-absolute) pitch.pop('tie_from', None)
if a < b: selected.append(note) generator.arrange.pickup.truncate(note, b - absolute)
if a < b:
selected.append(note)
voice['notes'] = _fill(selected, length, measure['id'] + '_' + voice['id'], voice['id']) voice['notes'] = _fill(selected, length, measure['id'] + '_' + voice['id'], voice['id'])
absolute += length absolute += length
staff = document['staves'][0] staff = document['staves'][0]
staff['tuplets']=pickup.retain_links(staff['measures'],staff.get('tuplets',[])) staff['tuplets'] = generator.arrange.pickup.retain_links(staff['measures'], staff.get('tuplets', []))
def _fill(notes, length, prefix, voice='voice_arranged'): def _fill(notes, length, prefix, voice='voice_arranged'):
notes=sorted(notes,key=lambda n:model.fraction(n['onset'])) notes = sorted(notes, key=lambda n: generator.model.fraction(n['onset']))
result=[]; cursor=F() result = []
cursor = fractions.Fraction()
def rests(end): def rests(end):
nonlocal cursor nonlocal cursor
for duration,written in accompaniment.written_durations(end-cursor): for duration, written in generator.accompaniment.written_durations(end - cursor):
result.append(model.make_event(prefix+'_rest_'+str(len(result)),cursor,duration,'rest',written=written)|{'voice':voice}) result.append(generator.model.make_event(prefix + '_rest_' + str(len(result)), cursor, duration, 'rest', written=written) | {'voice': voice})
cursor += duration cursor += duration
for n in notes: for n in notes:
start=model.fraction(n['onset']) start = generator.model.fraction(n['onset'])
if start<cursor: raise ValueError(prefix+': overlapping independently generated roles') if start < cursor:
rests(start); n['voice']=voice;result.append(n) raise ValueError(prefix + ': overlapping independently generated roles')
cursor=start+model.fraction(n['duration']) rests(start)
n['voice'] = voice
result.append(n)
cursor = start + generator.model.fraction(n['duration'])
rests(length) rests(length)
return result return result
def generate_role(source, role, data_dir, settings=None, cache_dir=None): def generate_role(source, role, data_dir, settings=None, cache_dir=None):
from generator.input_audit import composition
started = time.monotonic() started = time.monotonic()
with composition(data_dir,role,cache_dir) as reads: with generator.input_audit.composition(data_dir, role, cache_dir) as reads:
result = _generate_role(source, role, data_dir, settings, cache_dir) result = _generate_role(source, role, data_dir, settings, cache_dir)
# Observed reads are diagnostics, never cache identity or musical seeds. # Observed reads are diagnostics, never cache identity or musical seeds.
result['trace'] = {**result['trace'], 'input_boundary': 'production data (including explicit authored repertoire), generator code, Python runtime, own role cache'} result['trace'] = {**result['trace'], 'input_boundary': 'production data (including explicit authored repertoire), generator code, Python runtime, own role cache'}
@@ -157,42 +171,49 @@ def generate_role(source, role, data_dir, settings=None, cache_dir=None):
result['seconds'] = time.monotonic() - started result['seconds'] = time.monotonic() - started
return result return result
def _generate_role(source, role, data_dir, settings=None, cache_dir=None): def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
if role not in ROLES: raise ValueError('Unknown generation role: '+role) if role not in ROLES:
data_dir=Path(data_dir); resources=vocabulary.load(data_dir/'composition') raise ValueError('Unknown generation role: ' + role)
data_dir = pathlib.Path(data_dir)
resources = generator.vocabulary.load(data_dir / 'composition')
settings = settings or {} settings = settings or {}
registered=repertoire.lookup(source,role.removesuffix('_solo'),data_dir) if role.endswith('_solo') and not settings.get('predict',False) else None registered = generator.solo.authored.lookup(source, role.removesuffix('_solo'), data_dir) if role.endswith('_solo') and not settings.get('predict', False) else None
algorithm=VERSION+'/'+learned.VERSION if role.endswith('_solo') else VERSION algorithm = VERSION + '/' + generator.solo.learned.VERSION if role.endswith('_solo') else VERSION
inputs = {'version': algorithm, 'source': source.musical_key, 'role': role, 'settings': settings, inputs = {'version': algorithm, 'source': source.musical_key, 'role': role, 'settings': settings,
'vocabulary': dependencies(role, resources), 'catalogs': ({'authored': registered['sha256'], 'id': registered['id'], 'pitch_convention': registered['pitch_convention']} if registered else _resource_key(data_dir, role)), 'code': _code_key(role)} 'vocabulary': dependencies(role, resources), 'catalogs': ({'authored': registered['sha256'], 'id': registered['id'], 'pitch_convention': registered['pitch_convention']} if registered else _resource_key(data_dir, role)), 'code': _code_key(role)}
# Source notation/lyrics also invalidate cached score representation, never the musical seed. # Source notation/lyrics also invalidate cached score representation, never the musical seed.
key=vocabulary.fingerprint({'inputs':inputs,'notation':source.document_json}) key = generator.vocabulary.fingerprint({'inputs': inputs, 'notation': source.document_json})
path=Path(cache_dir)/role/(key+'.json') if cache_dir else None path = pathlib.Path(cache_dir) / role / (key + '.json') if cache_dir else None
if path and path.is_file() and not path.is_symlink(): if path and path.is_file() and not path.is_symlink():
try: try:
entry=json.loads(path.read_text()); value=entry['result'] entry = json.loads(path.read_text())
if entry['key']==key and entry['digest']==vocabulary.fingerprint(value): value = entry['result']
model.validate_document(value['document']); fretboard.validate_document(value['document']) if entry['key'] == key and entry['digest'] == generator.vocabulary.fingerprint(value):
generator.model.validate_document(value['document'])
generator.fretboard.validate_document(value['document'])
return {**value, 'cache_hit': True} return {**value, 'cache_hit': True}
except (ValueError,KeyError,TypeError): pass except (ValueError, KeyError, TypeError):
started=time.monotonic(); document=source.document(); part=role.split('_')[0] pass
started = time.monotonic()
document = source.document()
part = role.split('_')[0]
trace = {'source': source.musical_key, 'role': role, 'production_resources': inputs['catalogs'], trace = {'source': source.musical_key, 'role': role, 'production_resources': inputs['catalogs'],
'vocabulary':vocabulary.fingerprint(inputs['vocabulary']),'settings':settings,'algorithm':algorithm,'code':inputs['code'],'cache_key':key} 'vocabulary': generator.vocabulary.fingerprint(inputs['vocabulary']), 'settings': settings, 'algorithm': algorithm, 'code': inputs['code'], 'cache_key': key}
seed=vocabulary.fingerprint({'source':source.musical_key,'role':role,'settings':settings,'data':inputs['vocabulary'],'version':algorithm}) seed = generator.vocabulary.fingerprint({'source': source.musical_key, 'role': role, 'settings': settings, 'data': inputs['vocabulary'], 'version': algorithm})
details={}; solo_report=''; composition_timings={} details = {}
with vocabulary.using(resources,role,settings|{'choice_seed':seed}): solo_report = ''
composition_timings = {}
with generator.vocabulary.using(resources, role, settings | {'choice_seed': seed}):
if role.endswith('_solo'): if role.endswith('_solo'):
result=generate._arrange_document(document,part,{},owned={'slash'}) result = generator.arrange.generate._arrange_document(document, part, {}, owned={'slash'})
from generator.solo.arrangement import regions
si = 3 if part == 'acoustic' else 4 si = 3 if part == 'acoustic' else 4
first,count,offset,length=regions(document)[si] first, count, offset, length = generator.solo.arrangement.regions(document)[si]
# The template/harmony comes directly from source form, not another generated part. # The template/harmony comes directly from source form, not another generated part.
authored=score.source_staff(document)['measures'] authored = generator.score.source_staff(document)['measures']
section=form.full_arrangement_sections(authored,part)[si] section = generator.arrange.form.full_arrangement_sections(authored, part)[si]
effective=generate.section_source(authored,section,form.full_arrangement_sections(authored,part)[si+1]) effective = generator.arrange.generate.section_source(authored, section, generator.arrange.form.full_arrangement_sections(authored, part)[si + 1])
template = copy.deepcopy(effective[section.source_measure_index:section.source_measure_index + count]) template = copy.deepcopy(effective[section.source_measure_index:section.source_measure_index + count])
initial=accompaniment.carried_chords(effective)[section.source_measure_index] initial = generator.accompaniment.carried_chords(effective)[section.source_measure_index]
start, end = next((a - offset, b - offset) for a, b, r in windows(document, part) if r == role) start, end = next((a - offset, b - offset) for a, b, r in windows(document, part) if r == role)
notation_first = first notation_first = first
if start < 0: if start < 0:
@@ -200,35 +221,35 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
# not invent harmony from a comparison score's unlabelled lead-in. # not invent harmony from a comparison score's unlabelled lead-in.
template.insert(0, copy.deepcopy(result['staves'][0]['measures'][first - 1])) template.insert(0, copy.deepcopy(result['staves'][0]['measures'][first - 1]))
notation_first -= 1 notation_first -= 1
initial=accompaniment.carried_chords(result['staves'][0]['measures'])[first-1] initial = generator.accompaniment.carried_chords(result['staves'][0]['measures'])[first - 1]
if registered: if registered:
solo=repertoire.realize(registered,analysis.analyze(document),template,part,start,end,initial) solo = generator.solo.authored.realize(registered, generator.solo.analysis.analyze(document), template, part, start, end, initial)
else: else:
solo_model = _solo_model(str(data_dir), inputs['catalogs']['model']) solo_model = _solo_model(str(data_dir), inputs['catalogs']['model'])
solo=composer.compose(analysis.analyze(document),template,part,end, solo = generator.solo.composer.compose(generator.solo.analysis.analyze(document), template, part, end, initial=initial, learned_model=solo_model, expressions_enabled=generator.vocabulary.role().get('expressions', True), start=start)
initial=initial,learned_model=solo_model,
expressions_enabled=vocabulary.role().get('expressions',True),start=start)
composition_timings = {k: v for k, v in solo.diagnostics.items() if k.endswith('_seconds')} composition_timings = {k: v for k, v in solo.diagnostics.items() if k.endswith('_seconds')}
relative = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in solo.notes()) relative = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in solo.notes())
output=notation.measures(result['staves'][0]['measures'][notation_first:first+count],relative,'solo_'+part) output = generator.solo.notation.measures(result['staves'][0]['measures'][notation_first:first + count], relative, 'solo_' + part)
notation.replace_region(result,notation_first,output) generator.solo.notation.replace_region(result, notation_first, output)
details=report.structure(solo) details = generator.solo.report.structure(solo)
details['allocation'] = {'start': str(start), 'end': str(end), 'lead_in_measures': first - notation_first} details['allocation'] = {'start': str(start), 'end': str(end), 'lead_in_measures': first - notation_first}
solo_report=report.describe(solo,first,offset,detail=details) solo_report = generator.solo.report.describe(solo, first, offset, detail=details)
elif role.endswith('_accompaniment'): elif role.endswith('_accompaniment'):
canonical=comping.Canonical(document,part,seed) canonical = generator.comping.Canonical(document, part, seed)
result=generate._arrange_document(document,part,{},owned={'accompaniment'},accompaniment=canonical) result = generator.arrange.generate._arrange_document(document, part, {}, owned={'accompaniment'}, accompaniment=canonical)
details = {'canonical_comping': canonical.report} details = {'canonical_comping': canonical.report}
else: else:
result=generate._arrange_document(document,part,{},owned={'vocal' if part=='vocal' else 'melody'}) result = generator.arrange.generate._arrange_document(document, part, {}, owned={'vocal' if part == 'vocal' else 'melody'})
allowed = tuple((a, b) for a, b, r in windows(document, part) if r == role) allowed = tuple((a, b) for a, b, r in windows(document, part) if r == role)
_mask(result, allowed) _mask(result, allowed)
staff = result['staves'][0] staff = result['staves'][0]
for measure in staff['measures']: for measure in staff['measures']:
measure['id'] = role + '_' + measure['id'] measure['id'] = role + '_' + measure['id']
staff['tuplets']=score.reidentify(staff['measures'],staff.get('tuplets',[])) staff['tuplets'] = generator.score.reidentify(staff['measures'], staff.get('tuplets', []))
if part!='vocal':result['staves'][0]['tablature']=True if part != 'vocal':
model.validate_document(result);fretboard.validate_document(result) result['staves'][0]['tablature'] = True
generator.model.validate_document(result)
generator.fretboard.validate_document(result)
value = {'role': role, 'document': result, 'trace': trace, 'details': details, 'report': solo_report, 'seconds': time.monotonic() - started, 'composition_timings': composition_timings, 'cache_hit': False} value = {'role': role, 'document': result, 'trace': trace, 'details': details, 'report': solo_report, 'seconds': time.monotonic() - started, 'composition_timings': composition_timings, 'cache_hit': False}
# Sidecar diagnostics use the same rational-safe representation as reports. # Sidecar diagnostics use the same rational-safe representation as reports.
# Identical insertion order after cold generation and cache loading also # Identical insertion order after cold generation and cache loading also
@@ -236,78 +257,93 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
value = json.loads(json.dumps(value, default=str, sort_keys=True)) value = json.loads(json.dumps(value, default=str, sort_keys=True))
if path: if path:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
payload={'key':key,'digest':vocabulary.fingerprint(value),'result':value} payload = {'key': key, 'digest': generator.vocabulary.fingerprint(value), 'result': value}
with tempfile.NamedTemporaryFile('w', dir=path.parent, delete=False) as out: with tempfile.NamedTemporaryFile('w', dir=path.parent, delete=False) as out:
json.dump(payload,out,sort_keys=True);temporary=out.name json.dump(payload, out, sort_keys=True)
temporary = out.name
os.replace(temporary, path) os.replace(temporary, path)
return value return value
def assemble(source, results): def assemble(source, results):
"""Union prevalidated role windows; no selection or pitch/fingering rewrite.""" """Union prevalidated role windows; no selection or pitch/fingering rewrite."""
documents = {} documents = {}
for part in parts.PART_TYPES: for part in generator.parts.PART_TYPES:
selected = [results[r]['document'] for r in ROLES if r.startswith(part + '_')] selected = [results[r]['document'] for r in ROLES if r.startswith(part + '_')]
document=copy.deepcopy(selected[0]); staff=document['staves'][0] document = copy.deepcopy(selected[0])
staff = document['staves'][0]
if part == 'vocal': if part == 'vocal':
documents[part]=document;continue documents[part] = document
staff['tuplets']=[];staff['octave_shifts']=[];staff['scale_spans']=[] continue
staff['tuplets'] = []
staff['octave_shifts'] = []
staff['scale_spans'] = []
for other in selected: for other in selected:
for field in ('tuplets', 'octave_shifts', 'scale_spans'): for field in ('tuplets', 'octave_shifts', 'scale_spans'):
staff[field].extend(copy.deepcopy(other['staves'][0].get(field, []))) staff[field].extend(copy.deepcopy(other['staves'][0].get(field, [])))
for i, m in enumerate(staff['measures']): for i, m in enumerate(staff['measures']):
# Tuplet rests belong to the source/role rhythm and must survive union. # Tuplet rests belong to the source/role rhythm and must survive union.
sounding=[copy.deepcopy(n) for d in selected for n in score.measure_notes(d['staves'][0]['measures'][i]) if n['pitches'] or n.get('tuplet_ids')] sounding = [copy.deepcopy(n) for d in selected for n in generator.score.measure_notes(d['staves'][0]['measures'][i]) if n['pitches'] or n.get('tuplet_ids')]
m['voices']=[{'id':'voice_arranged','notes':_fill(sounding,model.fraction(m['duration']),m['id'])}] m['voices'] = [{'id': 'voice_arranged', 'notes': _fill(sounding, generator.model.fraction(m['duration']), m['id'])}]
# Attribute changes belong to source melody pickups and are assembly metadata. # Attribute changes belong to source melody pickups and are assembly metadata.
for d in selected: for d in selected:
other = d['staves'][0]['measures'][i] other = d['staves'][0]['measures'][i]
if other.get('attributes'):m['attributes']=copy.deepcopy(other['attributes']) if other.get('attributes'):
staff['tuplets']=pickup.retain_links(staff['measures'],staff['tuplets']) m['attributes'] = copy.deepcopy(other['attributes'])
for span in staff['tuplets']:span['voice']='voice_arranged' staff['tuplets'] = generator.arrange.pickup.retain_links(staff['measures'], staff['tuplets'])
model.validate_document(document);fretboard.validate_document(document) for span in staff['tuplets']:
span['voice'] = 'voice_arranged'
generator.model.validate_document(document)
generator.fretboard.validate_document(document)
documents[part] = document documents[part] = document
return documents return documents
def _job(request): def _job(request):
source, role, directory, settings, cache = request source, role, directory, settings, cache = request
return generate_role(source, role, directory, settings, cache) return generate_role(source, role, directory, settings, cache)
def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=False, timings=None): def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=False, timings=None):
if type(jobs) is not int or not 1 <= jobs <= 16: if type(jobs) is not int or not 1 <= jobs <= 16:
raise ValueError('composition workers must be between 1 and 16') raise ValueError('composition workers must be between 1 and 16')
source_dir,output_dir=Path(source_dir),Path(output_dir) source_dir, output_dir = pathlib.Path(source_dir), pathlib.Path(output_dir)
sources = {} sources = {}
for path in parts.source_paths(source_dir): for path in generator.parts.source_paths(source_dir):
document=parts.prepare_document(musicxml.load_musicxml(path)) document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
sources[path] = (document, SourceAnalysis.read(document)) sources[path] = (document, SourceAnalysis.read(document))
requests = [(path, role, (s, role, source_dir.parent, {}, cache_dir)) for path, (_, s) in sources.items() for role in ROLES] requests = [(path, role, (s, role, source_dir.parent, {}, cache_dir)) for path, (_, s) in sources.items() for role in ROLES]
results = {p: {} for p in sources} results = {p: {} for p in sources}
def accept(path, role, value): def accept(path, role, value):
results[path][role] = value results[path][role] = value
if timings is not None:timings.append({'source':str(path),'role':role,'seconds':value['seconds'],'cache_hit':value['cache_hit'],'composition_timings':value.get('composition_timings',{}),'observed_reads':value.get('observed_reads',[])}) if timings is not None:
if progress:print(f'Composition: {path.stem}/{role}: '+('cached' if value['cache_hit'] else f'{value["seconds"]:.1f}s'),flush=True) timings.append({'source': str(path), 'role': role, 'seconds': value['seconds'], 'cache_hit': value['cache_hit'], 'composition_timings': value.get('composition_timings', {}), 'observed_reads': value.get('observed_reads', [])})
if progress:
print(f'Composition: {path.stem}/{role}: ' + ('cached' if value['cache_hit'] else f'{value["seconds"]:.1f}s'), flush=True)
if jobs == 1: if jobs == 1:
for path, role, args in requests: for path, role, args in requests:
try:accept(path,role,_job(args)) try:
except Exception as error:raise ValueError(f'{path.name}/{role}: {error}') from error accept(path, role, _job(args))
except Exception as error:
raise ValueError(f'{path.name}/{role}: {error}') from error
else: else:
with concurrent.futures.ProcessPoolExecutor(max_workers=jobs, mp_context=multiprocessing.get_context('spawn')) as pool: with concurrent.futures.ProcessPoolExecutor(max_workers=jobs, mp_context=multiprocessing.get_context('spawn')) as pool:
futures = {pool.submit(_job, args): (path, role) for path, role, args in requests} futures = {pool.submit(_job, args): (path, role) for path, role, args in requests}
for future in concurrent.futures.as_completed(futures): for future in concurrent.futures.as_completed(futures):
path, role = futures[future] path, role = futures[future]
try:accept(path,role,future.result()) try:
except Exception as error:raise ValueError(f'{path.name}/{role}: {error}') from error accept(path, role, future.result())
except Exception as error:
raise ValueError(f'{path.name}/{role}: {error}') from error
for path, (original, source) in sources.items(): for path, (original, source) in sources.items():
assembled = assemble(source, results[path]) assembled = assemble(source, results[path])
for part, document in assembled.items(): for part, document in assembled.items():
document['metadata'] = copy.deepcopy(original['metadata']) document['metadata'] = copy.deepcopy(original['metadata'])
score.write_json(output_dir/(score.slugify(path.stem)+'_'+part+'.json'),document) document['metadata']['song_id'] = generator.score.slugify(path.stem)
analysis_dir=output_dir.parent/'analysis';analysis_dir.mkdir(parents=True,exist_ok=True) generator.score.write_json(output_dir / (generator.score.slugify(path.stem) + '_' + part + '.json'), document)
(analysis_dir/(score.slugify(path.stem)+'.txt')).write_text('\n'.join(results[path][r]['report'] for r in ROLES)) analysis_dir = output_dir.parent / 'analysis'
analysis_dir.mkdir(parents=True, exist_ok=True)
(analysis_dir / (generator.score.slugify(path.stem) + '.txt')).write_text('\n'.join(results[path][r]['report'] for r in ROLES))
# Not a score file: diagnostics are deliberately outside the songs schema. # Not a score file: diagnostics are deliberately outside the songs schema.
stable = {role: {k: v for k, v in value.items() if k not in ('seconds', 'composition_timings', 'cache_hit', 'observed_reads')} for role, value in results[path].items()} stable = {role: {k: v for k, v in value.items() if k not in ('seconds', 'composition_timings', 'cache_hit', 'observed_reads')} for role, value in results[path].items()}
(analysis_dir/(score.slugify(path.stem)+'.roles')).write_text(json.dumps(stable,sort_keys=True,indent=2)) (analysis_dir / (generator.score.slugify(path.stem) + '.roles')).write_text(json.dumps(stable, sort_keys=True, indent=2))
return len(sources) * 3 return len(sources) * 3
+4 -4
View File
@@ -9,7 +9,7 @@ import generator.engraving.validation
import generator.fretboard import generator.fretboard
import generator.model import generator.model
import generator.pico_artwork import generator.pico_artwork
import generator.playback import generator.performance
def load_json(path: pathlib.Path) -> dict: def load_json(path: pathlib.Path) -> dict:
document = json.loads(path.read_text(encoding='utf-8')) document = json.loads(path.read_text(encoding='utf-8'))
@@ -24,6 +24,9 @@ def validate_json(document: dict, path: pathlib.Path, validated_pages: set[str]
raise ValueError('invalid score bundle; regenerate score assets') raise ValueError('invalid score bundle; regenerate score assets')
if (presentation.get('engine'), presentation.get('width'), presentation.get('height')) != (generator.engraving.validation.ENGINE_NAME, generator.engraving.validation.PAGE_WIDTH, generator.engraving.validation.PAGE_HEIGHT): if (presentation.get('engine'), presentation.get('width'), presentation.get('height')) != (generator.engraving.validation.ENGINE_NAME, generator.engraving.validation.PAGE_WIDTH, generator.engraving.validation.PAGE_HEIGHT):
raise ValueError('unsupported score presentation; regenerate score assets') raise ValueError('unsupported score presentation; regenerate score assets')
generator.performance.validate_pages(document['music'], presentation['pages'])
if document.get('performance') != generator.performance.timing(document['music']):
raise ValueError('invalid performance timing')
for page in presentation['pages']: for page in presentation['pages']:
if not isinstance(page, dict) or not isinstance(page.get('path'), str) or not isinstance(page.get('sha256'), str): if not isinstance(page, dict) or not isinstance(page.get('path'), str) or not isinstance(page.get('sha256'), str):
raise ValueError('invalid score page manifest') raise ValueError('invalid score page manifest')
@@ -126,7 +129,6 @@ def count_json_files(root: pathlib.Path) -> int:
return sum(1 for p in root.rglob('*.json') if p.is_file()) return sum(1 for p in root.rglob('*.json') if p.is_file())
def validate_generated_tree(root: pathlib.Path, validated_pages: set[str] | None = None) -> None: def validate_generated_tree(root: pathlib.Path, validated_pages: set[str] | None = None) -> None:
audio_cache = {}
validated_pages = set() if validated_pages is None else validated_pages validated_pages = set() if validated_pages is None else validated_pages
for path in root.rglob('*.json'): for path in root.rglob('*.json'):
document = json.loads(path.read_text(encoding='utf-8')) document = json.loads(path.read_text(encoding='utf-8'))
@@ -134,5 +136,3 @@ def validate_generated_tree(root: pathlib.Path, validated_pages: set[str] | None
generator.fretboard.validate_document(music) generator.fretboard.validate_document(music)
if 'presentation' in document: if 'presentation' in document:
generator.pico_artwork.validate_title(root, document['presentation'].get('title_image')) generator.pico_artwork.validate_title(root, document['presentation'].get('title_image'))
if 'playback' in document:
generator.playback.validate_assets(document['playback'], root, audio_cache)
+2 -1
View File
@@ -39,6 +39,7 @@ def attacks(document: dict) -> tuple[generator.solo.types.Note, ...]:
def analyze(document): def analyze(document):
measures = generator.score.source_staff(document)['measures'] measures = generator.score.source_staff(document)['measures']
boundaries = [fractions.Fraction()] boundaries = [fractions.Fraction()]
for m in measures:boundaries.append(boundaries[-1]+generator.model.fraction(m['duration'])) for m in measures:
boundaries.append(boundaries[-1] + generator.model.fraction(m['duration']))
tempo = next((generator.model.fraction(t['bpm']) * generator.model.written_length(t['written']) for m in measures for t in m.get('tempos', []) if 'bpm' in t), fractions.Fraction(120)) tempo = next((generator.model.fraction(t['bpm']) * generator.model.written_length(t['written']) for m in measures for t in m.get('tempos', []) if 'bpm' in t), fractions.Fraction(120))
return Analysis(tuple(boundaries), tempo, attacks(document), boundaries[generator.score.body_start_measure_index(measures)]) return Analysis(tuple(boundaries), tempo, attacks(document), boundaries[generator.score.body_start_measure_index(measures)])
+60 -41
View File
@@ -8,70 +8,89 @@ from __future__ import annotations
import dataclasses import dataclasses
import hashlib import hashlib
import json import json
from pathlib import Path import pathlib
from fractions import Fraction as F import fractions
from generator import musicxml,parts,training_io,harmonic_time import generator.musicxml
from generator.solo import analysis,types,placement,profiles,harmonic import generator.parts
import generator.training_io
import generator.harmonic_time
import generator.solo.analysis
import generator.solo.types
import generator.solo.placement
import generator.solo.profiles
import generator.solo.harmonic
import generator.production
def sounding(evidence, offset=fractions.Fraction()):
def sounding(evidence,offset=F()):
result = [] result = []
for i, event in enumerate(evidence['sounding_events']): for i, event in enumerate(evidence['sounding_events']):
p=event['pitch'];number=F(event['sounding_midi']) p = event['pitch']
if number.denominator!=1:raise ValueError('Microtonal guitar solo is unsupported') number = fractions.Fraction(event['sounding_midi'])
pitch=types.Pitch(p['step'].lower(),F(p['alter']),p['octave']+(int(number)-int(F(event['encoded_midi'])))//12) if number.denominator != 1:
result.append(types.Note(F(event['start'])-offset,F(event['end'])-offset,pitch,role='authored',event_id=f'authored_{i}')) raise ValueError('Microtonal guitar solo is unsupported')
pitch = generator.solo.types.Pitch(p['step'].lower(), fractions.Fraction(p['alter']), p['octave'] + (int(number) - int(fractions.Fraction(event['encoded_midi']))) // 12)
result.append(generator.solo.types.Note(fractions.Fraction(event['start']) - offset, fractions.Fraction(event['end']) - offset, pitch, role='authored', event_id=f'authored_{i}'))
return tuple(result) return tuple(result)
def validate_evidence(document, evidence): def validate_evidence(document, evidence):
"""Training uses the same source-form harmonic context as solo playback.""" """Training uses the same source-form harmonic context as solo playback."""
h=harmonic.Harmony(harmonic_time.solo_source(document['staves'][0])) h = generator.solo.harmonic.Harmony(generator.harmonic_time.solo_source(document['staves'][0]))
checked = h.evaluate(sounding(evidence), complete=True) checked = h.evaluate(sounding(evidence), complete=True)
if not checked.valid:raise ValueError('Unsupported harmonic relationship in authored solo: '+checked.error) if not checked.valid:
raise ValueError('Unsupported harmonic relationship in authored solo: ' + checked.error)
return checked return checked
def lookup(source, role, data_dir): def lookup(source, role, data_dir):
from generator.production import SourceAnalysis root = pathlib.Path(data_dir)
root=Path(data_dir);manifest=root/'training/manifest.json' manifest = root / 'training/manifest.json'
if not manifest.exists():return None if not manifest.exists():
return None
value = json.loads(manifest.read_text()) value = json.loads(manifest.read_text())
if value.get('version')!=1:raise ValueError('Unsupported training manifest') if value.get('version') != 1:
raise ValueError('Unsupported training manifest')
matches = [] matches = []
for entry in value['examples']: for entry in value['examples']:
if entry['slot']!=(1 if role=='acoustic' else 2):continue if entry['slot'] != (1 if role == 'acoustic' else 2):
continue
path = (manifest.parent / entry['source']).resolve() path = (manifest.parent / entry['source']).resolve()
if not path.is_relative_to((root/'songs').resolve()):raise ValueError('Registered sources must be in data/songs') if not path.is_relative_to((root / 'songs').resolve()):
doc=parts.prepare_document(musicxml.load_musicxml(path)) raise ValueError('Registered sources must be in data/songs')
if SourceAnalysis.read(doc).musical_key==source.musical_key: doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
if generator.production.SourceAnalysis.read(doc).musical_key == source.musical_key:
target = (manifest.parent / entry['solo']).resolve() target = (manifest.parent / entry['solo']).resolve()
if not target.is_relative_to(manifest.parent.resolve()):raise ValueError('Registered solo must be inside data/training') if not target.is_relative_to(manifest.parent.resolve()):
evidence=training_io.read(target,role,pitch_convention=entry['pitch_convention']) raise ValueError('Registered solo must be inside data/training')
training_io.validate_harmony(doc,evidence,entry['id']) evidence = generator.training_io.read(target, role, pitch_convention=entry['pitch_convention'])
generator.training_io.validate_harmony(doc, evidence, entry['id'])
matches.append({**entry, 'path': target, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()}) matches.append({**entry, 'path': target, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
if len(matches)>1:raise ValueError('Multiple authored solos registered for the same source music and role') if len(matches) > 1:
raise ValueError('Multiple authored solos registered for the same source music and role')
return matches[0] if matches else None return matches[0] if matches else None
def realize(entry, source_analysis, measures, part, start, end, initial=None): def realize(entry, source_analysis, measures, part, start, end, initial=None):
evidence=training_io.read(entry['path'],part,pitch_convention=entry['pitch_convention']) evidence = generator.training_io.read(entry['path'], part, pitch_convention=entry['pitch_convention'])
if evidence['discrepancies']:raise ValueError(entry['id']+': invalid authored timing/ties: '+str(evidence['discrepancies'])) if evidence['discrepancies']:
raise ValueError(entry['id'] + ': invalid authored timing/ties: ' + str(evidence['discrepancies']))
expected = [(a, b - a) for a, b in zip(source_analysis.boundaries, source_analysis.boundaries[1:])] expected = [(a, b - a) for a, b in zip(source_analysis.boundaries, source_analysis.boundaries[1:])]
if [(F(m['start']),F(m['duration'])) for m in evidence['measures']]!=expected:raise ValueError(entry['id']+': source/solo forms differ; register and retrain the correct pair') if [(fractions.Fraction(m['start']), fractions.Fraction(m['duration'])) for m in evidence['measures']] != expected:
raise ValueError(entry['id'] + ': source/solo forms differ; register and retrain the correct pair')
notes = sounding(evidence, source_analysis.body_offset) notes = sounding(evidence, source_analysis.body_offset)
for n in notes: for n in notes:
if n.start<start or n.end>end:raise ValueError(f'{entry["id"]}: authored event {n.start}–{n.end} conflicts with arrangement window {start}–{end}; no clipping applied') if n.start < start or n.end > end:
profile=profiles.for_part(part,source_analysis) raise ValueError(f'{entry["id"]}: authored event {n.start}–{n.end} conflicts with arrangement window {start}–{end}; no clipping applied')
illegal=next((n for n in notes if not placement.positions(n.pitch.number(),profile)),None) profile = generator.solo.profiles.for_part(part, source_analysis)
if illegal:raise ValueError(f'{entry["id"]}: {illegal.pitch.label()} at {illegal.start} is outside strings 1–6/frets 1–12; authored solo left unchanged') illegal = next((n for n in notes if not generator.solo.placement.positions(n.pitch.number(), profile)), None)
placed=placement.place(tuple(notes),source_analysis.tempo,profile=profile) if illegal:
if placed is None:raise ValueError(entry['id']+': no legal complete guitar placement') raise ValueError(f'{entry["id"]}: {illegal.pitch.label()} at {illegal.start} is outside strings 1–6/frets 1–12; authored solo left unchanged')
spans=tuple(dataclasses.replace(s,start=s.start+start,end=s.end+start) for s in harmonic_time.read({'measures':measures},initial=initial)) placed = generator.solo.placement.place(tuple(notes), source_analysis.tempo, profile=profile)
checked=harmonic.Harmony(spans).evaluate(placed[1],complete=True) if placed is None:
if not checked.valid:raise ValueError(entry['id']+': '+checked.error) raise ValueError(entry['id'] + ': no legal complete guitar placement')
phrase=types.Phrase('authored',start,end,placed[1],'authored','authored','handoff',theme=entry['id'],expressions=checked.contracts) spans = tuple(dataclasses.replace(s, start=s.start + start, end=s.end + start) for s in generator.harmonic_time.read({'measures': measures}, initial=initial))
return types.Solo(part,spans,(phrase,),source_analysis.tempo,end, checked = generator.solo.harmonic.Harmony(spans).evaluate(placed[1], complete=True)
if not checked.valid:
raise ValueError(entry['id'] + ': ' + checked.error)
phrase = generator.solo.types.Phrase('authored', start, end, placed[1], 'authored', 'authored', 'handoff', theme=entry['id'], expressions=checked.contracts)
return generator.solo.types.Solo(part, spans, (phrase,), source_analysis.tempo, end,
{'construction': 'authored_repertoire', 'authored_id': entry['id'], 'authored_sha256': entry['sha256'], {'construction': 'authored_repertoire', 'authored_id': entry['id'], 'authored_sha256': entry['sha256'],
'pitch_convention': entry['pitch_convention'], 'exact_sounding_events': True, 'pitch_convention': entry['pitch_convention'], 'exact_sounding_events': True,
'note': 'Authored music is preserved and passes automatic contextual harmonic validation.', 'note': 'Authored music is preserved and passes automatic contextual harmonic validation.',
+44 -29
View File
@@ -1,57 +1,72 @@
"""The sole production solo composer: frozen learned model plus music validity.""" """The sole production solo composer: frozen learned model plus music validity."""
import dataclasses import dataclasses
import time import time
from fractions import Fraction as F import fractions
from pathlib import Path import pathlib
from generator import harmonic_time, vocabulary import generator.harmonic_time
from generator.solo import types, profiles, harmonic, placement, learned, decoder, groups import generator.vocabulary
import generator.solo.types
import generator.solo.profiles
import generator.solo.harmonic
import generator.solo.placement
import generator.solo.learned
import generator.solo.decoder
import generator.solo.groups
def compose(analysis, measures, part, end, *, learned_model=None, initial=None, expressions_enabled=True, start=fractions.Fraction()):
def compose(analysis,measures,part,end,*,learned_model=None,initial=None,expressions_enabled=True,start=F()):
began = time.monotonic() began = time.monotonic()
spans=tuple(dataclasses.replace(s,start=s.start+start,end=s.end+start) for s in harmonic_time.read({'measures':measures},initial=initial)) spans = tuple(dataclasses.replace(s, start=s.start + start, end=s.end + start) for s in generator.harmonic_time.read({'measures': measures}, initial=initial))
profile=profiles.for_part(part,analysis);harmony=harmonic.Harmony(spans,profile) profile = generator.solo.profiles.for_part(part, analysis)
model=learned_model or learned.Model.load(Path(__file__).resolve().parents[2]/'data/models/solo.json') harmony = generator.solo.harmonic.Harmony(spans, profile)
settings=vocabulary.role(part+'_solo') model = learned_model or generator.solo.learned.Model.load(pathlib.Path(__file__).resolve().parents[2] / 'data/models/solo.json')
builder=decoder.Decoder(analysis,harmony,model,part,start,end,expressions_enabled=expressions_enabled, settings = generator.vocabulary.role(part + '_solo')
attempts=settings.get('attempts',4),seed=settings.get('choice_seed','')) builder = generator.solo.decoder.Decoder(analysis, harmony, model, part, start, end, expressions_enabled=expressions_enabled, attempts=settings.get('attempts', 4), seed=settings.get('choice_seed', ''))
selected=builder.compose();decoded=time.monotonic() selected = builder.compose()
placed=placement.place(selected.notes,analysis.tempo,profile=profile) decoded = time.monotonic()
if placed is None:raise decoder.SearchExhausted('learned solo has no complete legal guitar path') placed = generator.solo.placement.place(selected.notes, analysis.tempo, profile=profile)
if placed is None:
raise generator.solo.decoder.SearchExhausted('learned solo has no complete legal guitar path')
# Phrase labels describe heard rests/arrivals. They do not impose a new # Phrase labels describe heard rests/arrivals. They do not impose a new
# endpoint or reset the learned history at barlines or arbitrary windows. # endpoint or reset the learned history at barlines or arbitrary windows.
notes=placed[1];ends=[n.end for i,n in enumerate(notes[:-1]) if notes[i+1].start-n.end>=1] notes = placed[1]
boundaries=[start,*[t for t in ends if start<t<end],end];phrases=[] ends = [n.end for i, n in enumerate(notes[:-1]) if notes[i + 1].start - n.end >= 1]
boundaries = [start, *[t for t in ends if start < t < end], end]
phrases = []
home = tuple(n for n in notes if n.start < (ends[0] if ends else min(end, start + 8))) home = tuple(n for n in notes if n.start < (ends[0] if ends else min(end, start + 8)))
for i, (a, b) in enumerate(zip(boundaries, boundaries[1:])): for i, (a, b) in enumerate(zip(boundaries, boundaries[1:])):
ns = tuple(n for n in notes if a <= n.start < b) ns = tuple(n for n in notes if a <= n.start < b)
cs = tuple(c for c in selected.contracts if a <= c.target.start < b) cs = tuple(c for c in selected.contracts if a <= c.target.start < b)
ending = 'handoff' if b == end else 'settle' ending = 'handoff' if b == end else 'settle'
phrases.append(types.Phrase(f'learned_{i+1}',a,b,ns,'learned_sequence','learned',ending, phrases.append(generator.solo.types.Phrase(f'learned_{i + 1}', a, b, ns, 'learned_sequence', 'learned', ending,
expressions=cs,groups=groups.describe(tuple((n.start,n.end) for n in ns)), expressions=cs, groups=generator.solo.groups.describe(tuple((n.start, n.end) for n in ns)),
theme='introduced' if i == 0 else 'conditional_history', identity=home if i == 0 else ())) theme='introduced' if i == 0 else 'conditional_history', identity=home if i == 0 else ()))
result=types.Solo(part,spans,tuple(phrases),analysis.tempo,end, result = generator.solo.types.Solo(part, spans, tuple(phrases), analysis.tempo, end,
{'construction':'learned_joint_events','version':learned.VERSION,'decode_seconds':decoded-began, {'construction': 'learned_joint_events', 'version': generator.solo.learned.VERSION, 'decode_seconds': decoded - began,
'placement_seconds': time.monotonic() - decoded, 'selection_total': selected.cost, 'placement_seconds': time.monotonic() - decoded, 'selection_total': selected.cost,
'objective': 'seeded sampling from validated learned joint-event likelihoods; score is diagnostic, not a complete-solo ranking', 'objective': 'seeded sampling from validated learned joint-event likelihoods; score is diagnostic, not a complete-solo ranking',
'budget': {'attempts': builder.attempts}, 'budget': {'attempts': builder.attempts},
'counts': dict(builder.counts), 'frontiers': builder.trace, 'completed_candidates': [ 'counts': dict(builder.counts), 'frontiers': builder.trace, 'completed_candidates': [
{'rank': i + 1, 'cost': s.cost, 'events': [(str(a), str(b), p) for a, b, p in s.events]} for i, s in enumerate(builder.completed)], {'rank': i + 1, 'cost': s.cost, 'events': [(str(a), str(b), p) for a, b, p in s.events]} for i, s in enumerate(builder.completed)],
'expressions_enabled': expressions_enabled}, profile, start) 'expressions_enabled': expressions_enabled}, profile, start)
validate(result);return result validate(result)
return result
def validate(solo): def validate(solo):
previous = None previous = None
for n in solo.notes(): for n in solo.notes():
if n.start<solo.start or n.end>solo.end or n.end<=n.start or previous and previous.end>n.start:raise ValueError('overlapping/out-of-bounds sounding event') if n.start < solo.start or n.end > solo.end or n.end <= n.start or previous and previous.end > n.start:
if n.position not in placement.positions(n.pitch.number(),solo.profile):raise ValueError('invalid guitar placement') raise ValueError('overlapping/out-of-bounds sounding event')
if n.position not in generator.solo.placement.positions(n.pitch.number(), solo.profile):
raise ValueError('invalid guitar placement')
previous = n previous = n
checked=harmonic.Harmony(solo.harmony).evaluate(solo.notes(),until=solo.end,complete=True, checked = generator.solo.harmonic.Harmony(solo.harmony).evaluate(solo.notes(), until=solo.end, complete=True,
expressions=tuple(c for p in solo.phrases for c in p.expressions)) expressions=tuple(c for p in solo.phrases for c in p.expressions))
if not checked.valid:raise ValueError(checked.error) if not checked.valid:
raise ValueError(checked.error)
cursor = solo.start cursor = solo.start
for p in solo.phrases: for p in solo.phrases:
if p.start!=cursor or p.end<=p.start:raise ValueError('phrase coverage gap or overlap') if p.start != cursor or p.end <= p.start:
raise ValueError('phrase coverage gap or overlap')
cursor = p.end cursor = p.end
if cursor!=solo.end:raise ValueError('incomplete solo duration') if cursor != solo.end:
raise ValueError('incomplete solo duration')
+203 -136
View File
@@ -11,13 +11,17 @@ import dataclasses
import hashlib import hashlib
import random import random
import math import math
from fractions import Fraction as F import fractions
from generator import model as music, vocabulary import generator.model
from generator.solo import expressions, groups, learned, types import generator.vocabulary
import generator.solo.expressions
import generator.solo.groups
class SearchExhausted(ValueError):pass import generator.solo.learned
import generator.solo.types
import generator.harmony
class SearchExhausted(ValueError):
pass
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class State: class State:
@@ -28,205 +32,247 @@ class State:
previous: int | None = None previous: int | None = None
cost: float = 0. cost: float = 0.
def event_key(events): return tuple((a, b, -1 if p is None else p) for a, b, p in events) def event_key(events): return tuple((a, b, -1 if p is None else p) for a, b, p in events)
class Decoder: class Decoder:
def __init__(self, analysis, harmony, model, role, start, end, *, expressions_enabled=True, attempts=4, seed=''): def __init__(self, analysis, harmony, model, role, start, end, *, expressions_enabled=True, attempts=4, seed=''):
if type(attempts) is not int or not 1<=attempts<=16:raise ValueError('attempts must be between 1 and 16') if type(attempts) is not int or not 1 <= attempts <= 16:
raise ValueError('attempts must be between 1 and 16')
self.analysis, self.harmony, self.model, self.role = analysis, harmony, model, role self.analysis, self.harmony, self.model, self.role = analysis, harmony, model, role
self.start, self.end = start, end self.start, self.end = start, end
self.attempts = attempts self.attempts = attempts
source = tuple((str(n.start), str(n.end), n.pitch.number()) for n in analysis.melody) source = tuple((str(n.start), str(n.end), n.pitch.number()) for n in analysis.melody)
self.seed=seed or hashlib.sha256(repr((source,role,learned.VERSION)).encode()).hexdigest() self.seed = seed or hashlib.sha256(repr((source, role, generator.solo.learned.VERSION)).encode()).hexdigest()
self.expressions_enabled = expressions_enabled self.expressions_enabled = expressions_enabled
self.low, self.high = harmony.profile.register self.low, self.high = harmony.profile.register
limits=vocabulary.data('roles')['difficulty'] limits = generator.vocabulary.data('roles')['difficulty']
self.durations = tuple(d for d in model.durations if 0 < d <= 4 and d.denominator in (1, 2, 4) and self.durations = tuple(d for d in model.durations if 0 < d <= 4 and d.denominator in (1, 2, 4) and
(d.denominator != 4 or analysis.tempo <= limits['sixteenth_max_bpm'])) (d.denominator != 4 or analysis.tempo <= limits['sixteenth_max_bpm']))
self.allow_triplets = analysis.tempo <= limits['triplet_max_bpm'] self.allow_triplets = analysis.tempo <= limits['triplet_max_bpm']
self._actions={};self._context={};self._pitches={};self._colors={};self._permissions={};self.trace=[];self.counts=collections.Counter() self._actions = {}
self._context = {}
self._pitches = {}
self._colors = {}
self._permissions = {}
self.trace = []
self.counts = collections.Counter()
def context(self, time): def context(self, time):
if time not in self._context: if time not in self._context:
harmonic,root,future=learned.chord_context(self.harmony,time) harmonic, root, future = generator.solo.learned.chord_context(self.harmony, time)
self._context[time]=(harmonic,root,learned.phase_at(self.analysis,time),learned.source_feature(self.analysis,time,root),future) self._context[time] = (harmonic, root, generator.solo.learned.phase_at(self.analysis, time), generator.solo.learned.source_feature(self.analysis, time, root), future)
return self._context[time] return self._context[time]
def pitch(self, number, time): def pitch(self, number, time):
span = self.harmony.at(time) span = self.harmony.at(time)
identity = (number, span.start if span else None) identity = (number, span.start if span else None)
if identity not in self._pitches:self._pitches[identity]=self._spell(number,time) if identity not in self._pitches:
self._pitches[identity] = self._spell(number, time)
return self._pitches[identity] return self._pitches[identity]
def _spell(self, number, time): def _spell(self, number, time):
span = self.harmony.at(time) span = self.harmony.at(time)
if span and span.chord != 'N.C.': if span and span.chord != 'N.C.':
chord=expressions.parsed_chord(span.chord) chord = generator.solo.expressions.parsed_chord(span.chord)
from generator import harmony as chords for degree, alter in generator.harmony.degrees(chord).items():
for degree,alter in chords.degrees(chord).items(): p = generator.solo.expressions.degree_pitch(chord, degree, alter, number)
p=expressions.degree_pitch(chord,degree,alter,number) if p.number() == number:
if p.number()==number:return p return p
for letter,alter in zip(music.LETTERS,span.key['alterations']): for letter, alter in zip(generator.model.LETTERS, span.key['alterations']):
accidental=music.fraction(alter) accidental = generator.model.fraction(alter)
if (music.NATURAL[letter]+accidental)%12==number%12: if (generator.model.NATURAL[letter] + accidental) % 12 == number % 12:
return types.Pitch(letter,accidental,int((number-music.NATURAL[letter]-accidental)//12)-1) return generator.solo.types.Pitch(letter, accidental, int((number - generator.model.NATURAL[letter] - accidental) // 12) - 1)
names = (('C', 0), ('D', -1), ('D', 0), ('E', -1), ('E', 0), ('F', 0), ('F', 1), ('G', 0), ('A', -1), ('A', 0), ('B', -1), ('B', 0)) names = (('C', 0), ('D', -1), ('D', 0), ('E', -1), ('E', 0), ('F', 0), ('F', 1), ('G', 0), ('A', -1), ('A', 0), ('B', -1), ('B', 0))
letter, alter = names[number % 12] letter, alter = names[number % 12]
return types.Pitch(letter.lower(),F(alter),number//12-1) return generator.solo.types.Pitch(letter.lower(), fractions.Fraction(alter), number // 12 - 1)
def note(self, a, b, p, role='learned'): def note(self, a, b, p, role='learned'):
pitch=p if isinstance(p,types.Pitch) else self.pitch(p,a) pitch = p if isinstance(p, generator.solo.types.Pitch) else self.pitch(p, a)
return types.Note(a,b,pitch,role=role,event_id=f'{a}:{b}:{pitch.number()}') return generator.solo.types.Note(a, b, pitch, role=role, event_id=f'{a}:{b}:{pitch.number()}')
def color_bases(self, n): def color_bases(self, n):
key = (n.start, n.end, n.pitch.number()) key = (n.start, n.end, n.pitch.number())
if key in self._colors:return self._colors[key] if key in self._colors:
return self._colors[key]
bases = [] bases = []
regions = [(None, n)] regions = [(None, n)]
regions.extend((s.start, dataclasses.replace(n, start=s.start)) for s in self.harmony.spans regions.extend((s.start, dataclasses.replace(n, start=s.start)) for s in self.harmony.spans
if n.start<s.start<n.end and s.start-n.start<=F(vocabulary.data('harmony')['anticipation_max'])) if n.start < s.start < n.end and s.start - n.start <= fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']))
for boundary, arrived in regions: for boundary, arrived in regions:
exposed = [s for s, _, _ in self.harmony.segments(arrived) if n.pitch.number() % 12 not in s.roles.basic | s.roles.specified] exposed = [s for s, _, _ in self.harmony.segments(arrived) if n.pitch.number() % 12 not in s.roles.basic | s.roles.specified]
if not exposed:continue if not exposed:
for rule in vocabulary.data('harmony')['colors']: continue
if arrived.end-arrived.start>F(rule['max_beats']):continue for rule in generator.vocabulary.data('harmony')['colors']:
if any(s.chord=='N.C.' or not expressions.eligible_color(rule,s,self.harmony) or if arrived.end - arrived.start > fractions.Fraction(rule['max_beats']):
expressions.degree_pitch(expressions.parsed_chord(s.chord),rule['degree'],rule['alter'],n.pitch.number()).number()!=n.pitch.number() continue
for s in exposed):continue if any(s.chord == 'N.C.' or not generator.solo.expressions.eligible_color(rule, s, self.harmony) or
generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(s.chord), rule['degree'], rule['alter'], n.pitch.number()).number() != n.pitch.number()
for s in exposed):
continue
bases.append((rule, boundary)) bases.append((rule, boundary))
self._colors[key] = tuple(bases) self._colors[key] = tuple(bases)
return self._colors[key] return self._colors[key]
def color_contract(self, n, target, rule, boundary): def color_contract(self, n, target, rule, boundary):
span = self.harmony.at(boundary if boundary is not None else n.start) span = self.harmony.at(boundary if boundary is not None else n.start)
if not span:return None if not span:
return None
kind = 'anticipated_color' if boundary is not None else 'color' kind = 'anticipated_color' if boundary is not None else 'color'
return types.ExpressionContract(n.event_id+':'+rule['id']+':'+str(boundary),kind,rule['id'],(n,),target,span.chord,boundary) return generator.solo.types.ExpressionContract(n.event_id + ':' + rule['id'] + ':' + str(boundary), kind, rule['id'], (n,), target, span.chord, boundary)
def permission(self, n): def permission(self, n):
key = (n.start, n.end, n.pitch.number()) key = (n.start, n.end, n.pitch.number())
if key not in self._permissions:self._permissions[key]=self._permission(n) if key not in self._permissions:
self._permissions[key] = self._permission(n)
return self._permissions[key] return self._permissions[key]
def _permission(self, n): def _permission(self, n):
"""Find justified color/anticipation support, never authorize by label.""" """Find justified color/anticipation support, never authorize by label."""
# Source spans are contiguous. These are precisely the ordinary-event # Source spans are contiguous. These are precisely the ordinary-event
# branches of Harmony.evaluate, without rebuilding an empty contract map. # branches of Harmony.evaluate, without rebuilding an empty contract map.
if self.harmony.at(n.start) and n.end <= self.harmony.spans[-1].end: if self.harmony.at(n.start) and n.end <= self.harmony.spans[-1].end:
if n.pitch.number()%12 in self.harmony.members(n.start,n.end):return () if n.pitch.number() % 12 in self.harmony.members(n.start, n.end):
if n.end-n.start<2 and n.pitch.number()%12 in self.harmony.permitted(n.start,n.end):return () return ()
if not self.expressions_enabled:return None if n.end - n.start < 2 and n.pitch.number() % 12 in self.harmony.permitted(n.start, n.end):
span=self.harmony.at(n.start) return ()
if not span or span.chord=='N.C.':return None if not self.expressions_enabled:
for rule,boundary in self.color_bases(n): return None
if rule.get('resolve'):continue span = self.harmony.at(n.start)
c=self.color_contract(n,n,rule,boundary) if not span or span.chord == 'N.C.':
if self.harmony.evaluate((n,),complete=True,expressions=(c,),infer=False).valid:return (c,) return None
for arriving in self.harmony.spans: for rule, boundary in self.color_bases(n):
if n.start<arriving.start<n.end and arriving.start-n.start<=F(vocabulary.data('harmony')['anticipation_max']): if rule.get('resolve'):
c=types.ExpressionContract(n.event_id+':anticipation','anticipation','early_chord_tone',(n,),n,arriving.chord,arriving.start) continue
if self.harmony.evaluate((n,),complete=True,expressions=(c,),infer=False).valid:return (c,) c = self.color_contract(n, n, rule, boundary)
if self.harmony.evaluate((n,), complete=True, expressions=(c,), infer=False).valid:
return tuple([c])
for arriving in self.harmony.spans:
if n.start < arriving.start < n.end and arriving.start - n.start <= fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']):
c = generator.solo.types.ExpressionContract(n.event_id + ':anticipation', 'anticipation', 'early_chord_tone', (n,), n, arriving.chord, arriving.start)
if self.harmony.evaluate((n,), complete=True, expressions=(c,), infer=False).valid:
return tuple([c])
return None return None
def tails(self, n, target): def tails(self, n, target):
"""Finite resolution timing: direct, real space, or one valid intervening note.""" """Finite resolution timing: direct, real space, or one valid intervening note."""
gap = target.start - n.end gap = target.start - n.end
if gap<0:return if gap < 0:
return
if gap == 0: if gap == 0:
yield ((n.start, n.end, n.pitch.number()), (target.start, target.end, target.pitch.number())), (n, target) yield ((n.start, n.end, n.pitch.number()), (target.start, target.end, target.pitch.number())), (n, target)
return return
yield ((n.start, n.end, n.pitch.number()), (n.end, target.start, None), (target.start, target.end, target.pitch.number())), (n, target) yield ((n.start, n.end, n.pitch.number()), (n.end, target.start, None), (target.start, target.end, target.pitch.number())), (n, target)
windows = {(n.end, target.start)} if gap <= 1 else set() windows = {(n.end, target.start)} if gap <= 1 else set()
if gap>=1:windows|={(n.end,n.end+F(1,2)),(target.start-F(1,2),target.start)} if gap >= 1:
windows |= {(n.end, n.end + fractions.Fraction(1, 2)), (target.start - fractions.Fraction(1, 2), target.start)}
for a, b in sorted(windows): for a, b in sorted(windows):
for number in range(max(self.low, n.pitch.number() - 3), min(self.high, n.pitch.number() + 3) + 1): for number in range(max(self.low, n.pitch.number() - 3), min(self.high, n.pitch.number() + 3) + 1):
mid = self.note(a, b, number) mid = self.note(a, b, number)
if number in (n.pitch.number(),target.pitch.number()) or not self.harmony.ordinary(mid):continue if number in (n.pitch.number(), target.pitch.number()) or not self.harmony.ordinary(mid):
continue
events = [(n.start, n.end, n.pitch.number())] events = [(n.start, n.end, n.pitch.number())]
if a>n.end:events.append((n.end,a,None)) if a > n.end:
events.append((n.end, a, None))
events.append((a, b, number)) events.append((a, b, number))
if b<target.start:events.append((b,target.start,None)) if b < target.start:
events.append((b, target.start, None))
events.append((target.start, target.end, target.pitch.number())) events.append((target.start, target.end, target.pitch.number()))
yield tuple(events), (n, mid, target) yield tuple(events), (n, mid, target)
def resolutions(self, n): def resolutions(self, n):
"""Joint color/delayed-approach actions; the destination is never optional.""" """Joint color/delayed-approach actions; the destination is never optional."""
requests = [] requests = []
for rule, boundary in self.color_bases(n): for rule, boundary in self.color_bases(n):
if rule.get('resolve'): if rule.get('resolve'):
for gap in (F(),F(1,2),F(1)): for gap in (fractions.Fraction(), fractions.Fraction(1, 2), fractions.Fraction(1)):
if gap<=F(vocabulary.data('harmony')['resolution_gap']):requests.append(('color',rule,boundary,n.end+gap,(-2,-1,1,2))) if gap <= fractions.Fraction(generator.vocabulary.data('harmony')['resolution_gap']):
delay=vocabulary.data('lead_ins')['delayed_approach'] requests.append(('color', rule, boundary, n.end + gap, (-2, -1, 1, 2)))
if n.end-n.start<=F(delay['max_note']) and n.pitch.number()%12 not in self.harmony.permitted(n.start,n.end): delay = generator.vocabulary.data('lead_ins')['delayed_approach']
for arrival in map(F,delay['arrivals']): if n.end - n.start <= fractions.Fraction(delay['max_note']) and n.pitch.number() % 12 not in self.harmony.permitted(n.start, n.end):
if n.end<=n.start+arrival<=n.start+F(delay['deadline']):requests.append(('delayed',delay,None,n.start+arrival,delay['steps'])) for arrival in map(fractions.Fraction, delay['arrivals']):
if n.end <= n.start + arrival <= n.start + fractions.Fraction(delay['deadline']):
requests.append(('delayed', delay, None, n.start + arrival, delay['steps']))
for kind, rule, boundary, arrival, steps in requests: for kind, rule, boundary, arrival, steps in requests:
for delta in steps: for delta in steps:
number = n.pitch.number() + delta number = n.pitch.number() + delta
if not self.low<=number<=self.high:continue if not self.low <= number <= self.high:
for hold in (F(1,2),F(1),F(2)): continue
if arrival+hold>self.end or number%12 not in self.harmony.members(arrival,arrival+hold):continue for hold in (fractions.Fraction(1, 2), fractions.Fraction(1), fractions.Fraction(2)):
if arrival + hold > self.end or number % 12 not in self.harmony.members(arrival, arrival + hold):
continue
target = self.note(arrival, arrival + hold, number, 'destination') target = self.note(arrival, arrival + hold, number, 'destination')
c=self.color_contract(n,target,rule,boundary) if kind=='color' else types.ExpressionContract(n.event_id+':delayed','delayed_approach',rule['id'],(n,),target,self.harmony.at(arrival).chord) c = self.color_contract(n, target, rule, boundary) if kind == 'color' else generator.solo.types.ExpressionContract(n.event_id + ':delayed', 'delayed_approach', rule['id'], (n,), target, self.harmony.at(arrival).chord)
# Color and delayed-approach contracts permit this intervening # Color and delayed-approach contracts permit this intervening
# window. Validate the invariant endpoints once; tails() checks # window. Validate the invariant endpoints once; tails() checks
# every inserted note's ordinary full-duration permission itself. # every inserted note's ordinary full-duration permission itself.
if not self.harmony.evaluate((n,target),complete=True,expressions=(c,),infer=False).valid:continue if not self.harmony.evaluate((n, target), complete=True, expressions=(c,), infer=False).valid:
continue
for events, ns in self.tails(n, target): for events, ns in self.tails(n, target):
if not groups.valid(tuple((a,b) for a,b,_ in events)):continue if not generator.solo.groups.valid(tuple((a, b) for a, b, _ in events)):
continue
yield events, ns, (c,) yield events, ns, (c,)
def actions(self, time): def actions(self, time):
if time in self._actions:return self._actions[time] if time in self._actions:
return self._actions[time]
result = [] result = []
def add(events, notes, contracts=()): result.append((events, notes, contracts)) def add(events, notes, contracts=()): result.append((events, notes, contracts))
for duration in self.durations: for duration in self.durations:
end = time + duration end = time + duration
if end>self.end:continue if end > self.end:
continue
add(((time, end, None),), ()) add(((time, end, None),), ())
for number in range(self.low, self.high + 1): for number in range(self.low, self.high + 1):
n=self.note(time,end,number);support=self.permission(n) n = self.note(time, end, number)
if support is not None:add(((time,end,number),),(n,),support) support = self.permission(n)
if support is not None:
add(((time, end, number),), (n,), support)
if self.expressions_enabled and not self.harmony.ordinary(n): if self.expressions_enabled and not self.harmony.ordinary(n):
for events,ns,contracts in self.resolutions(n):add(events,ns,contracts) for events, ns, contracts in self.resolutions(n):
rule=vocabulary.data('lead_ins')['passing'] add(events, ns, contracts)
if duration<=F(rule['max_note']): rule = generator.vocabulary.data('lead_ins')['passing']
if duration <= fractions.Fraction(rule['max_note']):
for step in rule['steps']: for step in rule['steps']:
p = number + step p = number + step
if not self.low<=p<=self.high:continue if not self.low <= p <= self.high:
for hold in (F(1,2),F(1),F(2)): continue
if end+hold>self.end:continue for hold in (fractions.Fraction(1, 2), fractions.Fraction(1), fractions.Fraction(2)):
target=self.note(end,end+hold,p);extra=self.permission(target) if end + hold > self.end:
if extra is None or any(c.kind not in ('color','anticipated_color') for c in extra):continue continue
c=types.ExpressionContract(n.event_id+':passing','passing',rule['id'],(n,),target,self.harmony.at(end).chord) target = self.note(end, end + hold, p)
extra = self.permission(target)
if extra is None or any(c.kind not in ('color', 'anticipated_color') for c in extra):
continue
c = generator.solo.types.ExpressionContract(n.event_id + ':passing', 'passing', rule['id'], (n,), target, self.harmony.at(end).chord)
add(((time, end, number), (end, end + hold, p)), (n, target), (c, *extra)) add(((time, end, number), (end, end + hold, p)), (n, target), (c, *extra))
if self.expressions_enabled: if self.expressions_enabled:
limits=vocabulary.data('roles')['difficulty'] limits = generator.vocabulary.data('roles')['difficulty']
for rule in vocabulary.data('lead_ins')['patterns']: for rule in generator.vocabulary.data('lead_ins')['patterns']:
difficulty = rule.get('difficulty') difficulty = rule.get('difficulty')
if difficulty and self.analysis.tempo>limits[difficulty+'_max_bpm']:continue if difficulty and self.analysis.tempo > limits[difficulty + '_max_bpm']:
continue
# Connections require a concrete preceding note, checked at expansion. # Connections require a concrete preceding note, checked at expansion.
durations=tuple(map(F,rule['durations']));gaps=tuple(map(F,rule.get('internal_gaps',['0']*(len(durations)-1)))) durations = tuple(map(fractions.Fraction, rule['durations']))
target_time=time+sum(durations,F())+sum(gaps,F())+F(rule['gap']) gaps = tuple(map(fractions.Fraction, rule.get('internal_gaps', ['0'] * (len(durations) - 1))))
target_time = time + sum(durations, fractions.Fraction()) + sum(gaps, fractions.Fraction()) + fractions.Fraction(rule['gap'])
span = self.harmony.at(target_time) span = self.harmony.at(target_time)
if not span or span.chord=='N.C.':continue if not span or span.chord == 'N.C.':
holds=(F(1,3),) if difficulty=='triplet' else (F(1,2),F(1),F(2)) continue
holds = (fractions.Fraction(1, 3),) if difficulty == 'triplet' else (fractions.Fraction(1, 2), fractions.Fraction(1), fractions.Fraction(2))
for duration in holds: for duration in holds:
end = target_time + duration end = target_time + duration
if end>self.end:continue if end > self.end:
continue
for number in range(self.low, self.high + 1): for number in range(self.low, self.high + 1):
if number%12 not in self.harmony.members(target_time,end):continue if number % 12 not in self.harmony.members(target_time, end):
target=self.note(target_time,end,number,'destination');cursor=time;notes=[];events=[] continue
target = self.note(target_time, end, number, 'destination')
cursor = time
notes = []
events = []
for i, (delta, d) in enumerate(zip(rule['semitones'], durations)): for i, (delta, d) in enumerate(zip(rule['semitones'], durations)):
n=self.note(cursor,cursor+d,expressions.chromatic(target.pitch,delta),rule['kind']) n = self.note(cursor, cursor + d, generator.solo.expressions.chromatic(target.pitch, delta), rule['kind'])
notes.append(n);events.append((n.start,n.end,n.pitch.number()));cursor=n.end notes.append(n)
gap=gaps[i] if i<len(gaps) else F(rule['gap']) events.append((n.start, n.end, n.pitch.number()))
if gap:events.append((cursor,cursor+gap,None));cursor+=gap cursor = n.end
if any(not self.low<=n.pitch.number()<=self.high for n in notes):continue gap = gaps[i] if i < len(gaps) else fractions.Fraction(rule['gap'])
if not groups.valid(tuple((a,b) for a,b,p in (*events,(target_time,end,number)))):continue if gap:
contract=types.ExpressionContract(f'{time}:{number}:{rule["id"]}',rule['kind'],rule['id'],tuple(notes),target,span.chord) events.append((cursor, cursor + gap, None))
cursor += gap
if any(not self.low <= n.pitch.number() <= self.high for n in notes):
continue
if not generator.solo.groups.valid(tuple((a, b) for a, b, p in (*events, (target_time, end, number)))):
continue
contract = generator.solo.types.ExpressionContract(f'{time}:{number}:{rule["id"]}', rule['kind'], rule['id'], tuple(notes), target, span.chord)
ns = (*notes, target) ns = (*notes, target)
if rule['kind'] == 'connection': if rule['kind'] == 'connection':
# Validation waits for the actual incoming event. # Validation waits for the actual incoming event.
@@ -237,47 +283,60 @@ class Decoder:
if self.allow_triplets and time + 1 <= self.end: if self.allow_triplets and time + 1 <= self.end:
for number in range(self.low, self.high + 1): for number in range(self.low, self.high + 1):
for direction in (-1, 0, 1): for direction in (-1, 0, 1):
numbers=[number];cursor=time numbers = [number]
cursor = time
for _ in range(2): for _ in range(2):
nxt = next((p for p in range(numbers[-1] + direction, numbers[-1] + direction * 4, direction or 1) nxt = next((p for p in range(numbers[-1] + direction, numbers[-1] + direction * 4, direction or 1)
if self.low<=p<=self.high and p%12 in self.harmony.permitted(cursor+F(1,3),cursor+F(2,3))),None) if direction else number if self.low <= p <= self.high and p % 12 in self.harmony.permitted(cursor + fractions.Fraction(1, 3), cursor + fractions.Fraction(2, 3))), None) if direction else number
if nxt is None:break if nxt is None:
numbers.append(nxt);cursor+=F(1,3) break
if len(numbers)!=3:continue numbers.append(nxt)
ns=tuple(self.note(time+i*F(1,3),time+(i+1)*F(1,3),p) for i,p in enumerate(numbers)) cursor += fractions.Fraction(1, 3)
if self.harmony.evaluate(ns,complete=True,infer=False).valid:add(tuple((n.start,n.end,n.pitch.number()) for n in ns),ns) if len(numbers) != 3:
self._actions[time]=tuple(result);self.counts['valid_structures']+=len(result) continue
ns = tuple(self.note(time + i * fractions.Fraction(1, 3), time + (i + 1) * fractions.Fraction(1, 3), p) for i, p in enumerate(numbers))
if self.harmony.evaluate(ns, complete=True, infer=False).valid:
add(tuple((n.start, n.end, n.pitch.number()) for n in ns), ns)
self._actions[time] = tuple(result)
self.counts['valid_structures'] += len(result)
return self._actions[time] return self._actions[time]
def extend(self, state, action): def extend(self, state, action):
events, notes, contracts = action events, notes, contracts = action
# Training tokenization merges contiguous silence. Never manufacture a # Training tokenization merges contiguous silence. Never manufacture a
# rest-to-rest transition absent from that event representation. # rest-to-rest transition absent from that event representation.
if state.events and state.events[-1][2] is None and events[0][2] is None:return None if state.events and state.events[-1][2] is None and events[0][2] is None:
if notes and state.previous is not None and abs(notes[0].pitch.number()-state.previous)>12:return None return None
if notes and state.previous is not None and abs(notes[0].pitch.number() - state.previous) > 12:
return None
if any(c.kind in ('connection', 'passing') and c.preparation is None for c in contracts): if any(c.kind in ('connection', 'passing') and c.preparation is None for c in contracts):
if not state.notes:return None if not state.notes:
return None
contracts = tuple(dataclasses.replace(c, preparation=state.notes[-1]) if c.kind in ('connection', 'passing') and c.preparation is None else c for c in contracts) contracts = tuple(dataclasses.replace(c, preparation=state.notes[-1]) if c.kind in ('connection', 'passing') and c.preparation is None else c for c in contracts)
if self.harmony.ordinary(state.notes[-1]): if self.harmony.ordinary(state.notes[-1]):
_,error=expressions.validate((state.notes[-1],*notes),contracts,self.harmony) _, error = generator.solo.expressions.validate((state.notes[-1], *notes), contracts, self.harmony)
else: else:
_,error=expressions.validate((*state.notes,*notes),(*state.contracts,*contracts),self.harmony) _, error = generator.solo.expressions.validate((*state.notes, *notes), (*state.contracts, *contracts), self.harmony)
if error:return None if error:
history=state.history;previous=state.previous;cost=state.cost return None
history = state.history
previous = state.previous
cost = state.cost
for a, b, p in events: for a, b, p in events:
harmonic, root, phase, source, _ = self.context(a) harmonic, root, phase, source, _ = self.context(a)
action=learned.token(b-a,p,previous,root) action = generator.solo.learned.token(b - a, p, previous, root)
context=learned.contexts(harmonic,phase,source,history) context = generator.solo.learned.contexts(harmonic, phase, source, history)
cost += self.model.cost(action, context) cost += self.model.cost(action, context)
if p is not None: if p is not None:
if previous is None:cost+=self.model.register_cost(p) if previous is None:
cost += self.model.register_cost(p)
previous = p previous = p
history = (*history, action)[-4:] history = (*history, action)[-4:]
return State((*state.events, *events), (*state.notes, *notes), (*state.contracts, *contracts), history, previous, cost) return State((*state.events, *events), (*state.notes, *notes), (*state.contracts, *contracts), history, previous, cost)
def compose(self): def compose(self):
for attempt in range(self.attempts): for attempt in range(self.attempts):
rng=random.Random(self.seed+':'+str(attempt));state=State();time=self.start rng = random.Random(self.seed + ':' + str(attempt))
state = State()
time = self.start
trace = [] trace = []
while time < self.end: while time < self.end:
actions = self.actions(time) actions = self.actions(time)
@@ -287,21 +346,29 @@ class Decoder:
unique = {} unique = {}
for s in options: for s in options:
signature = event_key(s.events[len(state.events):]) signature = event_key(s.events[len(state.events):])
if signature not in unique or len(s.contracts)<len(unique[signature].contracts):unique[signature]=s if signature not in unique or len(s.contracts) < len(unique[signature].contracts):
unique[signature] = s
options = sorted(unique.values(), key=lambda s: event_key(s.events[len(state.events):])) options = sorted(unique.values(), key=lambda s: event_key(s.events[len(state.events):]))
self.counts['scored_continuations'] += len(options) self.counts['scored_continuations'] += len(options)
if not options:break if not options:
break
low = min(s.cost - state.cost for s in options) low = min(s.cost - state.cost for s in options)
weights = [math.exp(-(s.cost - state.cost - low)) for s in options] weights = [math.exp(-(s.cost - state.cost - low)) for s in options]
draw=rng.random()*sum(weights);selected=options[-1] draw = rng.random() * sum(weights)
selected = options[-1]
for s, w in zip(options, weights): for s, w in zip(options, weights):
draw -= w draw -= w
if draw<=0:selected=s;break if draw <= 0:
selected = s
break
trace.append({'time': str(time), 'valid_joint_actions': len(options), trace.append({'time': str(time), 'valid_joint_actions': len(options),
'selected_events': [(str(a), str(b), p) for a, b, p in selected.events[len(state.events):]], 'selected_events': [(str(a), str(b), p) for a, b, p in selected.events[len(state.events):]],
'negative_log_likelihood': selected.cost - state.cost}) 'negative_log_likelihood': selected.cost - state.cost})
state=selected;time=state.events[-1][1] state = selected
time = state.events[-1][1]
if time == self.end and state.notes: if time == self.end and state.notes:
self.trace=trace;self.completed=(state,);self.counts['attempts']=attempt+1 self.trace = trace
self.completed = (state,)
self.counts['attempts'] = attempt + 1
return state return state
raise SearchExhausted(f'{self.role}: no complete non-silent sample within {self.attempts} deterministic attempts; end={self.end}') raise SearchExhausted(f'{self.role}: no complete non-silent sample within {self.attempts} deterministic attempts; end={self.end}')
+19 -20
View File
@@ -3,11 +3,12 @@ from __future__ import annotations
import copy import copy
import json import json
from pathlib import Path import pathlib
from fractions import Fraction as F import fractions
from generator import model, musicxml_writer, score
import generator.model
import generator.musicxml_writer
import generator.score
def region(document, part): def region(document, part):
measures = document['staves'][0]['measures'] measures = document['staves'][0]['measures']
@@ -15,7 +16,6 @@ def region(document, part):
end = next((i for i in range(first + 1, len(measures)) if measures[i].get('section_label')), len(measures)) end = next((i for i in range(first + 1, len(measures)) if measures[i].get('section_label')), len(measures))
return first, end - first return first, end - first
def excerpt(document, first, count): def excerpt(document, first, count):
result = copy.deepcopy(document) result = copy.deepcopy(document)
staff = result['staves'][0] staff = result['staves'][0]
@@ -23,7 +23,7 @@ def excerpt(document, first, count):
staff['measures'] = original[first:first + count] staff['measures'] = original[first:first + count]
if not staff['measures']: if not staff['measures']:
raise ValueError('Empty solo export region') raise ValueError('Empty solo export region')
notes = [n for m in staff['measures'] for n in score.measure_notes(m)] notes = [n for m in staff['measures'] for n in generator.score.measure_notes(m)]
identities = {p['id'] for n in notes for p in n['pitches']} identities = {p['id'] for n in notes for p in n['pitches']}
events = {n['id'] for n in notes} events = {n['id'] for n in notes}
for note in notes: for note in notes:
@@ -36,16 +36,15 @@ def excerpt(document, first, count):
for note in notes: for note in notes:
if 'tuplet_ids' in note: if 'tuplet_ids' in note:
note['tuplet_ids'] = [i for i in note['tuplet_ids'] if i in tuplets] note['tuplet_ids'] = [i for i in note['tuplet_ids'] if i in tuplets]
offset = sum((model.fraction(m['duration']) for m in original[:first]), F()) offset = sum((generator.model.fraction(m['duration']) for m in original[:first]), fractions.Fraction())
end = offset + sum((model.fraction(m['duration']) for m in staff['measures']), F()) end = offset + sum((generator.model.fraction(m['duration']) for m in staff['measures']), fractions.Fraction())
for key in ('scale_spans', 'octave_shifts'): for key in ('scale_spans', 'octave_shifts'):
staff[key] = [{**s, 'start': model.rational(max(offset, model.fraction(s['start'])) - offset), staff[key] = [{**s, 'start': generator.model.rational(max(offset, generator.model.fraction(s['start'])) - offset),
'end': model.rational(min(end, model.fraction(s['end'])) - offset)} for s in staff.get(key, []) 'end': generator.model.rational(min(end, generator.model.fraction(s['end'])) - offset)} for s in staff.get(key, [])
if model.fraction(s['start']) < end and model.fraction(s['end']) > offset] if generator.model.fraction(s['start']) < end and generator.model.fraction(s['end']) > offset]
model.validate_document(result) generator.model.validate_document(result)
return result return result
def write_role(result, path): def write_role(result, path):
role = result['role'] role = result['role']
if role not in ('acoustic_solo', 'electric_solo'): if role not in ('acoustic_solo', 'electric_solo'):
@@ -54,24 +53,24 @@ def write_role(result, path):
document = result['document'] document = result['document']
first, count = region(document, part) first, count = region(document, part)
leadin = int(result.get('details', {}).get('allocation', {}).get('lead_in_measures', 0)) leadin = int(result.get('details', {}).get('allocation', {}).get('lead_in_measures', 0))
first-=leadin;count+=leadin first -= leadin
count += leadin
selected = excerpt(document, first, count) selected = excerpt(document, first, count)
numbers = [m['number'] for m in selected['staves'][0]['measures']] numbers = [m['number'] for m in selected['staves'][0]['measures']]
for i, measure in enumerate(selected['staves'][0]['measures'], 1): for i, measure in enumerate(selected['staves'][0]['measures'], 1):
measure['number'] = str(i) measure['number'] = str(i)
path = Path(path) path = pathlib.Path(path)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
musicxml_writer.write_musicxml(path, selected, tablature=True) generator.musicxml_writer.write_musicxml(path, selected, tablature=True)
return {'role': role, 'file': path.name, 'form_start_measure': 1 + leadin, return {'role': role, 'file': path.name, 'form_start_measure': 1 + leadin,
'measure_count': count, 'arrangement_measures': numbers, 'measure_count': count, 'arrangement_measures': numbers,
'arrangement_start_beat': str(sum((model.fraction(m['duration']) for m in document['staves'][0]['measures'][:first]), F())), 'arrangement_start_beat': str(sum((generator.model.fraction(m['duration']) for m in document['staves'][0]['measures'][:first]), fractions.Fraction())),
'pitch_convention': 'guitar-written; explicit octave transposition -1', 'pitch_convention': 'guitar-written; explicit octave transposition -1',
'trace': result['trace'], 'phrase_report': result.get('details', {})} 'trace': result['trace'], 'phrase_report': result.get('details', {})}
def export_tree(assets, output=None): def export_tree(assets, output=None):
assets = Path(assets) assets = pathlib.Path(assets)
output = Path(output) if output else assets / 'solo_xml' output = pathlib.Path(output) if output else assets / 'solo_xml'
output.mkdir(parents=True, exist_ok=True) output.mkdir(parents=True, exist_ok=True)
entries = {} entries = {}
for path in sorted((assets / 'analysis').glob('*.roles')): for path in sorted((assets / 'analysis').glob('*.roles')):
+168 -102
View File
@@ -2,204 +2,266 @@
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import functools import functools
from fractions import Fraction as F import fractions
from generator import harmony as chord_model, model, pitch as spelling, vocabulary import generator.harmony
from generator.solo import types import generator.model
import generator.pitch
import generator.vocabulary
import generator.solo.types
@functools.lru_cache(maxsize=512) @functools.lru_cache(maxsize=512)
def parsed_chord(symbol): def parsed_chord(symbol):
"""Private read-only chord analysis shared by pure expression operations.""" """Private read-only chord analysis shared by pure expression operations."""
return chord_model.parse_symbol(symbol) return generator.harmony.parse_symbol(symbol)
@functools.lru_cache(maxsize=512) @functools.lru_cache(maxsize=512)
def degree_spelling(letter, accidental, degree, alter): def degree_spelling(letter, accidental, degree, alter):
root={'letter':letter,'alter':model.rational(accidental),'octave':4} root = {'letter': letter, 'alter': generator.model.rational(accidental), 'octave': 4}
p=spelling.shift_pitch(root,degree-1,chord_model.degree_semitones(degree,alter)) p = generator.pitch.shift_pitch(root, degree - 1, generator.harmony.degree_semitones(degree, alter))
return types.Pitch(p['letter'],model.alteration(p['alter']),p['octave']) return generator.solo.types.Pitch(p['letter'], generator.model.alteration(p['alter']), p['octave'])
def quality(chord): def quality(chord):
degrees=chord_model.degrees(chord) degrees = generator.harmony.degrees(chord)
if degrees.get(3)==0 and degrees.get(7)==-1:return 'dominant' if degrees.get(3) == 0 and degrees.get(7) == -1:
return 'dominant'
if degrees.get(3) == -1: if degrees.get(3) == -1:
if degrees.get(5)==-1:return 'half_diminished' if degrees.get(7)==-1 else 'diminished' if degrees.get(5) == -1:
return 'half_diminished' if degrees.get(7) == -1 else 'diminished'
return 'minor' return 'minor'
if chord['kind'] in ('suspended-second','suspended-fourth'):return chord['kind'] if chord['kind'] in ('suspended-second', 'suspended-fourth'):
if degrees.get(3)==0 and degrees.get(5)==0:return 'major' return chord['kind']
if degrees.get(3) == 0 and degrees.get(5) == 0:
return 'major'
return 'other' return 'other'
def degree_pitch(chord, degree, alter, near): def degree_pitch(chord, degree, alter, near):
value=degree_spelling(chord['root']['letter'],model.alteration(chord['root']['alter']),degree,alter) value = degree_spelling(chord['root']['letter'], generator.model.alteration(chord['root']['alter']), degree, alter)
return dataclasses.replace(value, octave=value.octave + round((near - value.number()) / 12)) return dataclasses.replace(value, octave=value.octave + round((near - value.number()) / 12))
@functools.lru_cache(maxsize=1024) @functools.lru_cache(maxsize=1024)
def chromatic(target, delta): def chromatic(target, delta):
# Chromatic neighbors are spelled relative to their exact destination. # Chromatic neighbors are spelled relative to their exact destination.
if delta in (-3, -2, -1, 1, 2, 3): if delta in (-3, -2, -1, 1, 2, 3):
step = (1 if delta > 0 else -1) * (2 if abs(delta) == 3 else 1) step = (1 if delta > 0 else -1) * (2 if abs(delta) == 3 else 1)
root={'letter':target.letter,'alter':model.rational(target.alter),'octave':target.octave} root = {'letter': target.letter, 'alter': generator.model.rational(target.alter), 'octave': target.octave}
p=spelling.shift_pitch(root,step,delta) p = generator.pitch.shift_pitch(root, step, delta)
return types.Pitch(p['letter'],model.alteration(p['alter']),p['octave']) return generator.solo.types.Pitch(p['letter'], generator.model.alteration(p['alter']), p['octave'])
raise ValueError('unsupported approach interval') raise ValueError('unsupported approach interval')
def eligible_color(rule, span, harmony): def eligible_color(rule, span, harmony):
# An explicit no-chord span (or missing harmony) has no optional colors. # An explicit no-chord span (or missing harmony) has no optional colors.
# In particular, looking ahead for an anticipation must not try to parse # In particular, looking ahead for an anticipation must not try to parse
# the display label N.C. as a written chord. # the display label N.C. as a written chord.
if not span or span.chord=='N.C.' or not span.chord:return False if not span or span.chord == 'N.C.' or not span.chord:
return False
key = (tuple(rule['qualities']), rule['degree'], rule['alter'], rule.get('function'), span.start, span.end, span.chord) key = (tuple(rule['qualities']), rule['degree'], rule['alter'], rule.get('function'), span.start, span.end, span.chord)
cache = harmony.color_eligibility cache = harmony.color_eligibility
if key not in cache:cache[key]=_eligible_color(rule,span,harmony) if key not in cache:
cache[key] = _eligible_color(rule, span, harmony)
return cache[key] return cache[key]
def _eligible_color(rule, span, harmony): def _eligible_color(rule, span, harmony):
chord = parsed_chord(span.chord) chord = parsed_chord(span.chord)
degrees=chord_model.degrees(chord) degrees = generator.harmony.degrees(chord)
if quality(chord) not in rule['qualities']:return False if quality(chord) not in rule['qualities']:
degree=rule['degree']; simple=(degree-1)%7+1 return False
degree = rule['degree']
simple = (degree - 1) % 7 + 1
# Explicit alterations and omissions take precedence over optional colors. # Explicit alterations and omissions take precedence over optional colors.
if any((d['value']-1)%7+1==simple and (d['type']=='subtract' or model.alteration(d['alter'])!=rule['alter']) for d in chord.get('degrees',[])):return False if any((d['value'] - 1) % 7 + 1 == simple and (d['type'] == 'subtract' or generator.model.alteration(d['alter']) != rule['alter']) for d in chord.get('degrees', [])):
if degree in degrees and degrees[degree]!=rule['alter']:return False return False
if degree in degrees and degrees[degree] != rule['alter']:
return False
following = next((s for s in harmony.spans if s.start >= span.end and s.chord != span.chord and s.chord != 'N.C.'), None) following = next((s for s in harmony.spans if s.start >= span.end and s.chord != span.chord and s.chord != 'N.C.'), None)
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))%12 root = int(generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter'])) % 12
next_chord = parsed_chord(following.chord) if following else None next_chord = parsed_chord(following.chord) if following else None
next_root=int(model.NATURAL[next_chord['root']['letter']]+model.alteration(next_chord['root']['alter']))%12 if next_chord else None next_root = int(generator.model.NATURAL[next_chord['root']['letter']] + generator.model.alteration(next_chord['root']['alter'])) % 12 if next_chord else None
function = rule.get('function') function = rule.get('function')
if function=='predominant_to_dominant' and not (next_chord and quality(next_chord)=='dominant' and (next_root-root)%12==5):return False if function == 'predominant_to_dominant' and not (next_chord and quality(next_chord) == 'dominant' and (next_root - root) % 12 == 5):
if function=='resolving_dominant' and (next_root is None or (root-next_root)%12!=7):return False return False
if function == 'resolving_dominant' and (next_root is None or (root - next_root) % 12 != 7):
return False
if function == 'tonic_or_predominant': if function == 'tonic_or_predominant':
tonic = span.key.get('tonic') tonic = span.key.get('tonic')
if not tonic:return False if not tonic:
tonic_pc=int(model.NATURAL[tonic['letter']]+model.alteration(tonic['alter']))%12 return False
if root!=tonic_pc and not (next_chord and quality(next_chord)=='dominant' and (next_root-root)%12==5):return False tonic_pc = int(generator.model.NATURAL[tonic['letter']] + generator.model.alteration(tonic['alter'])) % 12
if root != tonic_pc and not (next_chord and quality(next_chord) == 'dominant' and (next_root - root) % 12 == 5):
return False
return True return True
def validate(notes, contracts, harmony): def validate(notes, contracts, harmony):
"""Return event permissions only after checking complete actual gestures.""" """Return event permissions only after checking complete actual gestures."""
actual={n.event_id:n for n in notes};approved={} actual = {n.event_id: n for n in notes}
approved = {}
def same(expected): def same(expected):
n = actual.get(expected.event_id) n = actual.get(expected.event_id)
return n and (n.start, n.end, n.pitch) == (expected.start, expected.end, expected.pitch) return n and (n.start, n.end, n.pitch) == (expected.start, expected.end, expected.pitch)
for c in contracts: for c in contracts:
if not c.events or not all(same(n) for n in (*c.events, c.target)): if not c.events or not all(same(n) for n in (*c.events, c.target)):
return {}, c.id + ': missing or changed expression event/target' return {}, c.id + ': missing or changed expression event/target'
if c.preparation and not same(c.preparation):return {},c.id+': changed preparation' if c.preparation and not same(c.preparation):
if any(n.event_id in approved for n in c.events):return {},c.id+': overlapping expression permissions' return {}, c.id + ': changed preparation'
target=c.target; span=harmony.at(c.boundary if c.boundary is not None else c.events[0].start if c.kind=='color' else target.start) if any(n.event_id in approved for n in c.events):
if not span or span.chord=='N.C.' or span.chord!=c.chord:return {},c.id+': wrong target harmony' return {}, c.id + ': overlapping expression permissions'
target = c.target
span = harmony.at(c.boundary if c.boundary is not None else c.events[0].start if c.kind == 'color' else target.start)
if not span or span.chord == 'N.C.' or span.chord != c.chord:
return {}, c.id + ': wrong target harmony'
if c.kind in ('anticipation', 'anticipated_color'): if c.kind in ('anticipation', 'anticipated_color'):
previous = next((s for s in harmony.spans if s.end == c.boundary), None) previous = next((s for s in harmony.spans if s.end == c.boundary), None)
if not previous or previous.chord.strip('()')==span.chord.strip('()'):return {},c.id+': anticipation requires a harmonic change' if not previous or previous.chord.strip('()') == span.chord.strip('()'):
return {}, c.id + ': anticipation requires a harmonic change'
if c.kind in ('approach', 'enclosure', 'connection'): if c.kind in ('approach', 'enclosure', 'connection'):
rule=next((r for r in vocabulary.data('lead_ins')['patterns'] if r['id']==c.rule and r['kind']==c.kind),None) rule = next((r for r in generator.vocabulary.data('lead_ins')['patterns'] if r['id'] == c.rule and r['kind'] == c.kind), None)
if not rule or len(c.events)!=len(rule['semitones']):return {},c.id+': unknown approach formula' if not rule or len(c.events) != len(rule['semitones']):
return {}, c.id + ': unknown approach formula'
arrival = c.boundary if c.boundary is not None else target.start arrival = c.boundary if c.boundary is not None else target.start
if c.boundary is not None and not any(a.kind=='anticipation' and a.target==target and a.boundary==c.boundary for a in contracts):return {},c.id+': future target lacks anticipation contract' if c.boundary is not None and not any(a.kind == 'anticipation' and a.target == target and a.boundary == c.boundary for a in contracts):
return {}, c.id + ': future target lacks anticipation contract'
planned_color = any(a.kind == 'color' and a.target == target and a.events == (target,) for a in contracts) planned_color = any(a.kind == 'color' and a.target == target and a.events == (target,) for a in contracts)
if not planned_color and (target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in harmony.permitted(arrival,target.end)):return {},c.id+': invalid destination hold' if not planned_color and (target.pitch.number() % 12 not in harmony.members(arrival, target.end) or target.pitch.number() % 12 not in harmony.permitted(arrival, target.end)):
gaps=tuple(map(F,rule.get('internal_gaps',['0']*(len(c.events)-1)))) return {}, c.id + ': invalid destination hold'
if len(gaps)!=len(c.events)-1 or any(g<0 for g in gaps):return {},c.id+': invalid internal approach gaps' gaps = tuple(map(fractions.Fraction, rule.get('internal_gaps', ['0'] * (len(c.events) - 1))))
gap=F(rule['gap']);cursor=target.start-gap-sum((F(d) for d in rule['durations']),F())-sum(gaps,F()) if len(gaps) != len(c.events) - 1 or any(g < 0 for g in gaps):
if target.start-cursor>F(vocabulary.data('lead_ins')['max_approach']):return {},c.id+': approach too long' return {}, c.id + ': invalid internal approach gaps'
gap = fractions.Fraction(rule['gap'])
cursor = target.start - gap - sum((fractions.Fraction(d) for d in rule['durations']), fractions.Fraction()) - sum(gaps, fractions.Fraction())
if target.start - cursor > fractions.Fraction(generator.vocabulary.data('lead_ins')['max_approach']):
return {}, c.id + ': approach too long'
for i, (n, step, duration) in enumerate(zip(c.events, rule['semitones'], rule['durations'])): for i, (n, step, duration) in enumerate(zip(c.events, rule['semitones'], rule['durations'])):
if n.start!=cursor or n.end!=cursor+F(duration) or n.pitch.number()!=target.pitch.number()+step:return {},c.id+': wrong register, interval, or timing' if n.start != cursor or n.end != cursor + fractions.Fraction(duration) or n.pitch.number() != target.pitch.number() + step:
return {}, c.id + ': wrong register, interval, or timing'
cursor = n.end + (gaps[i] if i < len(gaps) else 0) cursor = n.end + (gaps[i] if i < len(gaps) else 0)
for index in rule.get('diatonic_indices', []): for index in rule.get('diatonic_indices', []):
n = c.events[index] n = c.events[index]
if n.pitch.number()%12 not in harmony.permitted(n.start,n.end):return {},c.id+': declared diatonic neighbor is outside source-key/chord motion' if n.pitch.number() % 12 not in harmony.permitted(n.start, n.end):
if any(n.start<c.target.start and n.end>c.events[0].start and n.event_id not in {e.event_id for e in c.events} for n in notes):return {},c.id+': interrupted approach' return {}, c.id + ': declared diatonic neighbor is outside source-key/chord motion'
if c.kind=='connection' and (not c.preparation or c.preparation.end!=c.events[0].start or abs(c.preparation.pitch.number()-c.events[0].pitch.number())>2):return {},c.id+': chromatic connection lacks its incoming motion' if any(n.start < c.target.start and n.end > c.events[0].start and n.event_id not in {e.event_id for e in c.events} for n in notes):
return {}, c.id + ': interrupted approach'
if c.kind == 'connection' and (not c.preparation or c.preparation.end != c.events[0].start or abs(c.preparation.pitch.number() - c.events[0].pitch.number()) > 2):
return {}, c.id + ': chromatic connection lacks its incoming motion'
elif c.kind == 'passing': elif c.kind == 'passing':
rule=vocabulary.data('lead_ins')['passing'] rule = generator.vocabulary.data('lead_ins')['passing']
if c.rule!=rule['id'] or len(c.events)!=1 or not c.preparation:return {},c.id+': unsupported passing figure' if c.rule != rule['id'] or len(c.events) != 1 or not c.preparation:
n,=c.events;before=c.preparation return {}, c.id + ': unsupported passing figure'
left=n.pitch.number()-before.pitch.number();right=target.pitch.number()-n.pitch.number() n, = c.events
if before.end!=n.start or n.end!=target.start or n.end-n.start>F(rule['max_note']) or left!=right or left not in rule['steps']: before = c.preparation
left = n.pitch.number() - before.pitch.number()
right = target.pitch.number() - n.pitch.number()
if before.end != n.start or n.end != target.start or n.end - n.start > fractions.Fraction(rule['max_note']) or left != right or left not in rule['steps']:
return {}, c.id + ': interrupted/nonstepwise passing figure' return {}, c.id + ': interrupted/nonstepwise passing figure'
def supported(endpoint): def supported(endpoint):
return harmony.ordinary(endpoint) or any(a.kind in ('color', 'anticipated_color') and endpoint in a.events for a in contracts) return harmony.ordinary(endpoint) or any(a.kind in ('color', 'anticipated_color') and endpoint in a.events for a in contracts)
if not supported(before) or not supported(target):return {},c.id+': unsupported passing endpoints'
if not supported(before) or not supported(target):
return {}, c.id + ': unsupported passing endpoints'
elif c.kind == 'delayed_approach': elif c.kind == 'delayed_approach':
rule=vocabulary.data('lead_ins').get('delayed_approach',{}) rule = generator.vocabulary.data('lead_ins').get('delayed_approach', {})
if c.rule!=rule.get('id') or len(c.events)!=1:return {},c.id+': unknown delayed approach' if c.rule != rule.get('id') or len(c.events) != 1:
return {}, c.id + ': unknown delayed approach'
n, = c.events n, = c.events
if n.end-n.start>F(rule['max_note']) or not n.end<=target.start<=n.start+F(rule['deadline']):return {},c.id+': expired delayed approach' if n.end - n.start > fractions.Fraction(rule['max_note']) or not n.end <= target.start <= n.start + fractions.Fraction(rule['deadline']):
if target.pitch.number()-n.pitch.number() not in rule['steps']:return {},c.id+': wrong delayed target register' return {}, c.id + ': expired delayed approach'
if target.pitch.number()%12 not in harmony.members(target.start,target.end) or target.pitch.number()%12 not in harmony.permitted(target.start,target.end):return {},c.id+': invalid delayed destination' if target.pitch.number() - n.pitch.number() not in rule['steps']:
return {}, c.id + ': wrong delayed target register'
if target.pitch.number() % 12 not in harmony.members(target.start, target.end) or target.pitch.number() % 12 not in harmony.permitted(target.start, target.end):
return {}, c.id + ': invalid delayed destination'
elif c.kind == 'color': elif c.kind == 'color':
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None) rule = next((r for r in generator.vocabulary.data('harmony')['colors'] if r['id'] == c.rule), None)
if not rule:return {},c.id+': unknown color policy' if not rule:
return {}, c.id + ': unknown color policy'
colored = False colored = False
for n in c.events: for n in c.events:
if n.end-n.start>F(rule['max_beats']):return {},c.id+': excessive color duration' if n.end - n.start > fractions.Fraction(rule['max_beats']):
return {}, c.id + ': excessive color duration'
for region, a, b in harmony.segments(n): for region, a, b in harmony.segments(n):
if n.pitch.number()%12 in region.roles.basic|region.roles.specified:continue if n.pitch.number() % 12 in region.roles.basic | region.roles.specified:
if region.chord=='N.C.' or not eligible_color(rule,region,harmony):return {},c.id+': incompatible color harmony/function' continue
if region.chord == 'N.C.' or not eligible_color(rule, region, harmony):
return {}, c.id + ': incompatible color harmony/function'
p = degree_pitch(parsed_chord(region.chord), rule['degree'], rule['alter'], n.pitch.number()) p = degree_pitch(parsed_chord(region.chord), rule['degree'], rule['alter'], n.pitch.number())
# The rule retains the functional degree/spelling. Encoded # The rule retains the functional degree/spelling. Encoded
# enharmonic notation must not change sounding harmonic validity. # enharmonic notation must not change sounding harmonic validity.
if p.number()!=n.pitch.number():return {},c.id+': color degree/register mismatch' if p.number() != n.pitch.number():
return {}, c.id + ': color degree/register mismatch'
colored = True colored = True
if not colored:return {},c.id+': no interval expresses the declared color' if not colored:
return {}, c.id + ': no interval expresses the declared color'
last = c.events[-1] last = c.events[-1]
if rule.get('resolve'): if rule.get('resolve'):
if target.event_id==last.event_id or not 0<=target.start-last.end<=F(vocabulary.data('harmony')['resolution_gap']) or not 0<abs(target.pitch.number()-last.pitch.number())<=2 or target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': unresolved color' if target.event_id == last.event_id or not 0 <= target.start - last.end <= fractions.Fraction(generator.vocabulary.data('harmony')['resolution_gap']) or not 0 < abs(target.pitch.number() - last.pitch.number()) <= 2 or target.pitch.number() % 12 not in harmony.members(target.start, target.end):
return {}, c.id + ': unresolved color'
elif c.kind == 'suspension': elif c.kind == 'suspension':
rule=next((r for r in vocabulary.data('harmony').get('suspensions',[]) if r['id']==c.rule),None) rule = next((r for r in generator.vocabulary.data('harmony').get('suspensions', []) if r['id'] == c.rule), None)
if not rule or len(c.events)!=1 or c.boundary is None:return {},c.id+': unsupported suspension' if not rule or len(c.events) != 1 or c.boundary is None:
return {}, c.id + ': unsupported suspension'
held, = c.events held, = c.events
if not held.start<=c.boundary-F(rule['preparation'])<c.boundary<held.end: if not held.start <= c.boundary - fractions.Fraction(rule['preparation']) < c.boundary < held.end:
return {}, c.id + ': insufficient sounding preparation' return {}, c.id + ': insufficient sounding preparation'
if held.pitch.number()%12 not in harmony.members(held.start,c.boundary):return {},c.id+': nonchordal preparation' if held.pitch.number() % 12 not in harmony.members(held.start, c.boundary):
if held.end-c.boundary>F(rule['max_delay']) or target.start!=held.end or target.pitch.number()-held.pitch.number() not in rule['steps']: return {}, c.id + ': nonchordal preparation'
if held.end - c.boundary > fractions.Fraction(rule['max_delay']) or target.start != held.end or target.pitch.number() - held.pitch.number() not in rule['steps']:
return {}, c.id + ': missed suspension resolution' return {}, c.id + ': missed suspension resolution'
if target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': invalid suspension target' if target.pitch.number() % 12 not in harmony.members(target.start, target.end):
if held.pitch.number()%12 not in harmony.permitted(held.start,held.end):return {},c.id+': suspension outside source-key/chord motion' return {}, c.id + ': invalid suspension target'
if held.pitch.number() % 12 not in harmony.permitted(held.start, held.end):
return {}, c.id + ': suspension outside source-key/chord motion'
elif c.kind == 'anticipated_color': elif c.kind == 'anticipated_color':
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None) rule = next((r for r in generator.vocabulary.data('harmony')['colors'] if r['id'] == c.rule), None)
if not rule or len(c.events)!=1 or c.boundary!=span.start:return {},c.id+': unsupported anticipated color' if not rule or len(c.events) != 1 or c.boundary != span.start:
return {}, c.id + ': unsupported anticipated color'
n, = c.events n, = c.events
if not n.start<c.boundary<n.end or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipated color interval' if not n.start < c.boundary < n.end or c.boundary - n.start > fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']):
return {}, c.id + ': invalid anticipated color interval'
arrived = dataclasses.replace(n, start=c.boundary) arrived = dataclasses.replace(n, start=c.boundary)
target = arrived if c.target == n else c.target target = arrived if c.target == n else c.target
inner=types.ExpressionContract(c.id+':arrived','color',rule['id'],(arrived,),target,span.chord) inner = generator.solo.types.ExpressionContract(c.id + ':arrived', 'color', rule['id'], (arrived,), target, span.chord)
_, error = validate((arrived, *((target,) if target != arrived else ())), (inner,), harmony) _, error = validate((arrived, *((target,) if target != arrived else ())), (inner,), harmony)
if error:return {},error if error:
return {}, error
elif c.kind == 'anticipation': elif c.kind == 'anticipation':
if c.rule not in ('early_chord_tone', 'early_reattack') or len(c.events) != 1 or span.start != c.boundary: if c.rule not in ('early_chord_tone', 'early_reattack') or len(c.events) != 1 or span.start != c.boundary:
return {}, c.id + ': unknown anticipation or non-boundary target' return {}, c.id + ': unknown anticipation or non-boundary target'
n, = c.events n, = c.events
if c.boundary is None or not n.start<c.boundary or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipation interval' if c.boundary is None or not n.start < c.boundary or c.boundary - n.start > fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']):
if c.rule=='early_chord_tone' and (n!=target or not c.boundary<n.end):return {},c.id+': invalid held anticipation' return {}, c.id + ': invalid anticipation interval'
if c.rule=='early_reattack' and (n.end!=c.boundary or target.start!=c.boundary or target.event_id==n.event_id or target.pitch.number()!=n.pitch.number()):return {},c.id+': invalid anticipated reattack' if c.rule == 'early_chord_tone' and (n != target or not c.boundary < n.end):
if target.pitch.number()%12 not in harmony.members(c.boundary,target.end) or target.pitch.number()%12 not in harmony.permitted(c.boundary,target.end):return {},c.id+': invalid future destination' return {}, c.id + ': invalid held anticipation'
if c.rule == 'early_reattack' and (n.end != c.boundary or target.start != c.boundary or target.event_id == n.event_id or target.pitch.number() != n.pitch.number()):
return {}, c.id + ': invalid anticipated reattack'
if target.pitch.number() % 12 not in harmony.members(c.boundary, target.end) or target.pitch.number() % 12 not in harmony.permitted(c.boundary, target.end):
return {}, c.id + ': invalid future destination'
elif c.kind == 'blues': elif c.kind == 'blues':
if len(c.events)!=1:return {},c.id+': blues treatment requires one inflected event' if len(c.events) != 1:
rule=next((r for r in vocabulary.data('lead_ins')['blues'] if r['id']==c.rule),None) return {}, c.id + ': blues treatment requires one inflected event'
rule = next((r for r in generator.vocabulary.data('lead_ins')['blues'] if r['id'] == c.rule), None)
n, = c.events n, = c.events
chord = parsed_chord(span.chord) chord = parsed_chord(span.chord)
if not rule or n.end-n.start>F(1,2) or n.end!=target.start or target.pitch.number()%12 not in span.roles.basic:return {},c.id+': unresolved blues inflection' if not rule or n.end - n.start > fractions.Fraction(1, 2) or n.end != target.start or target.pitch.number() % 12 not in span.roles.basic:
return {}, c.id + ': unresolved blues inflection'
expected = degree_pitch(chord, rule['degree'], rule['alter'], n.pitch.number()) expected = degree_pitch(chord, rule['degree'], rule['alter'], n.pitch.number())
resolved = degree_pitch(chord, rule['target_degree'], 0, target.pitch.number()) resolved = degree_pitch(chord, rule['target_degree'], 0, target.pitch.number())
if n.pitch!=expected or target.pitch!=resolved or target.pitch.number()-n.pitch.number()!=1:return {},c.id+': unsupported blues destination' if n.pitch != expected or target.pitch != resolved or target.pitch.number() - n.pitch.number() != 1:
if not c.preparation or c.preparation.end!=n.start or abs(c.preparation.pitch.number()-n.pitch.number())>2:return {},c.id+': unprepared blues gesture' return {}, c.id + ': unsupported blues destination'
else:return {},c.id+': unknown expression kind' if not c.preparation or c.preparation.end != n.start or abs(c.preparation.pitch.number() - n.pitch.number()) > 2:
return {}, c.id + ': unprepared blues gesture'
else:
return {}, c.id + ': unknown expression kind'
for n in c.events: for n in c.events:
covered = n.start covered = n.start
for region, a, b in harmony.segments(n): for region, a, b in harmony.segments(n):
if a!=covered or region.chord=='N.C.':return {},c.id+': missing harmony' if a != covered or region.chord == 'N.C.':
return {}, c.id + ': missing harmony'
covered = b covered = b
if covered!=n.end:return {},c.id+': incomplete harmonic coverage' if covered != n.end:
return {}, c.id + ': incomplete harmonic coverage'
approved[n.event_id] = c approved[n.event_id] = c
return approved, '' return approved, ''
def validate_frontier(notes, contracts, harmony, until, complete): def validate_frontier(notes, contracts, harmony, until, complete):
"""Validate heard prefixes and bounded future obligations separately. """Validate heard prefixes and bounded future obligations separately.
@@ -210,19 +272,23 @@ def validate_frontier(notes,contracts,harmony,until,complete):
if complete: if complete:
approved, error = validate(notes, contracts, harmony) approved, error = validate(notes, contracts, harmony)
return approved, (), error return approved, (), error
actual={n.event_id:n for n in notes};future=dict(actual);pending=[] actual = {n.event_id: n for n in notes}
future = dict(actual)
pending = []
for c in contracts: for c in contracts:
required = tuple(dict.fromkeys((*c.events, c.target, *((c.preparation,) if c.preparation else ())))) required = tuple(dict.fromkeys((*c.events, c.target, *((c.preparation,) if c.preparation else ()))))
incomplete = False incomplete = False
for expected in required: for expected in required:
heard = actual.get(expected.event_id) heard = actual.get(expected.event_id)
if expected.start >= until: if expected.start >= until:
future[expected.event_id]=expected;incomplete=True future[expected.event_id] = expected
incomplete = True
elif heard and heard.pitch == expected.pitch and heard.start == expected.start and heard.end == min(until, expected.end): elif heard and heard.pitch == expected.pitch and heard.start == expected.start and heard.end == min(until, expected.end):
future[expected.event_id] = expected future[expected.event_id] = expected
incomplete |= heard.end < expected.end incomplete |= heard.end < expected.end
elif not heard or (heard.start, heard.end, heard.pitch) != (expected.start, expected.end, expected.pitch): elif not heard or (heard.start, heard.end, heard.pitch) != (expected.start, expected.end, expected.pitch):
return {}, (), c.id + ': missing/changed event before resolution deadline' return {}, (), c.id + ': missing/changed event before resolution deadline'
if incomplete:pending.append(c) if incomplete:
pending.append(c)
approved, error = validate(tuple(sorted(future.values(), key=lambda n: (n.start, n.event_id))), contracts, harmony) approved, error = validate(tuple(sorted(future.values(), key=lambda n: (n.start, n.event_id))), contracts, harmony)
return {key: c for key, c in approved.items() if key in actual}, tuple(pending), error return {key: c for key, c in approved.items() if key in actual}, tuple(pending), error
+34 -26
View File
@@ -7,36 +7,42 @@ not a rhythmic boundary and must not be used to clip a group.
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import functools import functools
from fractions import Fraction as F import fractions
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class RhythmGroup: class RhythmGroup:
start: F start: fractions.Fraction
end: F end: fractions.Fraction
family: str family: str
events: tuple[tuple[F, F], ...] events: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
displacement: F = F() displacement: fractions.Fraction = fractions.Fraction()
source: str = '' source: str = ''
def describe(pattern, *, origin=fractions.Fraction(), source='', imported=False):
def describe(pattern, *, origin=F(), source='', imported=False): if not pattern:
if not pattern:return () return ()
if imported: if imported:
return (RhythmGroup(origin, origin + pattern[-1][1], 'source', pattern, source=source),) return (RhythmGroup(origin, origin + pattern[-1][1], 'source', pattern, source=source),)
result=[]; i=0 result = []
i = 0
while i < len(pattern): while i < len(pattern):
a, b = pattern[i] a, b = pattern[i]
if b<=a or i and a<pattern[i-1][1]:raise ValueError('overlapping rhythm group') if b <= a or i and a < pattern[i - 1][1]:
raise ValueError('overlapping rhythm group')
# A complete ternary figure can be intentionally displaced on an eighth. # A complete ternary figure can be intentionally displaced on an eighth.
if (b - a).denominator % 3 == 0: if (b - a).denominator % 3 == 0:
if (a*2).denominator!=1:raise ValueError('unexplained tuplet entry phase') if (a * 2).denominator != 1:
start=i; cursor=a raise ValueError('unexplained tuplet entry phase')
start = i
cursor = a
while i < len(pattern): while i < len(pattern):
x, y = pattern[i] x, y = pattern[i]
if x!=cursor or (y-x).denominator not in (3,6):break if x != cursor or (y - x).denominator not in (3, 6):
cursor=y;i+=1 break
if (cursor-a).denominator==1:break cursor = y
i += 1
if (cursor - a).denominator == 1:
break
if i == start or (cursor - a).denominator != 1: if i == start or (cursor - a).denominator != 1:
raise ValueError('incomplete tuplet group') raise ValueError('incomplete tuplet group')
group = pattern[start:i] group = pattern[start:i]
@@ -45,22 +51,23 @@ def describe(pattern, *, origin=F(), source='', imported=False):
if (a * 4).denominator != 1 or (b * 4).denominator != 1: if (a * 4).denominator != 1 or (b * 4).denominator != 1:
raise ValueError('unexplained binary subdivision phase') raise ValueError('unexplained binary subdivision phase')
family = 'sixteenth' if (a * 2).denominator != 1 or (b * 2).denominator != 1 else 'binary' family = 'sixteenth' if (a * 2).denominator != 1 or (b * 2).denominator != 1 else 'binary'
result.append(RhythmGroup(origin+a,origin+b,family,((a,b),),a%1,source));i+=1 result.append(RhythmGroup(origin + a, origin + b, family, ((a, b),), a % 1, source))
i += 1
return tuple(result) return tuple(result)
@functools.lru_cache(maxsize=8192) @functools.lru_cache(maxsize=8192)
def valid(pattern): def valid(pattern):
try:describe(pattern);return True try:
except ValueError:return False describe(pattern)
return True
except ValueError:
return False
def signature(notes): def signature(notes):
"""Translation-invariant duration, inter-onset and rest vocabulary.""" """Translation-invariant duration, inter-onset and rest vocabulary."""
return tuple((n.end-n.start, notes[i+1].start-n.end if i+1<len(notes) else F()) return tuple((n.end - n.start, notes[i + 1].start - n.end if i + 1 < len(notes) else fractions.Fraction())
for i, n in enumerate(notes)) for i, n in enumerate(notes))
def descriptor(pattern, duration): def descriptor(pattern, duration):
"""Retain ordered attacks, durations and space, including a trailing breath. """Retain ordered attacks, durations and space, including a trailing breath.
@@ -69,11 +76,12 @@ def descriptor(pattern, duration):
""" """
return (duration, tuple(pattern)) return (duration, tuple(pattern))
def broaden(pattern, arrival, release): def broaden(pattern, arrival, release):
"""Reopen an editable binary tail; never slice a tuplet or inherit its phase.""" """Reopen an editable binary tail; never slice a tuplet or inherit its phase."""
if (arrival*2).denominator!=1 or (release*2).denominator!=1:return () if (arrival * 2).denominator != 1 or (release * 2).denominator != 1:
return ()
for group in describe(pattern): for group in describe(pattern):
if group.family=='triplet' and group.start<arrival<group.end:return () if group.family == 'triplet' and group.start < arrival < group.end:
return ()
result = tuple((a, min(b, arrival)) for a, b in pattern if a < arrival) + ((arrival, release),) result = tuple((a, min(b, arrival)) for a, b in pattern if a < arrival) + ((arrival, release),)
return result if valid(result) else () return result if valid(result) else ()
+70 -44
View File
@@ -6,9 +6,13 @@ permission follows the actual sounded interval and validated resolution.
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import functools import functools
from fractions import Fraction as F import fractions
from generator import harmony as chords, model import generator.harmony
from generator.solo import types import generator.model
import generator.solo.types
import generator.solo.profiles
import generator.solo.expressions
import generator.solo.recognition
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Roles: class Roles:
@@ -20,20 +24,23 @@ class Roles:
def degree_roles(kind, root, degrees): def degree_roles(kind, root, degrees):
basic, specified, labels = set(), set(), {} basic, specified, labels = set(), set(), {}
for degree, alter in degrees: for degree, alter in degrees:
pc=int(root+chords.degree_semitones(degree,alter))%12 pc = int(root + generator.harmony.degree_semitones(degree, alter)) % 12
structural = degree in (1, 3, 5, 6, 7) or degree in (2, 4) and kind in ('suspended-second', 'suspended-fourth') structural = degree in (1, 3, 5, 6, 7) or degree in (2, 4) and kind in ('suspended-second', 'suspended-fourth')
(basic if structural else specified).add(pc);labels.setdefault(pc,[]).append(degree) (basic if structural else specified).add(pc)
labels.setdefault(pc, []).append(degree)
return Roles(frozenset(basic), frozenset(specified - basic), tuple((p, tuple(v)) for p, v in sorted(labels.items()))) return Roles(frozenset(basic), frozenset(specified - basic), tuple((p, tuple(v)) for p, v in sorted(labels.items())))
def roles(chord): def roles(chord):
if not chord or chord['kind']=='none':return Roles(frozenset(),frozenset(),()) if not chord or chord['kind'] == 'none':
root=chord['root'];pc=model.NATURAL[root['letter']]+model.alteration(root['alter']) return Roles(frozenset(), frozenset(), ())
return degree_roles(chord['kind'],pc,tuple(sorted(chords.degrees(chord).items()))) root = chord['root']
pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
return degree_roles(chord['kind'], pc, tuple(sorted(generator.harmony.degrees(chord).items())))
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Span: class Span:
start:F start: fractions.Fraction
end:F end: fractions.Fraction
roles: Roles roles: Roles
chord: str chord: str
key: dict key: dict
@@ -49,13 +56,14 @@ class Result:
class Harmony: class Harmony:
def __init__(self, spans, profile=None): def __init__(self, spans, profile=None):
from generator.solo.profiles import UNRESTRICTED self.profile = profile or generator.solo.profiles.UNRESTRICTED
self.profile=profile or UNRESTRICTED self.source_spans = spans
self.source_spans=spans;self.spans=[];self.color_eligibility={} self.spans = []
self.color_eligibility = {}
for s in spans: for s in spans:
r = roles(s.chord) r = roles(s.chord)
key=frozenset((model.NATURAL[l]+int(model.fraction(a)))%12 for l,a in zip(model.LETTERS,s.key['alterations'])) key = frozenset((generator.model.NATURAL[l] + int(generator.model.fraction(a))) % 12 for l, a in zip(generator.model.LETTERS, s.key['alterations']))
self.spans.append(Span(s.start,s.end,r,chords.symbol(s.chord) if s.chord else 'N.C.',s.key,key|r.basic|r.specified)) self.spans.append(Span(s.start, s.end, r, generator.harmony.symbol(s.chord) if s.chord else 'N.C.', s.key, key | r.basic | r.specified))
self.spans = tuple(self.spans) self.spans = tuple(self.spans)
@functools.lru_cache(maxsize=2048) @functools.lru_cache(maxsize=2048)
def at(self, time): return next((s for s in self.spans if s.start <= time < s.end), None) def at(self, time): return next((s for s in self.spans if s.start <= time < s.end), None)
@@ -73,57 +81,75 @@ class Harmony:
return 'basic' if pc in span.roles.basic else 'specified_extension' if pc in span.roles.specified else 'diatonic_motion' if pc in span.pitches else 'chromatic' return 'basic' if pc in span.roles.basic else 'specified_extension' if pc in span.roles.specified else 'diatonic_motion' if pc in span.pitches else 'chromatic'
def step(self, a, b, time): return 0 < abs(a - b) <= 2 def step(self, a, b, time): return 0 < abs(a - b) <= 2
def connected_motion(self, notes, index): def connected_motion(self, notes, index):
if not 0<index<len(notes)-1:return '' if not 0 < index < len(notes) - 1:
return ''
a, n, b = notes[index - 1:index + 2] a, n, b = notes[index - 1:index + 2]
if a.end!=n.start or n.end!=b.start:return '' if a.end != n.start or n.end != b.start:
left=n.pitch.number()-a.pitch.number();right=b.pitch.number()-n.pitch.number() return ''
left = n.pitch.number() - a.pitch.number()
right = b.pitch.number() - n.pitch.number()
return 'neighbor' if 0 < abs(left) <= 2 and left == -right else 'passing' if 0 < abs(left) <= 2 and 0 < abs(right) <= 2 and left * right > 0 else '' return 'neighbor' if 0 < abs(left) <= 2 and left == -right else 'passing' if 0 < abs(left) <= 2 and 0 < abs(right) <= 2 and left * right > 0 else ''
@functools.lru_cache(maxsize=8192) @functools.lru_cache(maxsize=8192)
def _ordinary(self, start, end, number): def _ordinary(self, start, end, number):
covered = start covered = start
for s in self.spans: for s in self.spans:
a, b = max(start, s.start), min(end, s.end) a, b = max(start, s.start), min(end, s.end)
if a>=b:continue if a >= b:
if a!=covered or s.chord=='N.C.':return False continue
covered=b;pc=number%12 if a != covered or s.chord == 'N.C.':
if pc not in s.roles.basic|s.roles.specified and (pc not in s.pitches or end-start>=2):return False return False
covered = b
pc = number % 12
if pc not in s.roles.basic | s.roles.specified and (pc not in s.pitches or end - start >= 2):
return False
return covered == end and end > start return covered == end and end > start
def ordinary(self, n): return self._ordinary(n.start, n.end, n.pitch.number()) def ordinary(self, n): return self._ordinary(n.start, n.end, n.pitch.number())
@staticmethod @staticmethod
def identified(n): def identified(n):
return n if n.event_id else dataclasses.replace(n, event_id=f'event:{n.start}:{n.end}:{n.pitch.label()}') return n if n.event_id else dataclasses.replace(n, event_id=f'event:{n.start}:{n.end}:{n.pitch.label()}')
def evaluate(self, notes, *, until=None, complete=False, expressions=(), infer=True): def evaluate(self, notes, *, until=None, complete=False, expressions=(), infer=True):
from generator.solo.expressions import validate_frontier notes = tuple(self.identified(n) for n in generator.solo.types.sounding_notes(notes))
notes=tuple(self.identified(n) for n in types.sounding_notes(notes))
expressions = tuple(dataclasses.replace(c, events=tuple(self.identified(n) for n in c.events), expressions = tuple(dataclasses.replace(c, events=tuple(self.identified(n) for n in c.events),
target=self.identified(c.target), preparation=self.identified(c.preparation) if c.preparation else None) for c in expressions) target=self.identified(c.target), preparation=self.identified(c.preparation) if c.preparation else None) for c in expressions)
frontier=until if until is not None else max((n.end for n in notes),default=F()) frontier = until if until is not None else max((n.end for n in notes), default=fractions.Fraction())
permissions,pending,error=validate_frontier(notes,expressions,self,frontier,complete or until is None) permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, complete or until is None)
if error:return Result(False,0,error=error) if error:
return Result(False, 0, error=error)
if infer and not pending and (complete or until is None) and any(not self.ordinary(n) and n.event_id not in permissions for n in notes): if infer and not pending and (complete or until is None) and any(not self.ordinary(n) and n.event_id not in permissions for n in notes):
from generator.solo.recognition import recognize expressions, error = generator.solo.recognition.recognize(notes, self, expressions)
expressions,error=recognize(notes,self,expressions) if error:
if error:return Result(False,0,error=error) return Result(False, 0, error=error)
permissions,pending,error=validate_frontier(notes,expressions,self,frontier,True) permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, True)
if error:return Result(False,0,error=error) if error:
exposed=sounded=F() return Result(False, 0, error=error)
exposed = sounded = fractions.Fraction()
for n in notes: for n in notes:
covered=n.start;duration=n.end-n.start;sounded+=duration covered = n.start
duration = n.end - n.start
sounded += duration
for s, a, b in self.segments(n): for s, a, b in self.segments(n):
if a!=covered or s.chord=='N.C.':return Result(False,0,error=f'{n.event_id}: missing written/carried harmony') if a != covered or s.chord == 'N.C.':
covered=b;permission=permissions.get(n.event_id) return Result(False, 0, error=f'{n.event_id}: missing written/carried harmony')
if permission and permission.kind=='anticipation' and a>=permission.boundary:permission=None covered = b
permission = permissions.get(n.event_id)
if permission and permission.kind == 'anticipation' and a >= permission.boundary:
permission = None
pc = n.pitch.number() % 12 pc = n.pitch.number() % 12
if pc in s.roles.basic|s.roles.specified or permission:continue if pc in s.roles.basic | s.roles.specified or permission:
if pc not in s.pitches:return Result(False,0,error=f'{n.event_id}: unsupported chromatic event') continue
if duration>=2:return Result(False,0,error=f'{n.event_id}: sustained nonchord tone needs an explicit treatment') if pc not in s.pitches:
return Result(False, 0, error=f'{n.event_id}: unsupported chromatic event')
if duration >= 2:
return Result(False, 0, error=f'{n.event_id}: sustained nonchord tone needs an explicit treatment')
exposed += (b - a) / 4 exposed += (b - a) / 4
if covered!=n.end:return Result(False,0,error=f'{n.event_id}: incomplete harmonic coverage') if covered != n.end:
return Result(True,float(exposed/max(F(1,1000),sounded)),pending,contracts=tuple(expressions)) return Result(False, 0, error=f'{n.event_id}: incomplete harmonic coverage')
return Result(True, float(exposed / max(fractions.Fraction(1, 1000), sounded)), pending, contracts=tuple(expressions))
def describe(self, notes, expressions=()): def describe(self, notes, expressions=()):
notes=tuple(self.identified(n) for n in types.sounding_notes(notes)) notes = tuple(self.identified(n) for n in generator.solo.types.sounding_notes(notes))
checked = self.evaluate(notes, expressions=expressions, complete=True) checked = self.evaluate(notes, expressions=expressions, complete=True)
if not checked.valid:raise ValueError(checked.error) if not checked.valid:
raise ValueError(checked.error)
permissions = {n.event_id: c for c in checked.contracts for n in c.events} permissions = {n.event_id: c for c in checked.contracts for n in c.events}
return [{'start': str(a), 'end': str(b), 'attack': str(n.start), 'sounding_duration': str(n.end - n.start), return [{'start': str(a), 'end': str(b), 'attack': str(n.start), 'sounding_duration': str(n.end - n.start),
'pitch': n.pitch.label(), 'chord': s.chord, 'function': self.function(n.pitch.number(), s), 'pitch': n.pitch.label(), 'chord': s.chord, 'function': self.function(n.pitch.number(), s),
+44 -39
View File
@@ -9,35 +9,32 @@ import collections
import json import json
import math import math
import functools import functools
from fractions import Fraction as F import fractions
from pathlib import Path import pathlib
from generator import model import generator.model
from generator.solo import expressions import generator.solo.expressions
VERSION = 'pooled-events-1' VERSION = 'pooled-events-1'
PRIOR = 8 PRIOR = 8
DURATIONS = ('1/4', '1/3', '1/2', '2/3', '3/4', '1', '3/2', '2', '5/2', '3', '4') DURATIONS = ('1/4', '1/3', '1/2', '2/3', '3/4', '1', '3/2', '2', '5/2', '3', '4')
def key(value): def key(value):
return json.dumps(value, separators=(',', ':')) return json.dumps(value, separators=(',', ':'))
def chord_context(harmony, time): def chord_context(harmony, time):
span = harmony.at(time) span = harmony.at(time)
current=expressions.parsed_chord(span.chord) if span and span.chord!='N.C.' else None current = generator.solo.expressions.parsed_chord(span.chord) if span and span.chord != 'N.C.' else None
future = next((s for s in harmony.spans if s.start > time and s.chord != 'N.C.' and (not span or s.chord != span.chord)), None) future = next((s for s in harmony.spans if s.start > time and s.chord != 'N.C.' and (not span or s.chord != span.chord)), None)
following=expressions.parsed_chord(future.chord) if future else None following = generator.solo.expressions.parsed_chord(future.chord) if future else None
def root(c):return int(model.NATURAL[c['root']['letter']]+model.alteration(c['root']['alter']))%12 if c else 0
return (expressions.quality(current) if current else 'unknown', def root(c): return int(generator.model.NATURAL[c['root']['letter']] + generator.model.alteration(c['root']['alter'])) % 12 if c else 0
expressions.quality(following) if following else 'end',
return (generator.solo.expressions.quality(current) if current else 'unknown',
generator.solo.expressions.quality(following) if following else 'end',
(root(following) - root(current)) % 12 if current and following else -1), root(current), future (root(following) - root(current)) % 12 if current and following else -1), root(current), future
def token(duration, pitch, previous, root): def token(duration, pitch, previous, root):
return ('rest',str(duration),0,0) if pitch is None else ('note',str(duration), return ('rest', str(duration), 0, 0) if pitch is None else ('note', str(duration), pitch - previous if previous is not None else 'start', (pitch - root) % 12)
pitch-previous if previous is not None else 'start',(pitch-root)%12)
def source_feature(analysis, time, root=0): def source_feature(analysis, time, root=0):
time += analysis.body_offset time += analysis.body_offset
@@ -50,13 +47,11 @@ def source_feature(analysis,time,root=0):
(before[-1].pitch.number() - root) % 12 if before else -1, (before[-1].pitch.number() - root) % 12 if before else -1,
before[-1].pitch.number() - before[-2].pitch.number() if len(before) > 1 else 0) before[-1].pitch.number() - before[-2].pitch.number() if len(before) > 1 else 0)
def phase_at(analysis, time): def phase_at(analysis, time):
absolute = time + analysis.body_offset absolute = time + analysis.body_offset
start = next((a for a, b in zip(analysis.boundaries, analysis.boundaries[1:]) if a <= absolute < b), analysis.boundaries[-2]) start = next((a for a, b in zip(analysis.boundaries, analysis.boundaries[1:]) if a <= absolute < b), analysis.boundaries[-2])
return absolute - start return absolute - start
@functools.lru_cache(maxsize=65536) @functools.lru_cache(maxsize=65536)
def contexts(harmonic, phase, source, history): def contexts(harmonic, phase, source, history):
# No title, path, absolute bar number, progression ID, or target-derived ID. # No title, path, absolute bar number, progression ID, or target-derived ID.
@@ -68,63 +63,73 @@ def contexts(harmonic,phase,source,history):
result.append((*base, source, tuple(history[-count:]))) result.append((*base, source, tuple(history[-count:])))
return tuple(key(c) for c in result) return tuple(key(c) for c in result)
class Model: class Model:
def __init__(self, value): def __init__(self, value):
if value.get('version')!=VERSION:raise ValueError('Missing/incompatible solo model; run python -m generator.train') if value.get('version') != VERSION:
raise ValueError('Missing/incompatible solo model; run python -m generator.train')
self.value = value self.value = value
self.tables = {k: (v['total'], v['counts']) for k, v in value['contexts'].items()} self.tables = {k: (v['total'], v['counts']) for k, v in value['contexts'].items()}
self.durations=tuple(sorted(map(F,value['durations']))) self.durations = tuple(sorted(map(fractions.Fraction, value['durations'])))
self.rhythms=value['rhythms'];self.steps=value['steps'];self.degrees=value['degrees'] self.rhythms = value['rhythms']
self.steps = value['steps']
self.degrees = value['degrees']
self._prob = {} self._prob = {}
self._totals = {id(t): sum(t.values()) for t in (self.rhythms, self.steps, self.degrees)} self._totals = {id(t): sum(t.values()) for t in (self.rhythms, self.steps, self.degrees)}
@classmethod @classmethod
def load(cls, path): def load(cls, path):
try:return cls(json.loads(Path(path).read_text())) try:
except FileNotFoundError as e:raise ValueError('Solo model not found; run python -m generator.train') from e return cls(json.loads(pathlib.Path(path).read_text()))
except FileNotFoundError as e:
raise ValueError('Solo model not found; run python -m generator.train') from e
def probability(self, action, context): def probability(self, action, context):
cache = (action, context) cache = (action, context)
if cache in self._prob:return self._prob[cache] if cache in self._prob:
return self._prob[cache]
kind, duration, step, degree = action kind, duration, step, degree = action
def p(table, item, size): return (table.get(str(item), 0) + 1) / (self._totals[id(table)] + size) def p(table, item, size): return (table.get(str(item), 0) + 1) / (self._totals[id(table)] + size)
probability = p(self.rhythms, kind + ':' + duration, 2 * len(self.durations)) probability = p(self.rhythms, kind + ':' + duration, 2 * len(self.durations))
if kind=='note':probability*=p(self.steps,step,50)*p(self.degrees,degree,12) if kind == 'note':
probability *= p(self.steps, step, 50) * p(self.degrees, degree, 12)
encoded = key(action) encoded = key(action)
for c in context: for c in context:
if c in self.tables: if c in self.tables:
total, counts = self.tables[c] total, counts = self.tables[c]
probability = (counts.get(encoded, 0) + PRIOR * probability) / (total + PRIOR) probability = (counts.get(encoded, 0) + PRIOR * probability) / (total + PRIOR)
if len(self._prob)>100000:self._prob.clear() if len(self._prob) > 100000:
self._prob.clear()
self._prob[cache] = probability self._prob[cache] = probability
return probability return probability
def cost(self, action, context): return -math.log(self.probability(action, context)) def cost(self, action, context): return -math.log(self.probability(action, context))
def register_cost(self, number): def register_cost(self, number):
counts = self.value['registers'] counts = self.value['registers']
return -math.log((counts.get(str(number // 12), 0) + PRIOR) / (sum(counts.values()) + PRIOR * 4)) return -math.log((counts.get(str(number // 12), 0) + PRIOR) / (sum(counts.values()) + PRIOR * 4))
def fit(examples, provenance): def fit(examples, provenance):
tables = collections.defaultdict(collections.Counter) tables = collections.defaultdict(collections.Counter)
rhythms=collections.Counter();steps=collections.Counter();degrees=collections.Counter() rhythms = collections.Counter()
registers=collections.Counter();durations=set(DURATIONS) steps = collections.Counter()
degrees = collections.Counter()
registers = collections.Counter()
durations = set(DURATIONS)
for example in examples: for example in examples:
history=[];previous=None history = []
previous = None
for start, end, pitch in example['events']: for start, end, pitch in example['events']:
harmonic, root, _ = chord_context(example['harmony'], start) harmonic, root, _ = chord_context(example['harmony'], start)
action = token(end - start, pitch, previous, root) action = token(end - start, pitch, previous, root)
context = contexts(harmonic, phase_at(example['analysis'], start - example['analysis'].body_offset), context = contexts(harmonic, phase_at(example['analysis'], start - example['analysis'].body_offset),
source_feature(example['analysis'], start - example['analysis'].body_offset, root), tuple(history[-4:])) source_feature(example['analysis'], start - example['analysis'].body_offset, root), tuple(history[-4:]))
for c in context:tables[c][key(action)]+=1 for c in context:
durations.add(str(end-start));rhythms[action[0]+':'+action[1]]+=1 tables[c][key(action)] += 1
durations.add(str(end - start))
rhythms[action[0] + ':' + action[1]] += 1
if pitch is not None: if pitch is not None:
steps[str(action[2])]+=1;degrees[str(action[3])]+=1 steps[str(action[2])] += 1
registers[str(pitch//12)]+=1;previous=pitch degrees[str(action[3])] += 1
registers[str(pitch // 12)] += 1
previous = pitch
history.append(action) history.append(action)
return {'version': VERSION, 'provenance': provenance, 'prior': PRIOR, return {'version': VERSION, 'provenance': provenance, 'prior': PRIOR,
'durations':sorted(durations,key=F),'rhythms':dict(rhythms),'steps':dict(steps),'degrees':dict(degrees), 'durations': sorted(durations, key=fractions.Fraction), 'rhythms': dict(rhythms), 'steps': dict(steps), 'degrees': dict(degrees),
'registers': dict(registers), 'registers': dict(registers),
'contexts': {k: {'total': sum(v.values()), 'counts': dict(sorted(v.items()))} for k, v in sorted(tables.items())}} 'contexts': {k: {'total': sum(v.values()), 'counts': dict(sorted(v.items()))} for k, v in sorted(tables.items())}}
+13 -7
View File
@@ -64,24 +64,30 @@ def tuplets(measures):
spans = [] spans = []
events = [n for m in measures for n in generator.score.measure_notes(m)] events = [n for m in measures for n in generator.score.measure_notes(m)]
for ratio, values in itertools.groupby(events, key=lambda n: generator.model.fraction(n['written'].get('ratio', generator.model.rational(1)))): for ratio, values in itertools.groupby(events, key=lambda n: generator.model.fraction(n['written'].get('ratio', generator.model.rational(1)))):
if ratio==1:continue if ratio == 1:
members=[];elapsed=fractions.Fraction();length=None continue
members = []
elapsed = fractions.Fraction()
length = None
for note in values: for note in values:
if not members: if not members:
length = generator.model.TYPE_LENGTHS[note['written']['type']] * ratio.numerator length = generator.model.TYPE_LENGTHS[note['written']['type']] * ratio.numerator
members.append(note);elapsed+=generator.model.fraction(note['duration']) members.append(note)
elapsed += generator.model.fraction(note['duration'])
if elapsed >= length: if elapsed >= length:
identifier = members[0]['id'] + '_tuplet' identifier = members[0]['id'] + '_tuplet'
spans.append({'id': identifier, 'voice': 'voice_arranged', 'start': members[0]['id'], 'end': members[-1]['id'], 'note_count': ratio.denominator, 'space_count': ratio.numerator, 'depth': 0, 'bracket': 'auto', 'show_number': 'actual'}) spans.append({'id': identifier, 'voice': 'voice_arranged', 'start': members[0]['id'], 'end': members[-1]['id'], 'note_count': ratio.denominator, 'space_count': ratio.numerator, 'depth': 0, 'bracket': 'auto', 'show_number': 'actual'})
for n in members:n['tuplet_ids']=[identifier] for n in members:
members=[];elapsed=fractions.Fraction() n['tuplet_ids'] = [identifier]
members = []
elapsed = fractions.Fraction()
if members: if members:
identifier = members[0]['id'] + '_tuplet' identifier = members[0]['id'] + '_tuplet'
spans.append({'id': identifier, 'voice': 'voice_arranged', 'start': members[0]['id'], 'end': members[-1]['id'], 'note_count': ratio.denominator, 'space_count': ratio.numerator, 'depth': 0, 'bracket': 'auto', 'show_number': 'actual'}) spans.append({'id': identifier, 'voice': 'voice_arranged', 'start': members[0]['id'], 'end': members[-1]['id'], 'note_count': ratio.denominator, 'space_count': ratio.numerator, 'depth': 0, 'bracket': 'auto', 'show_number': 'actual'})
for n in members:n['tuplet_ids']=[identifier] for n in members:
n['tuplet_ids'] = [identifier]
return spans return spans
def replace_region(document: dict, first: int, replacement: list[dict]) -> None: def replace_region(document: dict, first: int, replacement: list[dict]) -> None:
staff = document['staves'][0] staff = document['staves'][0]
replacement = copy.deepcopy(replacement) replacement = copy.deepcopy(replacement)
+9 -11
View File
@@ -6,7 +6,8 @@ import functools
import generator.fretboard import generator.fretboard
import generator.solo.types import generator.solo.types
from generator.solo.profiles import UNRESTRICTED import generator.solo.profiles
import generator.solo.policy
def travel(previous: generator.solo.types.Note | None, current: generator.solo.types.Note, tempo: fractions.Fraction) -> int: def travel(previous: generator.solo.types.Note | None, current: generator.solo.types.Note, tempo: fractions.Fraction) -> int:
if previous is None: if previous is None:
@@ -23,18 +24,15 @@ def transition(previous, current, duration, tempo):
def hand_positions(position): def hand_positions(position):
return tuple(h for h in range(1, 10) if h <= position.fret <= h + 3) return tuple(h for h in range(1, 10) if h <= position.fret <= h + 3)
@functools.lru_cache(maxsize=512) @functools.lru_cache(maxsize=512)
def position_rank(position): def position_rank(position):
from generator.solo.policy import stable_rank return generator.solo.policy.stable_rank('guitar-position', position)
return stable_rank('guitar-position', position)
def stable_path(path): def stable_path(path):
return tuple(position_rank(n.position) for n in path) return tuple(position_rank(n.position) for n in path)
@functools.lru_cache(maxsize=4096) @functools.lru_cache(maxsize=4096)
def alternatives(notes, tempo, previous=None, limit=2, profile=UNRESTRICTED): def alternatives(notes, tempo, previous=None, limit=2, profile=generator.solo.profiles.UNRESTRICTED):
"""Phrase paths with a four-fret hand region and distinct endpoint choices.""" """Phrase paths with a four-fret hand region and distinct endpoint choices."""
states = [(0, (), h) for h in hand_positions(previous.position)] if previous and previous.position else [(0, (), None)] states = [(0, (), h) for h in hand_positions(previous.position)] if previous and previous.position else [(0, (), None)]
for note in notes: for note in notes:
@@ -67,18 +65,18 @@ def alternatives(notes, tempo, previous=None, limit=2, profile=UNRESTRICTED):
endpoint = path[-1].position if path else None endpoint = path[-1].position if path else None
if endpoint in seen: if endpoint in seen:
continue continue
seen.add(endpoint); selected.append((cost,path)) seen.add(endpoint)
selected.append((cost, path))
if len(selected) == limit: if len(selected) == limit:
break break
return tuple(selected) return tuple(selected)
@functools.lru_cache(maxsize=4096) @functools.lru_cache(maxsize=4096)
def place(notes, tempo, previous=None, profile=UNRESTRICTED): def place(notes, tempo, previous=None, profile=generator.solo.profiles.UNRESTRICTED):
result = alternatives(notes, tempo, previous, 1, profile) result = alternatives(notes, tempo, previous, 1, profile)
return result[0] if result else None return result[0] if result else None
@functools.lru_cache(maxsize=2048) @functools.lru_cache(maxsize=2048)
def positions(number: int, profile=UNRESTRICTED): def positions(number: int, profile=generator.solo.profiles.UNRESTRICTED):
profile = profile or UNRESTRICTED profile = profile or generator.solo.profiles.UNRESTRICTED
return tuple(p for p in generator.fretboard.single_positions_for_pitch(fractions.Fraction(number)) if p.string_number in profile.strings) return tuple(p for p in generator.fretboard.single_positions_for_pitch(fractions.Fraction(number)) if p.string_number in profile.strings)
+2 -6
View File
@@ -3,31 +3,27 @@ from __future__ import annotations
import dataclasses import dataclasses
import functools import functools
from fractions import Fraction as F import fractions
import generator.fretboard import generator.fretboard
import generator.vocabulary import generator.vocabulary
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Instrument: class Instrument:
part: str = '' part: str = ''
strings: tuple[int, ...] = (1, 2, 3, 4, 5, 6) strings: tuple[int, ...] = (1, 2, 3, 4, 5, 6)
reference: int | None = None reference: int | None = None
melody_range: tuple[int, int] | None = None melody_range: tuple[int, int] | None = None
@property @property
def register(self): def register(self):
tuning = [p for s, p in generator.fretboard.STANDARD_TUNING if s in self.strings] tuning = [p for s, p in generator.fretboard.STANDARD_TUNING if s in self.strings]
return min(tuning) + 1, max(tuning) + 12 return min(tuning) + 1, max(tuning) + 12
@functools.lru_cache(maxsize=4096) @functools.lru_cache(maxsize=4096)
def permits(self, number): def permits(self, number):
return any(p.string_number in self.strings for p in generator.fretboard.single_positions_for_pitch(F(number))) return any(p.string_number in self.strings for p in generator.fretboard.single_positions_for_pitch(fractions.Fraction(number)))
UNRESTRICTED = Instrument() UNRESTRICTED = Instrument()
def for_part(part, analysis=None): def for_part(part, analysis=None):
if part not in ('acoustic', 'electric'): if part not in ('acoustic', 'electric'):
raise ValueError('unknown solo instrument: ' + part) raise ValueError('unknown solo instrument: ' + part)
+76 -50
View File
@@ -4,100 +4,119 @@ No filenames, annotations, learned examples or role exemptions enter here.
Existing expression validators prove each candidate; deterministic set-cover Existing expression validators prove each candidate; deterministic set-cover
search chooses a mutually consistent explanation, never edits the notes. search chooses a mutually consistent explanation, never edits the notes.
""" """
from fractions import Fraction as F import fractions
from generator import vocabulary import generator.vocabulary
from generator.solo import expressions, types import generator.solo.expressions
import generator.solo.types
def signature(c): def signature(c):
return (c.kind, c.rule, tuple(n.event_id for n in c.events), c.target.event_id, return (c.kind, c.rule, tuple(n.event_id for n in c.events), c.target.event_id,
str(c.boundary), c.preparation.event_id if c.preparation else '') str(c.boundary), c.preparation.event_id if c.preparation else '')
def merge(*groups): def merge(*groups):
result = {} result = {}
for group in groups: for group in groups:
for c in group:result.setdefault(signature(c),c) for c in group:
result.setdefault(signature(c), c)
return tuple(result.values()) return tuple(result.values())
def recognize(notes, harmony, provided=(), *, budget=4096): def recognize(notes, harmony, provided=(), *, budget=4096):
required = {n.event_id for n in notes if not harmony.ordinary(n)} required = {n.event_id for n in notes if not harmony.ordinary(n)}
fixed = {n.event_id for c in provided for n in c.events} fixed = {n.event_id for c in provided for n in c.events}
if required<=fixed:return tuple(provided),'' if required <= fixed:
candidates={};single={};by_id={n.event_id:n for n in notes} return tuple(provided), ''
if len(by_id)!=len(notes):return (), 'Harmonic analysis requires unique sounding-event IDs' candidates = {}
single = {}
by_id = {n.event_id: n for n in notes}
if len(by_id) != len(notes):
return (), 'Harmonic analysis requires unique sounding-event IDs'
def contract(kind, rule, events, target, boundary=None, preparation=None): def contract(kind, rule, events, target, boundary=None, preparation=None):
span = harmony.at(boundary if boundary is not None else target.start) span = harmony.at(boundary if boundary is not None else target.start)
if not span or span.chord=='N.C.':return None if not span or span.chord == 'N.C.':
return None
identifier = f'inferred:{kind}:{rule}:{events[0].event_id}:{target.event_id}:{boundary}' identifier = f'inferred:{kind}:{rule}:{events[0].event_id}:{target.event_id}:{boundary}'
return types.ExpressionContract(identifier,kind,rule,tuple(events),target,span.chord,boundary,preparation) return generator.solo.types.ExpressionContract(identifier, kind, rule, tuple(events), target, span.chord, boundary, preparation)
def add(c, extras=()): def add(c, extras=()):
if c is None:return False if c is None:
bundle=merge(extras,(c,));covered=frozenset(n.event_id for a in bundle for n in a.events) return False
if not (covered & required):return False bundle = merge(extras, (c,))
covered = frozenset(n.event_id for a in bundle for n in a.events)
if not (covered & required):
return False
combined = merge(provided, bundle) combined = merge(provided, bundle)
_,error=expressions.validate(notes,combined,harmony) _, error = generator.solo.expressions.validate(notes, combined, harmony)
if error:return False if error:
return False
candidates[tuple(signature(a) for a in bundle)] = (bundle, covered) candidates[tuple(signature(a) for a in bundle)] = (bundle, covered)
return True return True
# First discover independently supported colors and harmonic anticipations. # First discover independently supported colors and harmonic anticipations.
# These can also support a later approach's destination or passing endpoint. # These can also support a later approach's destination or passing endpoint.
for i, n in enumerate(notes): for i, n in enumerate(notes):
if n.event_id not in required:continue if n.event_id not in required:
future=[t for t in notes[i+1:] if n.end<=t.start<=n.end+F(vocabulary.data('harmony')['resolution_gap']) continue
future = [t for t in notes[i + 1:] if n.end <= t.start <= n.end + fractions.Fraction(generator.vocabulary.data('harmony')['resolution_gap'])
and 0 < abs(t.pitch.number() - n.pitch.number()) <= 2] and 0 < abs(t.pitch.number() - n.pitch.number()) <= 2]
regions = [(None, harmony.at(n.start))] regions = [(None, harmony.at(n.start))]
regions.extend((s.start, s) for s in harmony.spans if n.start < s.start < n.end regions.extend((s.start, s) for s in harmony.spans if n.start < s.start < n.end
and s.start-n.start<=F(vocabulary.data('harmony')['anticipation_max'])) and s.start - n.start <= fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']))
for boundary, span in regions: for boundary, span in regions:
if not span or span.chord=='N.C.':continue if not span or span.chord == 'N.C.':
continue
if boundary is not None: if boundary is not None:
c = contract('anticipation', 'early_chord_tone', (n,), n, boundary) c = contract('anticipation', 'early_chord_tone', (n,), n, boundary)
if add(c):single.setdefault(n.event_id,[]).append((c,)) if add(c):
for rule in vocabulary.data('harmony')['colors']: single.setdefault(n.event_id, []).append((c,))
if not expressions.eligible_color(rule,span,harmony) and boundary is not None:continue for rule in generator.vocabulary.data('harmony')['colors']:
if not generator.solo.expressions.eligible_color(rule, span, harmony) and boundary is not None:
continue
kind = 'anticipated_color' if boundary is not None else 'color' kind = 'anticipated_color' if boundary is not None else 'color'
for target in future if rule.get('resolve') else (n,): for target in future if rule.get('resolve') else [n]:
c = contract(kind, rule['id'], (n,), target, boundary) c = contract(kind, rule['id'], (n,), target, boundary)
# Non-anticipated color permission is scoped to the note's initial # Non-anticipated color permission is scoped to the note's initial
# harmony, even when its resolution occurs after a chord boundary. # harmony, even when its resolution occurs after a chord boundary.
if boundary is None and c:c=types.ExpressionContract(c.id,c.kind,c.rule,c.events,c.target,span.chord) if boundary is None and c:
if add(c):single.setdefault(n.event_id,[]).append((c,)) c = generator.solo.types.ExpressionContract(c.id, c.kind, c.rule, c.events, c.target, span.chord)
if add(c):
single.setdefault(n.event_id, []).append((c,))
if i + 1 < len(notes): if i + 1 < len(notes):
target = notes[i + 1] target = notes[i + 1]
for s in harmony.spans: for s in harmony.spans:
if n.start < s.start < n.end: if n.start < s.start < n.end:
for rule in vocabulary.data('harmony').get('suspensions',[]): for rule in generator.vocabulary.data('harmony').get('suspensions', []):
add(contract('suspension', rule['id'], (n,), target, s.start)) add(contract('suspension', rule['id'], (n,), target, s.start))
elif s.start == n.end == target.start and n.pitch.number() == target.pitch.number(): elif s.start == n.end == target.start and n.pitch.number() == target.pitch.number():
add(contract('anticipation', 'early_reattack', (n,), target, s.start)) add(contract('anticipation', 'early_reattack', (n,), target, s.start))
for i, target in enumerate(notes): for i, target in enumerate(notes):
targets = [(None, ())] targets = [(None, ())]
for support in single.get(target.event_id, ()): for support in single.get(target.event_id, ()):
c = support[0] c = support[0]
# An approach into an anticipated color needs both permissions; that # An approach into an anticipated color needs both permissions; that
# combination is not silently interpreted as a chord-tone anticipation. # combination is not silently interpreted as a chord-tone anticipation.
if c.kind=='color':targets.append((None,support)) if c.kind == 'color':
elif c.kind=='anticipation':targets.append((c.boundary,support)) targets.append((None, support))
for rule in vocabulary.data('lead_ins')['patterns']: elif c.kind == 'anticipation':
targets.append((c.boundary, support))
for rule in generator.vocabulary.data('lead_ins')['patterns']:
size = len(rule['semitones']) size = len(rule['semitones'])
if i<size:continue if i < size:
continue
ns = notes[i - size:i] ns = notes[i - size:i]
if not any(n.event_id in required for n in ns):continue if not any(n.event_id in required for n in ns):
if tuple(n.pitch.number()-target.pitch.number() for n in ns)!=tuple(rule['semitones']):continue continue
if tuple(n.pitch.number() - target.pitch.number() for n in ns) != tuple(rule['semitones']):
continue
preparation = notes[i - size - 1] if i > size and rule['kind'] == 'connection' else None preparation = notes[i - size - 1] if i > size and rule['kind'] == 'connection' else None
for boundary, extras in targets: for boundary, extras in targets:
add(contract(rule['kind'], rule['id'], ns, target, boundary, preparation), extras) add(contract(rule['kind'], rule['id'], ns, target, boundary, preparation), extras)
# Delayed approaches may include real space or intervening valid music. # Delayed approaches may include real space or intervening valid music.
# They still have an exact semitone target and a finite deadline. # They still have an exact semitone target and a finite deadline.
rule=vocabulary.data('lead_ins')['delayed_approach'] rule = generator.vocabulary.data('lead_ins')['delayed_approach']
for n in reversed(notes[:i]): for n in reversed(notes[:i]):
if target.start-n.start>F(rule['deadline']):break if target.start - n.start > fractions.Fraction(rule['deadline']):
break
if n.event_id in required and target.pitch.number() - n.pitch.number() in rule['steps']: if n.event_id in required and target.pitch.number() - n.pitch.number() in rule['steps']:
add(contract('delayed_approach', rule['id'], (n,), target)) add(contract('delayed_approach', rule['id'], (n,), target))
if i >= 2: if i >= 2:
@@ -105,37 +124,44 @@ def recognize(notes,harmony,provided=(),*,budget=4096):
if n.event_id in required: if n.event_id in required:
for left in [(), *single.get(before.event_id, ())]: for left in [(), *single.get(before.event_id, ())]:
for right in [(), *single.get(target.event_id, ())]: for right in [(), *single.get(target.event_id, ())]:
add(contract('passing',vocabulary.data('lead_ins')['passing']['id'],(n,),target,preparation=before),merge(left,right)) add(contract('passing', generator.vocabulary.data('lead_ins')['passing']['id'], (n,), target, preparation=before), merge(left, right))
for rule in vocabulary.data('lead_ins')['blues']: for rule in generator.vocabulary.data('lead_ins')['blues']:
add(contract('blues', rule['id'], (n,), target, preparation=before)) add(contract('blues', rule['id'], (n,), target, preparation=before))
options = {identifier: [] for identifier in sorted(required - fixed, key=lambda identifier: (by_id[identifier].start, identifier))} options = {identifier: [] for identifier in sorted(required - fixed, key=lambda identifier: (by_id[identifier].start, identifier))}
for bundle, covered in candidates.values(): for bundle, covered in candidates.values():
for identifier in covered & options.keys():options[identifier].append((bundle,covered)) for identifier in covered & options.keys():
options[identifier].append((bundle, covered))
for identifier, choices in options.items(): for identifier, choices in options.items():
if not choices: if not choices:
n = by_id[identifier] n = by_id[identifier]
return (), f'{identifier} at {n.start}: unsupported harmonic relationship for {n.pitch.label()}' return (), f'{identifier} at {n.start}: unsupported harmonic relationship for {n.pitch.label()}'
choices.sort(key=lambda x:(sum((n.end-n.start for c in x[0] for n in c.events),F()), choices.sort(key=lambda x: (sum((n.end - n.start for c in x[0] for n in c.events), fractions.Fraction()),
sum((max(F(),c.target.start-c.events[-1].end) for c in x[0]),F()),tuple(signature(c) for c in x[0]))) sum((max(fractions.Fraction(), c.target.start - c.events[-1].end) for c in x[0]), fractions.Fraction()), tuple(signature(c) for c in x[0])))
visits = 0
failed = set()
visits=0;failed=set()
def solve(chosen, covered): def solve(chosen, covered):
nonlocal visits nonlocal visits
remaining = required - covered remaining = required - covered
if not remaining:return chosen if not remaining:
return chosen
key = tuple(sorted(signature(c) for c in chosen)) key = tuple(sorted(signature(c) for c in chosen))
if key in failed or visits>=budget:return None if key in failed or visits >= budget:
return None
visits += 1 visits += 1
identifier = min(remaining, key=lambda x: (len(options[x]), by_id[x].start, x)) identifier = min(remaining, key=lambda x: (len(options[x]), by_id[x].start, x))
for bundle, added in options[identifier]: for bundle, added in options[identifier]:
combined = merge(chosen, bundle) combined = merge(chosen, bundle)
_,error=expressions.validate(notes,combined,harmony) _, error = generator.solo.expressions.validate(notes, combined, harmony)
if error:continue if error:
continue
result = solve(combined, covered | added) result = solve(combined, covered | added)
if result is not None:return result if result is not None:
return result
failed.add(key) failed.add(key)
return None return None
result = solve(tuple(provided), fixed) result = solve(tuple(provided), fixed)
if result is None:return (), 'Harmonic treatment search budget exhausted' if visits>=budget else 'No mutually consistent harmonic treatment coverage' if result is None:
return (), 'Harmonic treatment search budget exhausted' if visits >= budget else 'No mutually consistent harmonic treatment coverage'
return result, '' return result, ''
+25 -14
View File
@@ -1,17 +1,23 @@
"""Compact reports of actual selected phrases, not duplicated draft ancestry.""" """Compact reports of actual selected phrases, not duplicated draft ancestry."""
import dataclasses, fractions, json import dataclasses, fractions, json
import generator.solo.placement import generator.solo.placement
from generator.solo import harmonic import generator.solo.harmonic
import collections
import generator.fretboard
def plain(value): def plain(value):
if dataclasses.is_dataclass(value):return plain(dataclasses.asdict(value)) if dataclasses.is_dataclass(value):
if isinstance(value,dict):return {k:plain(v) for k,v in value.items()} return plain(dataclasses.asdict(value))
if isinstance(value,(tuple,list,set,frozenset)):return [plain(v) for v in value] if isinstance(value, dict):
if isinstance(value,fractions.Fraction):return str(value) return {k: plain(v) for k, v in value.items()}
if isinstance(value, (tuple, list, set, frozenset)):
return [plain(v) for v in value]
if isinstance(value, fractions.Fraction):
return str(value)
return value return value
def structure(solo): def structure(solo):
harmony=harmonic.Harmony(solo.harmony) harmony = generator.solo.harmonic.Harmony(solo.harmony)
contracts = tuple(c for p in solo.phrases for c in p.expressions) contracts = tuple(c for p in solo.phrases for c in p.expressions)
checked = harmony.evaluate(solo.notes(), until=solo.end, complete=True, expressions=contracts) checked = harmony.evaluate(solo.notes(), until=solo.end, complete=True, expressions=contracts)
phrases = [] phrases = []
@@ -37,21 +43,26 @@ def describe(solo,measure_offset=0,time_offset=0,*,detail=None):
'Approximate fingering costs are not a biomechanical guarantee.', json.dumps(detail['phrasing'])] 'Approximate fingering costs are not a biomechanical guarantee.', json.dumps(detail['phrasing'])]
for p in solo.phrases: for p in solo.phrases:
lines.append(f'{p.id}: {p.start}–{p.end}; {p.purpose}, {p.direction}, {p.ending}; theme {p.theme}; score {p.score:.4f}') lines.append(f'{p.id}: {p.start}–{p.end}; {p.purpose}, {p.direction}, {p.ending}; theme {p.theme}; score {p.score:.4f}')
for n in p.notes:lines.append(f' {n.start}–{n.end}: {n.pitch.label()}, {n.role}; string {n.position.string_number}, fret {n.position.fret}') for n in p.notes:
for c in p.expressions:lines.append(f' {c.kind}/{c.rule} → {c.target.pitch.label()} at {c.target.start}, held to {c.target.end}') lines.append(f' {n.start}–{n.end}: {n.pitch.label()}, {n.role}; string {n.position.string_number}, fret {n.position.fret}')
for c in p.expressions:
lines.append(f' {c.kind}/{c.rule} → {c.target.pitch.label()} at {c.target.start}, held to {c.target.end}')
return '\n'.join(lines) + '\n' return '\n'.join(lines) + '\n'
def fretboard_report(solo): def fretboard_report(solo):
import collections
import generator.fretboard
notes = solo.notes() notes = solo.notes()
attacks=collections.Counter(); durations=collections.Counter(); forced=optional=0 attacks = collections.Counter()
durations = collections.Counter()
forced = optional = 0
for note in notes: for note in notes:
string = note.position.string_number string = note.position.string_number
attacks[string]+=1;durations[string]+=float(note.end-note.start) attacks[string] += 1
durations[string] += float(note.end - note.start)
if string == 1: if string == 1:
if len(generator.solo.placement.positions(note.pitch.number(),solo.profile))==1:forced+=1 if len(generator.solo.placement.positions(note.pitch.number(), solo.profile)) == 1:
else:optional+=1 forced += 1
else:
optional += 1
total = sum(durations.values()) total = sum(durations.values())
return {'attacks_by_string': dict(sorted(attacks.items())), return {'attacks_by_string': dict(sorted(attacks.items())),
'duration_fraction_by_string': {s: d / max(.001, total) for s, d in sorted(durations.items())}, 'duration_fraction_by_string': {s: d / max(.001, total) for s, d in sorted(durations.items())},
-1
View File
@@ -5,7 +5,6 @@ import generator.model
import generator.fretboard import generator.fretboard
import generator.solo.profiles import generator.solo.profiles
@dataclasses.dataclass(frozen=True, order=True) @dataclasses.dataclass(frozen=True, order=True)
class Pitch: class Pitch:
letter: str letter: str
+126 -79
View File
@@ -6,63 +6,89 @@ import dataclasses
import hashlib import hashlib
import json import json
import os import os
from pathlib import Path import pathlib
import tempfile import tempfile
import time import time
from fractions import Fraction as F import fractions
from generator import harmony, model, musicxml, parts, training_io, vocabulary import generator.harmony
from generator import harmonic_time as timeline import generator.model
from generator.solo import analysis, learned import generator.musicxml
import generator.parts
import generator.training_io
import generator.vocabulary
import generator.harmonic_time
import generator.solo.analysis
import generator.solo.learned
import shutil
import generator.production
import generator.solo.authored
import generator.fretboard
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Span: class Span:
start:F start: fractions.Fraction
end:F end: fractions.Fraction
chord: str chord: str
class Context: class Context:
def __init__(self, document): def __init__(self, document):
self.spans=tuple(Span(s.start,s.end,harmony.symbol(s.chord) if s.chord else 'N.C.') self.spans = tuple(Span(s.start, s.end, generator.harmony.symbol(s.chord) if s.chord else 'N.C.')
for s in timeline.read(document['staves'][0])) for s in generator.harmonic_time.read(document['staves'][0]))
def at(self, time): return next((s for s in self.spans if s.start <= time < s.end), None) def at(self, time): return next((s for s in self.spans if s.start <= time < s.end), None)
def load_examples(manifest): def load_examples(manifest):
manifest=Path(manifest);value=json.loads(manifest.read_text()) manifest = pathlib.Path(manifest)
if value.get('version')!=1:raise ValueError('Unsupported training manifest') value = json.loads(manifest.read_text())
examples=[];provenance=[];seen=set();pairs=set() if value.get('version') != 1:
raise ValueError('Unsupported training manifest')
examples = []
provenance = []
seen = set()
pairs = set()
for entry in sorted(value['examples'], key=lambda e: e['id']): for entry in sorted(value['examples'], key=lambda e: e['id']):
if entry['id'] in seen or entry['slot'] not in (1,2):raise ValueError('Duplicate example or invalid role') if entry['id'] in seen or entry['slot'] not in (1, 2):
seen.add(entry['id']);role='acoustic' if entry['slot']==1 else 'electric' raise ValueError('Duplicate example or invalid role')
source=(manifest.parent/entry['source']).resolve();solo=(manifest.parent/entry['solo']).resolve() seen.add(entry['id'])
if not solo.is_relative_to(manifest.parent.resolve()):raise ValueError('Training solo must be inside the training directory') role = 'acoustic' if entry['slot'] == 1 else 'electric'
if not source.is_relative_to((manifest.parent.parent/'songs').resolve()):raise ValueError('Training sources must be inside data/songs') source = (manifest.parent / entry['source']).resolve()
document=parts.prepare_document(musicxml.load_musicxml(source));a=analysis.analyze(document) solo = (manifest.parent / entry['solo']).resolve()
from generator.production import SourceAnalysis if not solo.is_relative_to(manifest.parent.resolve()):
pair=(SourceAnalysis.read(document).musical_key,entry['slot']) raise ValueError('Training solo must be inside the training directory')
if pair in pairs:raise ValueError('Duplicate source music and solo slot') if not source.is_relative_to((manifest.parent.parent / 'songs').resolve()):
raise ValueError('Training sources must be inside data/songs')
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(source))
a = generator.solo.analysis.analyze(document)
pair = (generator.production.SourceAnalysis.read(document).musical_key, entry['slot'])
if pair in pairs:
raise ValueError('Duplicate source music and solo slot')
pairs.add(pair) pairs.add(pair)
evidence=training_io.read(solo,role,pitch_convention=entry['pitch_convention']) evidence = generator.training_io.read(solo, role, pitch_convention=entry['pitch_convention'])
spans=[(F(m['start']),F(m['duration'])) for m in evidence['measures']] spans = [(fractions.Fraction(m['start']), fractions.Fraction(m['duration'])) for m in evidence['measures']]
if spans!=[(x,y-x) for x,y in zip(a.boundaries,a.boundaries[1:])]:raise ValueError(entry['id']+': source/solo measure timing differs; no padding or pickup removal allowed') if spans != [(x, y - x) for x, y in zip(a.boundaries, a.boundaries[1:])]:
if evidence['discrepancies']:raise ValueError(entry['id']+': '+str(evidence['discrepancies'])) raise ValueError(entry['id'] + ': source/solo measure timing differs; no padding or pickup removal allowed')
if evidence['discrepancies']:
raise ValueError(entry['id'] + ': ' + str(evidence['discrepancies']))
ctx = Context(document) ctx = Context(document)
training_io.validate_harmony(document,evidence,entry['id']) generator.training_io.validate_harmony(document, evidence, entry['id'])
from generator.solo.authored import validate_evidence checked = generator.solo.authored.validate_evidence(document, evidence)
checked=validate_evidence(document,evidence) events = []
events=[];cursor=F() cursor = fractions.Fraction()
from generator import fretboard
for n in evidence['sounding_events']: for n in evidence['sounding_events']:
start,end=F(n['start']),F(n['end']);number=F(n['sounding_midi']) start, end = fractions.Fraction(n['start']), fractions.Fraction(n['end'])
if number.denominator!=1:raise ValueError(entry['id']+': microtonal guitar pitch is unsupported') number = fractions.Fraction(n['sounding_midi'])
if number.denominator != 1:
raise ValueError(entry['id'] + ': microtonal guitar pitch is unsupported')
pitch = int(number) pitch = int(number)
if not fretboard.single_positions_for_pitch(F(pitch)):raise ValueError(entry['id']+': unplayable sounding pitch '+str(pitch)+' at '+str(start)) if not generator.fretboard.single_positions_for_pitch(fractions.Fraction(pitch)):
if start<cursor or end<=start:raise ValueError(entry['id']+': non-monophonic/invalid event') raise ValueError(entry['id'] + ': unplayable sounding pitch ' + str(pitch) + ' at ' + str(start))
if start>cursor:events.append((cursor,start,None)) if start < cursor or end <= start:
events.append((start,end,pitch));cursor=end raise ValueError(entry['id'] + ': non-monophonic/invalid event')
if cursor<a.boundaries[-1]:events.append((cursor,a.boundaries[-1],None)) if start > cursor:
events.append((cursor, start, None))
events.append((start, end, pitch))
cursor = end
if cursor < a.boundaries[-1]:
events.append((cursor, a.boundaries[-1], None))
source_hash = hashlib.sha256(source.read_bytes()).hexdigest() source_hash = hashlib.sha256(source.read_bytes()).hexdigest()
examples.append({'id': entry['id'], 'song': source_hash, 'role': role, 'analysis': a, 'harmony': ctx, 'events': tuple(events), examples.append({'id': entry['id'], 'song': source_hash, 'role': role, 'analysis': a, 'harmony': ctx, 'events': tuple(events),
'harmonic_validation': {'valid': checked.valid, 'treatments': dict(sorted(collections.Counter(c.rule for c in checked.contracts).items()))}}) 'harmonic_validation': {'valid': checked.valid, 'treatments': dict(sorted(collections.Counter(c.rule for c in checked.contracts).items()))}})
@@ -70,75 +96,93 @@ def load_examples(manifest):
'pitch_convention': entry['pitch_convention'], 'measures': len(spans), 'pickup_beats': str(a.body_offset), 'pitch_convention': entry['pitch_convention'], 'measures': len(spans), 'pickup_beats': str(a.body_offset),
'slot': entry['slot'], 'attacks': len(evidence['sounding_events']), 'annotations': len(evidence['annotations']), 'slot': entry['slot'], 'attacks': len(evidence['sounding_events']), 'annotations': len(evidence['annotations']),
'adapter_notes': ['Trailing chord-display parentheses interpreted without modifying XML.'] if any(h['display'] and h['display'].endswith(')') and '(' not in h['display'] for h in evidence['harmonies']) else []}) 'adapter_notes': ['Trailing chord-display parentheses interpreted without modifying XML.'] if any(h['display'] and h['display'].endswith(')') and '(' not in h['display'] for h in evidence['harmonies']) else []})
if not examples:raise ValueError('No training examples') if not examples:
return examples,{'description':value['provenance'],'examples':provenance,'training_version':learned.VERSION} raise ValueError('No training examples')
return examples, {'description': value['provenance'], 'examples': provenance, 'training_version': generator.solo.learned.VERSION}
def likelihood(predictor, example): def likelihood(predictor, example):
history=[];previous=None;cost=0 history = []
previous = None
cost = 0
for start, end, pitch in example['events']: for start, end, pitch in example['events']:
harmonic,root,_=learned.chord_context(example['harmony'],start) harmonic, root, _ = generator.solo.learned.chord_context(example['harmony'], start)
action=learned.token(end-start,pitch,previous,root) action = generator.solo.learned.token(end - start, pitch, previous, root)
relative = start - example['analysis'].body_offset relative = start - example['analysis'].body_offset
c=learned.contexts(harmonic,learned.phase_at(example['analysis'],relative),learned.source_feature(example['analysis'],relative,root),tuple(history[-4:])) c = generator.solo.learned.contexts(harmonic, generator.solo.learned.phase_at(example['analysis'], relative), generator.solo.learned.source_feature(example['analysis'], relative, root), tuple(history[-4:]))
cost+=predictor.cost(action,c);history.append(action) cost += predictor.cost(action, c)
if pitch is not None:previous=pitch history.append(action)
if pitch is not None:
previous = pitch
return cost / len(history) return cost / len(history)
def atomic_json(path, value): def atomic_json(path, value):
path=Path(path);path.parent.mkdir(parents=True,exist_ok=True) path = pathlib.Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile('w', dir=path.parent, delete=False) as stream: with tempfile.NamedTemporaryFile('w', dir=path.parent, delete=False) as stream:
json.dump(value,stream,sort_keys=True,separators=(',',':'));stream.write('\n');name=stream.name json.dump(value, stream, sort_keys=True, separators=(',', ':'))
stream.write('\n')
name = stream.name
os.chmod(name, 0o644) os.chmod(name, 0o644)
os.replace(name, path) os.replace(name, path)
def register_example(manifest, source, solo, slot, pitch_convention): def register_example(manifest, source, solo, slot, pitch_convention):
"""Copy and validate a new pair before atomically publishing its manifest.""" """Copy and validate a new pair before atomically publishing its manifest."""
import shutil manifest = pathlib.Path(manifest).resolve()
manifest=Path(manifest).resolve();root=manifest.parent root = manifest.parent
sources=root.parent/'songs';root.mkdir(parents=True,exist_ok=True);sources.mkdir(parents=True,exist_ok=True) sources = root.parent / 'songs'
root.mkdir(parents=True, exist_ok=True)
sources.mkdir(parents=True, exist_ok=True)
value = json.loads(manifest.read_text()) if manifest.exists() else {'version': 1, 'provenance': 'User-authored paired song/solo training corpus.', 'examples': []} value = json.loads(manifest.read_text()) if manifest.exists() else {'version': 1, 'provenance': 'User-authored paired song/solo training corpus.', 'examples': []}
source,solo=Path(source).resolve(),Path(solo).resolve() source, solo = pathlib.Path(source).resolve(), pathlib.Path(solo).resolve()
destinations=((source,sources/source.name),(solo,root/solo.name));created=[];temporary=None destinations = ((source, sources / source.name), (solo, root / solo.name))
created = []
temporary = None
try: try:
for original, destination in destinations: for original, destination in destinations:
if destination.exists(): if destination.exists():
if original.read_bytes()!=destination.read_bytes():raise ValueError(str(destination)+': already exists with different contents; no overwrite performed') if original.read_bytes() != destination.read_bytes():
else:shutil.copyfile(original,destination);created.append(destination) raise ValueError(str(destination) + ': already exists with different contents; no overwrite performed')
else:
shutil.copyfile(original, destination)
created.append(destination)
entry = {'id': source.stem + '/solo' + str(slot), 'source': '../songs/' + source.name, 'solo': solo.name, entry = {'id': source.stem + '/solo' + str(slot), 'source': '../songs/' + source.name, 'solo': solo.name,
'slot': slot, 'pitch_convention': pitch_convention} 'slot': slot, 'pitch_convention': pitch_convention}
if any(e['id']==entry['id'] for e in value['examples']):raise ValueError('Example already registered: '+entry['id']) if any(e['id'] == entry['id'] for e in value['examples']):
raise ValueError('Example already registered: ' + entry['id'])
value['examples'].append(entry) value['examples'].append(entry)
with tempfile.NamedTemporaryFile('w', dir=root, suffix='.json', delete=False) as stream: with tempfile.NamedTemporaryFile('w', dir=root, suffix='.json', delete=False) as stream:
json.dump(value,stream);temporary=Path(stream.name) json.dump(value, stream)
temporary = pathlib.Path(stream.name)
load_examples(temporary) load_examples(temporary)
atomic_json(manifest, value) atomic_json(manifest, value)
except BaseException: except BaseException:
for p in created:p.unlink() for p in created:
p.unlink()
raise raise
finally: finally:
if temporary:temporary.unlink(missing_ok=True) if temporary:
temporary.unlink(missing_ok=True)
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--manifest',type=Path,default=Path('data/training/manifest.json')) parser.add_argument('--manifest', type=pathlib.Path, default=pathlib.Path('data/training/manifest.json'))
parser.add_argument('--output',type=Path,default=Path('data/models/solo.json')) parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('data/models/solo.json'))
parser.add_argument('--report',type=Path,default=Path('build/training/report.json')) parser.add_argument('--report', type=pathlib.Path, default=pathlib.Path('build/training/report.json'))
parser.add_argument('--leave-one-song-out', action='store_true') parser.add_argument('--leave-one-song-out', action='store_true')
parser.add_argument('--add-source',type=Path,help='Register a source song (copied to data/songs if needed).') parser.add_argument('--add-source', type=pathlib.Path, help='Register a source song (copied to data/songs if needed).')
parser.add_argument('--add-solo',type=Path,help='Register its authored solo (copied unchanged to data/training).') parser.add_argument('--add-solo', type=pathlib.Path, help='Register its authored solo (copied unchanged to data/training).')
parser.add_argument('--slot', type=int, choices=(1, 2), help='Arrangement mapping only: 1 acoustic, 2 electric; learning is pooled.') parser.add_argument('--slot', type=int, choices=(1, 2), help='Arrangement mapping only: 1 acoustic, 2 electric; learning is pooled.')
parser.add_argument('--pitch-convention', choices=('guitar-written', 'encoded'), default='guitar-written') parser.add_argument('--pitch-convention', choices=('guitar-written', 'encoded'), default='guitar-written')
args=parser.parse_args();began=time.monotonic() args = parser.parse_args()
began = time.monotonic()
if any((args.add_source, args.add_solo, args.slot)): if any((args.add_source, args.add_solo, args.slot)):
if not all((args.add_source,args.add_solo,args.slot)):parser.error('--add-source, --add-solo and --slot must be supplied together') if not all((args.add_source, args.add_solo, args.slot)):
parser.error('--add-source, --add-solo and --slot must be supplied together')
register_example(args.manifest, args.add_source, args.add_solo, args.slot, args.pitch_convention) register_example(args.manifest, args.add_source, args.add_solo, args.slot, args.pitch_convention)
examples, provenance = load_examples(args.manifest) examples, provenance = load_examples(args.manifest)
value=learned.fit(examples,provenance);predictor=learned.Model(value) value = generator.solo.learned.fit(examples, provenance)
report={'version':learned.VERSION,'training':provenance,'model_sha256':vocabulary.fingerprint(value), predictor = generator.solo.learned.Model(value)
report = {'version': generator.solo.learned.VERSION, 'training': provenance, 'model_sha256': generator.vocabulary.fingerprint(value),
'contexts': len(value['contexts']), 'in_sample_event_nll': {e['id']: likelihood(predictor, e) for e in examples}, 'contexts': len(value['contexts']), 'in_sample_event_nll': {e['id']: likelihood(predictor, e) for e in examples},
'harmonic_validation': {e['id']: e['harmonic_validation'] for e in examples}, 'harmonic_validation': {e['id']: e['harmonic_validation'] for e in examples},
'limitations': 'A small corpus does not establish generalization. Likelihood is teacher-forced, not free generation or musical preference.'} 'limitations': 'A small corpus does not establish generalization. Likelihood is teacher-forced, not free generation or musical preference.'}
@@ -146,11 +190,14 @@ def main():
report['held_out_event_nll'] = {} report['held_out_event_nll'] = {}
for song in sorted({e['song'] for e in examples}): for song in sorted({e['song'] for e in examples}):
remaining = [e for e in examples if e['song'] != song] remaining = [e for e in examples if e['song'] != song]
if not remaining:continue if not remaining:
fitted=learned.Model(learned.fit(remaining,{'evaluation_only':True})) continue
fitted = generator.solo.learned.Model(generator.solo.learned.fit(remaining, {'evaluation_only': True}))
report['held_out_event_nll'].update({e['id']: likelihood(fitted, e) for e in examples if e['song'] == song}) report['held_out_event_nll'].update({e['id']: likelihood(fitted, e) for e in examples if e['song'] == song})
atomic_json(args.output,value);report['training_seconds']=time.monotonic()-began;atomic_json(args.report,report) atomic_json(args.output, value)
report['training_seconds'] = time.monotonic() - began
atomic_json(args.report, report)
print(f'Trained {len(examples)} solos: {len(value["contexts"])} contexts in {report["training_seconds"]:.3f}s; {args.output}') print(f'Trained {len(examples)} solos: {len(value["contexts"])} contexts in {report["training_seconds"]:.3f}s; {args.output}')
if __name__ == '__main__':
if __name__=='__main__':main() main()
+108 -67
View File
@@ -1,152 +1,193 @@
"""Literal authored-score reader for training, registered repertoire and comparison.""" """Literal authored-score reader for training, registered repertoire and comparison."""
from __future__ import annotations from __future__ import annotations
import hashlib, json import hashlib, json
from pathlib import Path import pathlib
from fractions import Fraction as F import fractions
import xml.etree.ElementTree as ET import xml.etree.ElementTree
import generator.fretboard
def read(path, role, form_start=None, pitch_convention='encoded'): def read(path, role, form_start=None, pitch_convention='encoded'):
if pitch_convention not in ('encoded', 'guitar-written'): if pitch_convention not in ('encoded', 'guitar-written'):
raise ValueError('Unknown reference pitch convention: ' + pitch_convention) raise ValueError('Unknown reference pitch convention: ' + pitch_convention)
raw=Path(path).read_bytes();tree=ET.fromstring(raw) raw = pathlib.Path(path).read_bytes()
result={'path':str(Path(path).resolve()),'sha256':hashlib.sha256(raw).hexdigest(),'validation_role':role, tree = xml.etree.ElementTree.fromstring(raw)
result = {'path': str(pathlib.Path(path).resolve()), 'sha256': hashlib.sha256(raw).hexdigest(), 'validation_role': role,
'pitch_convention': pitch_convention, 'part_names': [n.text for n in tree.findall('./part-list/score-part/part-name')], 'pitch_convention': pitch_convention, 'part_names': [n.text for n in tree.findall('./part-list/score-part/part-name')],
'measures': [], 'notes': [], 'harmonies': [], 'annotations': [], 'discrepancies': []} 'measures': [], 'notes': [], 'harmonies': [], 'annotations': [], 'discrepancies': []}
divisions=F(1);absolute=F();nominal=F(4);pending={};active=None;transpose=F() divisions = fractions.Fraction(1)
absolute = fractions.Fraction()
nominal = fractions.Fraction(4)
pending = {}
active = None
transpose = fractions.Fraction()
for index, m in enumerate(tree.findall('./part/measure')): for index, m in enumerate(tree.findall('./part/measure')):
cursor=F();extent=F();last_start=F();last_duration=F() cursor = fractions.Fraction()
if (value:=m.findtext('attributes/divisions')) is not None:divisions=F(value) extent = fractions.Fraction()
last_start = fractions.Fraction()
last_duration = fractions.Fraction()
if (value := m.findtext('attributes/divisions')) is not None:
divisions = fractions.Fraction(value)
meter = m.find('attributes/time') meter = m.find('attributes/time')
if meter is not None and meter.findtext('beats'): if meter is not None and meter.findtext('beats'):
nominal=sum((F(v) for v in meter.findtext('beats').split('+')),F())*4/F(meter.findtext('beat-type')) nominal = sum((fractions.Fraction(v) for v in meter.findtext('beats').split('+')), fractions.Fraction()) * 4 / fractions.Fraction(meter.findtext('beat-type'))
for item in m: for item in m:
offset=F(item.findtext('offset','0'))/divisions offset = fractions.Fraction(item.findtext('offset', '0')) / divisions
if item.tag == 'attributes' and item.find('transpose') is not None: if item.tag == 'attributes' and item.find('transpose') is not None:
transpose=F(item.findtext('transpose/chromatic','0'))+12*F(item.findtext('transpose/octave-change','0')) transpose = fractions.Fraction(item.findtext('transpose/chromatic', '0')) + 12 * fractions.Fraction(item.findtext('transpose/octave-change', '0'))
elif item.tag=='backup':cursor-=F(item.findtext('duration'))/divisions elif item.tag == 'backup':
elif item.tag=='forward':cursor+=F(item.findtext('duration'))/divisions;extent=max(extent,cursor) cursor -= fractions.Fraction(item.findtext('duration')) / divisions
elif item.tag == 'forward':
cursor += fractions.Fraction(item.findtext('duration')) / divisions
extent = max(extent, cursor)
elif item.tag == 'harmony': elif item.tag == 'harmony':
active = {'time': str(absolute + cursor + offset), 'measure': index + 1, 'offset': str(cursor + offset), active = {'time': str(absolute + cursor + offset), 'measure': index + 1, 'offset': str(cursor + offset),
'root': item.findtext('root/root-step'), 'alter': item.findtext('root/root-alter', '0'), 'root': item.findtext('root/root-step'), 'alter': item.findtext('root/root-alter', '0'),
'kind': item.findtext('kind'), 'display': item.find('kind').get('text') if item.find('kind') is not None else None, 'kind': item.findtext('kind'), 'display': item.find('kind').get('text') if item.find('kind') is not None else None,
'bass':ET.tostring(item.find('bass'),encoding='unicode') if item.find('bass') is not None else None, 'bass': xml.etree.ElementTree.tostring(item.find('bass'), encoding='unicode') if item.find('bass') is not None else None,
'degrees': [{'value': d.findtext('degree-value'), 'alter': d.findtext('degree-alter'), 'type': d.findtext('degree-type')} for d in item.findall('degree')]} 'degrees': [{'value': d.findtext('degree-value'), 'alter': d.findtext('degree-alter'), 'type': d.findtext('degree-type')} for d in item.findall('degree')]}
result['harmonies'].append(active) result['harmonies'].append(active)
elif item.tag == 'direction': elif item.tag == 'direction':
for words in item.findall('direction-type/words'): for words in item.findall('direction-type/words'):
result['annotations'].append({'time': str(absolute + cursor + offset), 'measure': index + 1, 'text': ''.join(words.itertext()), 'interpretation': 'descriptive; no production permission'}) result['annotations'].append({'time': str(absolute + cursor + offset), 'measure': index + 1, 'text': ''.join(words.itertext()), 'interpretation': 'descriptive; no production permission'})
elif item.tag == 'note': elif item.tag == 'note':
duration=F(item.findtext('duration','0'))/divisions duration = fractions.Fraction(item.findtext('duration', '0')) / divisions
start = last_start if item.find('chord') is not None else cursor start = last_start if item.find('chord') is not None else cursor
end=start+duration;voice=item.findtext('voice','1');p=item.find('pitch') end = start + duration
voice = item.findtext('voice', '1')
p = item.find('pitch')
encoded = {'step': p.findtext('step'), 'alter': p.findtext('alter', '0'), 'octave': int(p.findtext('octave'))} if p is not None else None encoded = {'step': p.findtext('step'), 'alter': p.findtext('alter', '0'), 'octave': int(p.findtext('octave'))} if p is not None else None
kinds = {t.get('type') for t in item.findall('tie')} | {t.get('type') for t in item.findall('notations/tied')} kinds = {t.get('type') for t in item.findall('tie')} | {t.get('type') for t in item.findall('notations/tied')}
event = {'start': str(absolute + start), 'end': str(absolute + end), 'measure': index + 1, 'offset': str(start), 'pitch': encoded, 'rest': item.find('rest') is not None, event = {'start': str(absolute + start), 'end': str(absolute + end), 'measure': index + 1, 'offset': str(start), 'pitch': encoded, 'rest': item.find('rest') is not None,
'written_type': item.findtext('type'), 'dots': len(item.findall('dot')), 'voice': voice, 'ties': sorted(kinds), 'written_type': item.findtext('type'), 'dots': len(item.findall('dot')), 'voice': voice, 'ties': sorted(kinds),
'sounding_transposition': str(-12 if pitch_convention == 'guitar-written' else transpose), 'sounding_transposition': str(-12 if pitch_convention == 'guitar-written' else transpose),
'tablature': {'string': int(item.findtext('notations/technical/string')), 'fret': int(item.findtext('notations/technical/fret'))} if item.find('notations/technical/string') is not None and item.find('notations/technical/fret') is not None else None, 'tablature': {'string': int(item.findtext('notations/technical/string')), 'fret': int(item.findtext('notations/technical/fret'))} if item.find('notations/technical/string') is not None and item.find('notations/technical/fret') is not None else None,
'time_modification':ET.tostring(item.find('time-modification'),encoding='unicode') if item.find('time-modification') is not None else None, 'time_modification': xml.etree.ElementTree.tostring(item.find('time-modification'), encoding='unicode') if item.find('time-modification') is not None else None,
'tuplets': [dict(t.attrib) for t in item.findall('notations/tuplet')], 'grace': item.find('grace') is not None, 'new_attack': 'stop' not in kinds and p is not None} 'tuplets': [dict(t.attrib) for t in item.findall('notations/tuplet')], 'grace': item.find('grace') is not None, 'new_attack': 'stop' not in kinds and p is not None}
key = (voice, json.dumps(encoded, sort_keys=True)) key = (voice, json.dumps(encoded, sort_keys=True))
if 'stop' in kinds: if 'stop' in kinds:
old = pending.get(key) old = pending.get(key)
if old is None or result['notes'][old]['end'] != event['start']: if old is None or result['notes'][old]['end'] != event['start']:
result['discrepancies'].append({'measure': index + 1, 'issue': 'unmatched/noncontiguous tie; retained literally'}) result['discrepancies'].append({'measure': index + 1, 'issue': 'unmatched/noncontiguous tie; retained literally'})
else:event['continuation_of']=old else:
if 'start' not in kinds:pending.pop(key,None) event['continuation_of'] = old
if 'start' in kinds:pending[key]=len(result['notes']) if 'start' not in kinds:
pending.pop(key, None)
if 'start' in kinds:
pending[key] = len(result['notes'])
result['notes'].append(event) result['notes'].append(event)
for container in ('notations/technical/fingering', 'notations/technical/other-technical', 'notations/other-notation'): for container in ('notations/technical/fingering', 'notations/technical/other-technical', 'notations/other-notation'):
for label in item.findall(container): for label in item.findall(container):
text = ''.join(label.itertext()).strip() text = ''.join(label.itertext()).strip()
if text:result['annotations'].append({'time':str(absolute+start),'measure':index+1,'text':text, if text:
result['annotations'].append({'time': str(absolute + start), 'measure': index + 1, 'text': text,
'container': container, 'interpretation': 'Original note annotation; container does not establish musical permission or guitar fingering.'}) 'container': container, 'interpretation': 'Original note annotation; container does not establish musical permission or guitar fingering.'})
if item.find('chord') is None and item.find('grace') is None:cursor=end if item.find('chord') is None and item.find('grace') is None:
last_start,last_duration=start,duration;extent=max(extent,end,cursor) cursor = end
if extent!=nominal and m.get('implicit')!='yes':result['discrepancies'].append({'measure':index+1,'issue':f'encoded span {extent} differs from nominal {nominal}; no padding applied'}) last_start, last_duration = start, duration
result['measures'].append({'number':m.get('number'),'start':str(absolute),'duration':str(extent),'implicit':m.get('implicit'),'initial_harmony_known':any(F(h['time'])<=absolute for h in result['harmonies'])}) extent = max(extent, end, cursor)
if extent != nominal and m.get('implicit') != 'yes':
result['discrepancies'].append({'measure': index + 1, 'issue': f'encoded span {extent} differs from nominal {nominal}; no padding applied'})
result['measures'].append({'number': m.get('number'), 'start': str(absolute), 'duration': str(extent), 'implicit': m.get('implicit'), 'initial_harmony_known': any(fractions.Fraction(h['time']) <= absolute for h in result['harmonies'])})
absolute += extent absolute += extent
if pending:result['discrepancies'].append({'issue':'unterminated ties retained'}) if pending:
result['harmonies'].sort(key=lambda h:F(h['time'])) result['discrepancies'].append({'issue': 'unterminated ties retained'})
result['harmonies'].sort(key=lambda h: fractions.Fraction(h['time']))
# Do not run the strict score importer over these references: annotations, # Do not run the strict score importer over these references: annotations,
# unusual timing and high encoded pitches are evidence, not repair requests. # unusual timing and high encoded pitches are evidence, not repair requests.
from generator import harmony, model, fretboard
for event in result['notes']: for event in result['notes']:
if not event['pitch']:continue if not event['pitch']:
p=event['pitch'];number=12*(p['octave']+1)+model.NATURAL[p['step'].lower()]+F(p['alter']) continue
p = event['pitch']
number = 12 * (p['octave'] + 1) + generator.model.NATURAL[p['step'].lower()] + fractions.Fraction(p['alter'])
event['encoded_midi'] = str(number) event['encoded_midi'] = str(number)
event['sounding_midi']=str(number+F(event['sounding_transposition'])) event['sounding_midi'] = str(number + fractions.Fraction(event['sounding_transposition']))
event['legal_positions_at_encoded_pitch']=len(fretboard.single_positions_for_pitch(number)) event['legal_positions_at_encoded_pitch'] = len(generator.fretboard.single_positions_for_pitch(number))
event['legal_positions_at_sounding_pitch']=len(fretboard.single_positions_for_pitch(F(event['sounding_midi']))) event['legal_positions_at_sounding_pitch'] = len(generator.fretboard.single_positions_for_pitch(fractions.Fraction(event['sounding_midi'])))
event['harmonic_spans'] = [] event['harmonic_spans'] = []
start,end=F(event['start']),F(event['end']) start, end = fractions.Fraction(event['start']), fractions.Fraction(event['end'])
changes=[F(h['time']) for h in result['harmonies'] if start<F(h['time'])<end] changes = [fractions.Fraction(h['time']) for h in result['harmonies'] if start < fractions.Fraction(h['time']) < end]
for a, b in zip([start, *changes], [*changes, end]): for a, b in zip([start, *changes], [*changes, end]):
current=next((h for h in reversed(result['harmonies']) if F(h['time'])<=a),None) current = next((h for h in reversed(result['harmonies']) if fractions.Fraction(h['time']) <= a), None)
info = {'start': str(a), 'end': str(b), 'chord': current, 'chord_degrees': []} info = {'start': str(a), 'end': str(b), 'chord': current, 'chord_degrees': []}
if current and current['root']: if current and current['root']:
try: try:
chord=harmony.parse_symbol(training_chord(current));root=model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']) chord = generator.harmony.parse_symbol(training_chord(current))
info['chord_degrees']=[d for d,alter in harmony.degrees(chord).items() if (root+harmony.degree_semitones(d,alter)-number)%12==0] root = generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter'])
info['chord_degrees'] = [d for d, alter in generator.harmony.degrees(chord).items() if (root + generator.harmony.degree_semitones(d, alter) - number) % 12 == 0]
info['root_relative_semitones'] = str((number - root) % 12) info['root_relative_semitones'] = str((number - root) % 12)
info['spelled_simple_degree']=(model.LETTERS.index(p['step'].lower())-model.LETTERS.index(chord['root']['letter']))%7+1 info['spelled_simple_degree'] = (generator.model.LETTERS.index(p['step'].lower()) - generator.model.LETTERS.index(chord['root']['letter'])) % 7 + 1
except (ValueError, KeyError, AttributeError) as error: except (ValueError, KeyError, AttributeError) as error:
info['uncertainty'] = str(error) info['uncertainty'] = str(error)
else:info['uncertainty']='No written/carried harmony; none invented.' else:
info['uncertainty'] = 'No written/carried harmony; none invented.'
event['harmonic_spans'].append(info) event['harmonic_spans'].append(info)
sounded=[];tie_roots={} sounded = []
tie_roots = {}
for i, n in enumerate(result['notes']): for i, n in enumerate(result['notes']):
if not n['pitch']:continue if not n['pitch']:
continue
previous = n.get('continuation_of') previous = n.get('continuation_of')
if previous is not None and previous in tie_roots: if previous is not None and previous in tie_roots:
root=tie_roots[previous];sounded[root]['end']=n['end'];sounded[root]['notation_events'].append(i);tie_roots[i]=root root = tie_roots[previous]
sounded[root]['end'] = n['end']
sounded[root]['notation_events'].append(i)
tie_roots[i] = root
else: else:
tie_roots[i]=len(sounded);sounded.append({'start':n['start'],'end':n['end'],'pitch':n['pitch'], tie_roots[i] = len(sounded)
sounded.append({'start': n['start'], 'end': n['end'], 'pitch': n['pitch'],
'encoded_midi': n['encoded_midi'], 'sounding_midi': n['sounding_midi'], 'measure': n['measure'], 'notation_events': [i]}) 'encoded_midi': n['encoded_midi'], 'sounding_midi': n['sounding_midi'], 'measure': n['measure'], 'notation_events': [i]})
result['sounding_events'] = sounded result['sounding_events'] = sounded
first = form_start or next((h['measure'] for h in result['harmonies']), None) first = form_start or next((h['measure'] for h in result['harmonies']), None)
offset=F(result['measures'][first-1]['start']) if first else None offset = fractions.Fraction(result['measures'][first - 1]['start']) if first else None
result['alignment'] = {'form_start_measure': first, 'encoded_offset': str(offset) if offset is not None else None, 'basis': 'explicit override' if form_start else 'first written harmony; review required', 'preceding_material': 'retained, without invented harmony'} result['alignment'] = {'form_start_measure': first, 'encoded_offset': str(offset) if offset is not None else None, 'basis': 'explicit override' if form_start else 'first written harmony; review required', 'preceding_material': 'retained, without invented harmony'}
for label in result['annotations']: for label in result['annotations']:
t=F(label['time']); current=[h for h in result['harmonies'] if F(h['time'])<=t] t = fractions.Fraction(label['time'])
future=next((h for h in result['harmonies'] if F(h['time'])>t),None) current = [h for h in result['harmonies'] if fractions.Fraction(h['time']) <= t]
future = next((h for h in result['harmonies'] if fractions.Fraction(h['time']) > t), None)
label['current_harmony'] = current[-1] if current else None label['current_harmony'] = current[-1] if current else None
label['following_harmony'] = future label['following_harmony'] = future
label['events']=[n for n in result['notes'] if F(n['start'])<=t<F(n['end']) or t<=F(n['start'])<t+1] label['events'] = [n for n in result['notes'] if fractions.Fraction(n['start']) <= t < fractions.Fraction(n['end']) or t <= fractions.Fraction(n['start']) < t + 1]
label['uncertainty'] = 'Label meaning must be assessed from these events and harmonic time; label preserved verbatim.' label['uncertainty'] = 'Label meaning must be assessed from these events and harmonic time; label preserved verbatim.'
expected={'b9':F(1),'#9':F(3),'b13':F(8),'#11':F(6),'6':F(9)}.get(label['text'].strip()) expected = {'b9': fractions.Fraction(1), '#9': fractions.Fraction(3), 'b13': fractions.Fraction(8), '#11': fractions.Fraction(6), '6': fractions.Fraction(9)}.get(label['text'].strip())
if expected is not None: if expected is not None:
relationships = [s.get('root_relative_semitones') for n in label['events'] for s in n.get('harmonic_spans', [])] relationships = [s.get('root_relative_semitones') for n in label['events'] for s in n.get('harmonic_spans', [])]
label['literal_interval_present'] = str(expected) in relationships label['literal_interval_present'] = str(expected) in relationships
if not label['literal_interval_present']:label['uncertainty']='No matching interval in the immediate annotated context; label retained, intended scope uncertain.' if not label['literal_interval_present']:
label['uncertainty'] = 'No matching interval in the immediate annotated context; label retained, intended scope uncertain.'
# Lead in and Bluesy stay contextual descriptions, never enclosure permits. # Lead in and Bluesy stay contextual descriptions, never enclosure permits.
return result return result
from generator import harmony,model import generator.harmony
import generator.model
def training_chord(h): def training_chord(h):
result={'root':{'letter':h['root'].lower(),'alter':model.rational(F(h['alter']))},'kind':h['kind'],'degrees':[]} result = {'root': {'letter': h['root'].lower(), 'alter': generator.model.rational(fractions.Fraction(h['alter']))}, 'kind': h['kind'], 'degrees': []}
for d in h['degrees']: for d in h['degrees']:
degree=int(d['value']);alter=F(d['alter']) degree = int(d['value'])
if d['type']=='alter':alter+=harmony.KINDS.get(result['kind'],{}).get(degree,0) alter = fractions.Fraction(d['alter'])
elif d['type']=='add' and (degree-1)%7==6:alter-=1 if d['type'] == 'alter':
result['degrees'].append({'value':degree,'alter':model.rational(alter),'type':d['type']}) alter += generator.harmony.KINDS.get(result['kind'], {}).get(degree, 0)
elif d['type'] == 'add' and (degree - 1) % 7 == 6:
alter -= 1
result['degrees'].append({'value': degree, 'alter': generator.model.rational(alter), 'type': d['type']})
if h['bass']: if h['bass']:
import xml.etree.ElementTree as ET bass = xml.etree.ElementTree.fromstring(h['bass'])
bass=ET.fromstring(h['bass']);result['bass']={'letter':bass.findtext('bass-step').lower(),'alter':model.rational(F(bass.findtext('bass-alter','0')))} result['bass'] = {'letter': bass.findtext('bass-step').lower(), 'alter': generator.model.rational(fractions.Fraction(bass.findtext('bass-alter', '0')))}
display = h['display'] display = h['display']
if display: if display:
# Soundslice exports an optional chord's suffix with a trailing closing # Soundslice exports an optional chord's suffix with a trailing closing
# parenthesis, but its actual root separately. Evidence stays untouched. # parenthesis, but its actual root separately. Evidence stays untouched.
if display.endswith(')') and '(' not in display:display=display[:-1] if display.endswith(')') and '(' not in display:
harmony.normalize_text(result,display) display = display[:-1]
return harmony.symbol(result).strip('()') generator.harmony.normalize_text(result, display)
return generator.harmony.symbol(result).strip('()')
def validate_harmony(document, evidence, identity): def validate_harmony(document, evidence, identity):
source_chords=[];cursor=F() source_chords = []
cursor = fractions.Fraction()
for measure in document['staves'][0]['measures']: for measure in document['staves'][0]['measures']:
source_chords.extend((cursor+model.fraction(h['onset']),harmony.symbol(h).strip('()')) for h in measure['harmonies']) source_chords.extend((cursor + generator.model.fraction(h['onset']), generator.harmony.symbol(h).strip('()')) for h in measure['harmonies'])
cursor+=model.fraction(measure['duration']) cursor += generator.model.fraction(measure['duration'])
authored=[(F(h['time']),training_chord(h)) for h in evidence['harmonies']] authored = [(fractions.Fraction(h['time']), training_chord(h)) for h in evidence['harmonies']]
if authored!=source_chords:raise ValueError(identity+': authored harmony differs from authoritative source') if authored != source_chords:
raise ValueError(identity + ': authored harmony differs from authoritative source')
+4 -4
View File
@@ -6,17 +6,17 @@ import dataclasses
import functools import functools
import hashlib import hashlib
import json import json
from pathlib import Path import pathlib
from fractions import Fraction as F import fractions
ROOT = Path(__file__).resolve().parents[1] / 'data' / 'composition' ROOT = pathlib.Path(__file__).resolve().parents[1] / 'data' / 'composition'
ACTIVE = contextvars.ContextVar('composition_vocabulary', default=None) ACTIVE = contextvars.ContextVar('composition_vocabulary', default=None)
def load(root=ROOT): def load(root=ROOT):
# Content, not just the directory, keys memoization. Long-lived individual # Content, not just the directory, keys memoization. Long-lived individual
# role builds must observe edited production data without restarting Python. # role builds must observe edited production data without restarting Python.
names = ('roles', 'harmony', 'lead_ins', 'accompaniment') names = ('roles', 'harmony', 'lead_ins', 'accompaniment')
return _load(tuple((name, (Path(root)/(name+'.json')).read_text()) for name in names)) return _load(tuple((name, (pathlib.Path(root) / (name + '.json')).read_text()) for name in names))
@functools.lru_cache(maxsize=16) @functools.lru_cache(maxsize=16)
def _load(contents): def _load(contents):
+7 -1
View File
@@ -42,13 +42,19 @@ enum class sprite : std::size_t {
MAIN_MENU, MAIN_MENU,
PERFORMANCE, PERFORMANCE,
SETTINGS, SETTINGS,
PLAYBACK, BACKING,
METRONOME, METRONOME,
TEMPO, TEMPO,
BACK, BACK,
ON, ON,
OFF, OFF,
BPM, BPM,
MANUAL,
PREPARING,
COUNT_IN,
PLAYING,
SUSTAIN,
HOLD_TO_EXIT,
DIGIT_0, DIGIT_0,
DIGIT_1, DIGIT_1,
DIGIT_2, DIGIT_2,
+5 -2
View File
@@ -20,7 +20,10 @@ struct remote_display_state {
std::char_t part[48]{}; std::char_t part[48]{};
std::char_t mode[24]{}; std::char_t mode[24]{};
std::char_t focus[32]{}; std::char_t focus[32]{};
std::bool_t playback{}; std::bool_t backing{};
std::bool_t backing_available{true};
std::bool_t error{};
std::array<std::char_t, 16> phase{};
std::bool_t metronome{}; std::bool_t metronome{};
std::bool_t editing{}; std::bool_t editing{};
std::uint32_t bpm{120U}; std::uint32_t bpm{120U};
@@ -28,7 +31,7 @@ struct remote_display_state {
std::uint32_t page{}; std::uint32_t page{};
std::uint32_t pages{}; std::uint32_t pages{};
auto operator==(remote_display_state const &other) const -> std::bool_t { auto operator==(remote_display_state const &other) const -> std::bool_t {
return active == other.active && std::string_view{title} == other.title && std::string_view{part} == other.part && std::string_view{mode} == other.mode && std::string_view{focus} == other.focus && playback == other.playback && metronome == other.metronome && editing == other.editing && bpm == other.bpm && title_image == other.title_image && page == other.page && pages == other.pages; return backing_available == other.backing_available && error == other.error && phase == other.phase && active == other.active && std::string_view{title} == other.title && std::string_view{part} == other.part && std::string_view{mode} == other.mode && std::string_view{focus} == other.focus && backing == other.backing && metronome == other.metronome && editing == other.editing && bpm == other.bpm && title_image == other.title_image && page == other.page && pages == other.pages;
} }
}; };
+5 -5
View File
@@ -16,6 +16,7 @@
#include <vamp_zero/config.hpp> #include <vamp_zero/config.hpp>
#include <vamp_zero/types.hpp> #include <vamp_zero/types.hpp>
#include <vamp_zero/score_loader.hpp> #include <vamp_zero/score_loader.hpp>
#include <vamp_zero/performance.hpp>
namespace vamp_zero { namespace vamp_zero {
@@ -28,19 +29,18 @@ public:
~audio_controller(); ~audio_controller();
auto sync(vamp_zero::app_state const &state) -> void; auto sync(vamp_zero::app_state const &state) -> void;
auto tick(vamp_zero::app_state const &state, vamp_zero::score_load_state const &loaded, std::filesystem::path const &assets) -> std::optional<std::expected<void, modulus::error>>; auto tick(vamp_zero::app_state const &state, vamp_zero::score_load_state const &loaded, std::filesystem::path const &assets) -> std::optional<std::expected<void, modulus::error>>;
auto position(vamp_zero::score_document const &score, std::uint32_t bpm) const -> std::optional<vamp_zero::performance_position>;
private: private:
auto make_metronome_config(vamp_zero::app_state const &state) const -> sonus::metronome_config;
vamp_zero::audio_config config_{}; vamp_zero::audio_config config_{};
sonus::engine engine_; sonus::engine engine_;
mutable std::mutex mutex_{}; mutable std::mutex mutex_{};
std::bool_t metronome_running_{false};
std::uint32_t last_metronome_bpm_{};
boost::asio::thread_pool worker_{1}; boost::asio::thread_pool worker_{1};
std::future<std::expected<sonus::prepared_sequence, modulus::error>> preparation_{}; std::future<std::expected<sonus::prepared_transport, modulus::error>> preparation_{};
std::uint64_t preparing_session_{}; std::uint64_t preparing_session_{};
std::shared_ptr<vamp_zero::catalog const> preparing_catalog_{};
std::optional<std::uint64_t> attempted_session_{}; std::optional<std::uint64_t> attempted_session_{};
std::shared_ptr<vamp_zero::catalog const> catalog_{}; std::shared_ptr<vamp_zero::catalog const> catalog_{};
sonus::playback_session playback_{}; sonus::transport_session playback_{};
std::bool_t reported_{true}; std::bool_t reported_{true};
}; };
+2 -6
View File
@@ -46,13 +46,9 @@ struct control_config {
struct audio_config { struct audio_config {
std::bool_t enabled{true}; std::bool_t enabled{true};
std::bool_t playback_enabled{false}; std::bool_t backing_enabled{false};
std::bool_t metronome_enabled{false}; std::bool_t metronome_enabled{false};
std::uint32_t metronome_bpm{120}; std::uint32_t tempo_bpm{120};
std::uint32_t metronome_min_bpm{40};
std::uint32_t metronome_max_bpm{240};
std::uint32_t metronome_step{5};
std::uint32_t beats_per_bar{4};
std::float32_t master_volume{0.70F}; std::float32_t master_volume{0.70F};
std::float32_t metronome_volume{0.45F}; std::float32_t metronome_volume{0.45F};
}; };
+1
View File
@@ -25,6 +25,7 @@ public:
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::catalog const> catalog = {}) -> void; auto finish_playback(std::uint64_t session, std::string error = {}, std::shared_ptr<vamp_zero::catalog const> catalog = {}) -> void;
auto update_performance(std::uint64_t session, std::size_t page, std::string phase, 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;
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <expected>
#include <filesystem>
#include <string_view>
#include <boost/json.hpp>
#include <sonus.hpp>
#include <vamp_zero/types.hpp>
namespace vamp_zero {
auto parse_beat(boost::json::value const &value) -> std::expected<vamp_zero::beat_position, modulus::error>;
auto frame_at(vamp_zero::beat_position beat, std::uint32_t bpm) -> std::uint64_t;
auto available_tempos(vamp_zero::app_state const &state) -> std::vector<std::uint32_t>;
auto parse_audio_assets(std::string_view json) -> std::expected<std::vector<vamp_zero::audio_asset>, modulus::error>;
auto validate_audio_assets(vamp_zero::catalog const &catalog, std::filesystem::path const &assets) -> std::expected<void, modulus::error>;
auto prepare_performance(vamp_zero::score_document const &score, vamp_zero::app_state const &state, std::filesystem::path const &assets, std::float32_t click_volume) -> std::expected<sonus::prepared_transport, modulus::error>;
struct performance_position {
std::size_t page{};
std::string phase{};
};
auto performance_at(vamp_zero::score_document const &score, std::uint64_t frames, std::uint32_t bpm) -> vamp_zero::performance_position;
auto automatic(vamp_zero::audio_state const &audio) -> bool;
}
-18
View File
@@ -1,18 +0,0 @@
#pragma once
#include <cstdint>
#include <expected>
#include <filesystem>
#include <modulus.hpp>
#include <sonus.hpp>
#include <vamp_zero/types.hpp>
namespace vamp_zero {
auto prepare_playback(vamp_zero::score_document const &score, std::filesystem::path const &assets, std::uint32_t bpm) -> std::expected<sonus::sample_sequence, modulus::error>;
auto validate_playback_assets(vamp_zero::score_document const &score, std::filesystem::path const &assets) -> std::expected<void, modulus::error>;
}
+2
View File
@@ -17,6 +17,7 @@ struct score_load_state {
std::shared_ptr<vamp_zero::score_document const> document{}; std::shared_ptr<vamp_zero::score_document const> document{};
std::optional<modulus::error> error{}; std::optional<modulus::error> error{};
std::bool_t loading{}; std::bool_t loading{};
std::bool_t pages_ready{};
}; };
class score_loader { class score_loader {
@@ -31,6 +32,7 @@ public:
auto select(std::shared_ptr<vamp_zero::catalog 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;
auto pages_prepared(std::shared_ptr<vamp_zero::score_document const> const &document, std::expected<void, modulus::error> result) -> void;
private: private:
struct state; struct state;
std::shared_ptr<state> state_; std::shared_ptr<state> state_;
+27 -5
View File
@@ -11,9 +11,18 @@
namespace vamp_zero { namespace vamp_zero {
struct beat_position {
std::uint64_t numerator{};
std::uint64_t denominator{1};
};
struct score_page { struct score_page {
std::string path{}; std::string path{};
std::string sha256{}; std::string sha256{};
std::size_t first_measure{};
std::size_t measure_count{};
vamp_zero::beat_position start_beat{};
vamp_zero::beat_position end_beat{};
}; };
struct score_document { struct score_document {
@@ -28,6 +37,9 @@ struct score_document {
std::string source_json{}; std::string source_json{};
std::size_t measure_count{}; std::size_t measure_count{};
std::vector<vamp_zero::score_page> pages{}; std::vector<vamp_zero::score_page> pages{};
std::string song_id{};
vamp_zero::beat_position duration{};
std::uint32_t count_in_beats{4};
}; };
struct library_part { struct library_part {
@@ -40,6 +52,7 @@ struct library_part {
std::size_t measure_count{}; std::size_t measure_count{};
std::size_t total_pages{}; std::size_t total_pages{};
std::string title_image{}; std::string title_image{};
std::string song_id{};
}; };
struct song_group { struct song_group {
@@ -48,9 +61,20 @@ struct song_group {
std::vector<std::size_t> parts{}; std::vector<std::size_t> parts{};
}; };
struct audio_asset {
std::string path{};
std::string sha256{};
std::string song_id{};
std::uint32_t bpm{};
std::uint32_t sample_rate{};
std::uint32_t channels{};
std::uint64_t frames{};
};
struct catalog { struct catalog {
std::vector<vamp_zero::library_part> parts{}; std::vector<vamp_zero::library_part> parts{};
std::vector<vamp_zero::song_group> groups{}; std::vector<vamp_zero::song_group> groups{};
std::vector<vamp_zero::audio_asset> audio_assets{};
}; };
auto empty_catalog() -> std::shared_ptr<vamp_zero::catalog const> const &; auto empty_catalog() -> std::shared_ptr<vamp_zero::catalog const> const &;
@@ -70,12 +94,10 @@ enum class app_mode {
struct audio_state { struct audio_state {
std::string playback_error{}; std::string playback_error{};
std::bool_t playback_enabled{false}; std::string phase{"Manual"};
std::bool_t backing_enabled{false};
std::bool_t metronome_enabled{false}; std::bool_t metronome_enabled{false};
std::uint32_t metronome_bpm{120}; std::uint32_t tempo_bpm{120};
std::uint32_t metronome_min_bpm{40};
std::uint32_t metronome_max_bpm{240};
std::uint32_t metronome_step{5};
std::bool_t tempo_editing{false}; std::bool_t tempo_editing{false};
}; };
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -6
View File
@@ -37,13 +37,9 @@
}, },
"audio": { "audio": {
"enabled": true, "enabled": true,
"playback_enabled": false, "backing_enabled": false,
"metronome_enabled": false, "metronome_enabled": false,
"metronome_bpm": 120, "tempo_bpm": 120,
"metronome_min_bpm": 40,
"metronome_max_bpm": 240,
"metronome_step": 5,
"beats_per_bar": 4,
"master_volume": 0.7, "master_volume": 0.7,
"metronome_volume": 0.45 "metronome_volume": 0.45
}, },
+9 -2
View File
@@ -71,11 +71,18 @@ auto vamp_pico::parse_remote_display_body(std::string_view body, vamp_pico::remo
return false; return false;
} }
copy_value(value, parsed.focus, sizeof(parsed.focus)); copy_value(value, parsed.focus, sizeof(parsed.focus));
} else if (key == "playback" || key == "metronome" || key == "editing") { } else if (key == "phase") {
if (value.size() >= parsed.phase.size()) {
return false;
}
std::copy(value.begin(), value.end(), parsed.phase.begin());
} else if (key == "backing" || key == "metronome" || key == "editing" || key == "error" || key == "backing_available") {
if (value != "0" && value != "1") { if (value != "0" && value != "1") {
return false; return false;
} }
auto &flag = key == "playback" ? parsed.playback : key == "metronome" ? parsed.metronome auto &flag = key == "backing_available" ? parsed.backing_available : key == "error" ? parsed.error
: key == "backing" ? parsed.backing
: key == "metronome" ? parsed.metronome
: parsed.editing; : parsed.editing;
flag = value == "1"; flag = value == "1";
} else if (key == "bpm") { } else if (key == "bpm") {
+35 -11
View File
@@ -11,8 +11,8 @@ auto blend(std::uint16_t background, vamp_style::color foreground, std::uint8_t
} }
auto label_sprite(std::string_view text) -> vamp_pico::sprite { auto label_sprite(std::string_view text) -> vamp_pico::sprite {
constexpr auto names = std::array<std::string_view, 10>{"Acoustic", "Electric", "Vocal", "Performance", "Settings", "Playback", "Metronome", "Tempo", "Back", "Main Menu"}; constexpr auto names = std::array<std::string_view, 10>{"Acoustic", "Electric", "Vocal", "Performance", "Settings", "Backing", "Metronome", "Tempo", "Back", "Main Menu"};
constexpr auto sprites = std::array{vamp_pico::sprite::ACOUSTIC, vamp_pico::sprite::ELECTRIC, vamp_pico::sprite::VOCAL, vamp_pico::sprite::PERFORMANCE, vamp_pico::sprite::SETTINGS, vamp_pico::sprite::PLAYBACK, vamp_pico::sprite::METRONOME, vamp_pico::sprite::TEMPO, vamp_pico::sprite::BACK, vamp_pico::sprite::MAIN_MENU}; constexpr auto sprites = std::array{vamp_pico::sprite::ACOUSTIC, vamp_pico::sprite::ELECTRIC, vamp_pico::sprite::VOCAL, vamp_pico::sprite::PERFORMANCE, vamp_pico::sprite::SETTINGS, vamp_pico::sprite::BACKING, vamp_pico::sprite::METRONOME, vamp_pico::sprite::TEMPO, vamp_pico::sprite::BACK, vamp_pico::sprite::MAIN_MENU};
for (auto i = std::size_t{}; i < names.size(); ++i) { for (auto i = std::size_t{}; i < names.size(); ++i) {
if (names[i] == text) { if (names[i] == text) {
return sprites[i]; return sprites[i];
@@ -24,7 +24,7 @@ auto label_sprite(std::string_view text) -> vamp_pico::sprite {
} }
auto vamp_pico::render_scanline(std::array<std::uint16_t, vamp_pico::display_width> &row, std::size_t y, vamp_pico::remote_display_state const &state, vamp_pico::link_status link, std::bool_t busy, vamp_pico::title_cache const &title, std::string_view atlas) -> void { auto vamp_pico::render_scanline(std::array<std::uint16_t, vamp_pico::display_width> &row, std::size_t y, vamp_pico::remote_display_state const &state, vamp_pico::link_status link, std::bool_t busy, vamp_pico::title_cache const &title, std::string_view atlas) -> void {
row.fill(vamp_style::rgb565(y < 44U ? vamp_style::panel : vamp_style::background)); row.fill(vamp_style::rgb565(vamp_style::background));
auto fill = [&](std::size_t left, std::size_t right, vamp_style::color color) { auto fill = [&](std::size_t left, std::size_t right, vamp_style::color color) {
std::fill(row.begin() + left, row.begin() + right, vamp_style::rgb565(color)); std::fill(row.begin() + left, row.begin() + right, vamp_style::rgb565(color));
}; };
@@ -40,7 +40,7 @@ auto vamp_pico::render_scanline(std::array<std::uint16_t, vamp_pico::display_wid
auto image = static_sprite(atlas, value); auto image = static_sprite(atlas, value);
overlay(image, (row.size() - image.width) / 2U, top, ink); overlay(image, (row.size() - image.width) / 2U, top, ink);
}; };
auto number = [&](std::string_view text, sprite suffix) { auto number = [&](std::string_view text, sprite suffix, std::size_t top = 192U) {
auto label = suffix == sprite::COUNT ? coverage_image{} : static_sprite(atlas, suffix); auto label = suffix == sprite::COUNT ? coverage_image{} : static_sprite(atlas, suffix);
auto width = text.size() * digit_width + (label.width == 0U ? 0U : label.width + 8U); auto width = text.size() * digit_width + (label.width == 0U ? 0U : label.width + 8U);
if (text.empty() || width > row.size() - 16U) { if (text.empty() || width > row.size() - 16U) {
@@ -49,10 +49,10 @@ auto vamp_pico::render_scanline(std::array<std::uint16_t, vamp_pico::display_wid
auto x = (row.size() - width) / 2U; auto x = (row.size() - width) / 2U;
for (auto character : text) { for (auto character : text) {
auto value = character == '/' ? sprite::SLASH : static_cast<sprite>(static_cast<std::size_t>(sprite::DIGIT_0) + character - '0'); auto value = character == '/' ? sprite::SLASH : static_cast<sprite>(static_cast<std::size_t>(sprite::DIGIT_0) + character - '0');
overlay(static_sprite(atlas, value), x, 192U, vamp_style::ink); overlay(static_sprite(atlas, value), x, top, vamp_style::ink);
x += digit_width; x += digit_width;
} }
overlay(label, x + 8U, 192U, vamp_style::muted); overlay(label, x + 8U, top, vamp_style::muted);
}; };
centered(sprite::VAMP, 8U, vamp_style::ink); centered(sprite::VAMP, 8U, vamp_style::ink);
if (y >= 18U && y < 26U) { if (y >= 18U && y < 26U) {
@@ -66,7 +66,7 @@ auto vamp_pico::render_scanline(std::array<std::uint16_t, vamp_pico::display_wid
auto mode = std::string_view{state.mode}; auto mode = std::string_view{state.mode};
if (has_title || state.active) { if (has_title || state.active) {
if (title.ready()) { if (title.ready()) {
overlay(title.image(), 12U, 60U, vamp_style::ink); overlay(title.image(), 12U, state.active ? 48U : 60U, vamp_style::ink);
} else { } else {
centered(title.failed() || !has_title ? sprite::UNAVAILABLE : sprite::LOADING, 78U, vamp_style::muted); centered(title.failed() || !has_title ? sprite::UNAVAILABLE : sprite::LOADING, 78U, vamp_style::muted);
} }
@@ -79,7 +79,27 @@ auto vamp_pico::render_scanline(std::array<std::uint16_t, vamp_pico::display_wid
fill(16U, 304U, vamp_style::selection); fill(16U, 304U, vamp_style::selection);
fill(16U, 19U, vamp_style::accent); fill(16U, 19U, vamp_style::accent);
} }
centered(label_sprite(state.active ? std::string_view{state.part} : std::string_view{state.focus}), 138U, vamp_style::ink); if (state.active) {
auto phase = std::string_view{state.phase.data()};
auto label = state.error ? sprite::ERROR : phase == "Count-in" ? sprite::COUNT_IN
: phase == "Playing" ? sprite::PLAYING
: phase == "Sustain" ? sprite::SUSTAIN
: phase == "Preparing" ? sprite::PREPARING
: sprite::MANUAL;
overlay(static_sprite(atlas, label), 16U, 112U, state.error ? vamp_style::danger : vamp_style::accent);
auto digits = std::array<char, 4>{};
std::to_chars(digits.data(), digits.data() + 3, state.bpm);
auto x = std::size_t{174};
for (auto ch : std::string_view{digits.data()}) {
overlay(static_sprite(atlas, static_cast<sprite>(static_cast<std::size_t>(sprite::DIGIT_0) + ch - '0')), x, 112U, vamp_style::muted);
x += digit_width;
}
overlay(static_sprite(atlas, sprite::BPM), x + 6U, 112U, vamp_style::muted);
centered(label_sprite(state.part), 142U, vamp_style::ink);
centered(sprite::HOLD_TO_EXIT, 210U, vamp_style::muted);
} else {
centered(label_sprite(state.focus), 138U, vamp_style::ink);
}
if (busy || link != link_status::CONNECTED) { if (busy || link != link_status::CONNECTED) {
centered(busy ? sprite::BUSY : link == link_status::CONNECTING ? sprite::CONNECTING centered(busy ? sprite::BUSY : link == link_status::CONNECTING ? sprite::CONNECTING
: link == link_status::ERROR ? sprite::ERROR : link == link_status::ERROR ? sprite::ERROR
@@ -87,7 +107,7 @@ auto vamp_pico::render_scanline(std::array<std::uint16_t, vamp_pico::display_wid
192U, vamp_style::muted); 192U, vamp_style::muted);
} else if (state.active) { } else if (state.active) {
auto text = counter_text(state.page, state.pages); auto text = counter_text(state.page, state.pages);
number(text.data(), sprite::COUNT); number(text.data(), sprite::COUNT, 176U);
} else if (mode == "settings_menu") { } else if (mode == "settings_menu") {
auto focus = std::string_view{state.focus}; auto focus = std::string_view{state.focus};
if (focus == "Tempo") { if (focus == "Tempo") {
@@ -98,8 +118,12 @@ auto vamp_pico::render_scanline(std::array<std::uint16_t, vamp_pico::display_wid
if (state.editing && (y == 184U || y == 226U)) { if (state.editing && (y == 184U || y == 226U)) {
fill(64U, 256U, vamp_style::accent); fill(64U, 256U, vamp_style::accent);
} }
} else if (focus == "Playback" || focus == "Metronome") { } else if (focus == "Backing" || focus == "Metronome") {
centered((focus == "Playback" ? state.playback : state.metronome) ? sprite::ON : sprite::OFF, 192U, vamp_style::accent); if (focus == "Backing" && !state.backing_available) {
centered(sprite::UNAVAILABLE, 192U, vamp_style::muted);
} else {
centered((focus == "Backing" ? state.backing : state.metronome) ? sprite::ON : sprite::OFF, 192U, vamp_style::accent);
}
} }
} }
} }
+18 -12
View File
@@ -32,6 +32,7 @@
#include <vamp_zero/app.hpp> #include <vamp_zero/app.hpp>
#include <vamp_zero/config.hpp> #include <vamp_zero/config.hpp>
#include <vamp_zero/audio.hpp> #include <vamp_zero/audio.hpp>
#include <vamp_zero/performance.hpp>
#include <vamp_zero/database.hpp> #include <vamp_zero/database.hpp>
#include <vamp_zero/errors.hpp> #include <vamp_zero/errors.hpp>
#include <vamp_zero/health.hpp> #include <vamp_zero/health.hpp>
@@ -39,7 +40,7 @@
#include <vamp_zero/page_control.hpp> #include <vamp_zero/page_control.hpp>
#include <vamp_zero/control.hpp> #include <vamp_zero/control.hpp>
#include <vamp_zero/score.hpp> #include <vamp_zero/score.hpp>
#include <vamp_zero/playback.hpp>
#include <vamp_zero/ui.hpp> #include <vamp_zero/ui.hpp>
@@ -163,12 +164,9 @@ auto load_initial_state(vamp_zero::config const &config) -> std::expected<vamp_z
.catalog = std::make_shared<vamp_zero::catalog const>(std::move(*performance)), .catalog = std::make_shared<vamp_zero::catalog const>(std::move(*performance)),
.audio = .audio =
{ {
.playback_enabled = config.audio.playback_enabled, .backing_enabled = config.audio.backing_enabled,
.metronome_enabled = config.audio.metronome_enabled, .metronome_enabled = config.audio.metronome_enabled,
.metronome_bpm = config.audio.metronome_bpm, .tempo_bpm = config.audio.tempo_bpm,
.metronome_min_bpm = config.audio.metronome_min_bpm,
.metronome_max_bpm = config.audio.metronome_max_bpm,
.metronome_step = config.audio.metronome_step,
}, },
}; };
if (auto valid = vamp_zero::validate_app_state(state); !valid) { if (auto valid = vamp_zero::validate_app_state(state); !valid) {
@@ -188,14 +186,13 @@ auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::conf
co_return std::unexpected(valid.error()); co_return std::unexpected(valid.error());
} }
} }
auto playback = vamp_zero::validate_playback_assets(*score, config.runtime.assets_path);
if (!playback) {
co_return std::unexpected(playback.error());
}
co_return std::expected<void, modulus::error>{}; co_return 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>> { 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>> {
if (auto audio = vamp_zero::validate_audio_assets(catalog, config.runtime.assets_path); !audio) {
co_return audio;
}
for (auto const &part : catalog.parts) { for (auto const &part : catalog.parts) {
auto title = vamp_zero::validated_title_path(config.runtime.assets_path, part.title_image); auto title = vamp_zero::validated_title_path(config.runtime.assets_path, part.title_image);
if (!title) { if (!title) {
@@ -294,11 +291,20 @@ private:
if (selects_) { if (selects_) {
loader_->select(app.catalog, vamp_zero::selected_score_reference(app)); loader_->select(app.catalog, vamp_zero::selected_score_reference(app));
} }
auto loaded = loader_->snapshot();
if (selects_) {
loaded.pages_ready = true;
}
if (audio_) { if (audio_) {
if (auto result = audio_->tick(app, loader_->snapshot(), assets_)) { if (auto result = audio_->tick(app, loaded, assets_)) {
state_.finish_playback(app.score_session, *result ? std::string{} : result->error().describe(), app.catalog); state_.finish_playback(app.score_session, *result ? std::string{} : result->error().describe(), app.catalog);
} }
} else if (app.audio.playback_enabled && vamp_zero::selected_score_reference(app) && app.audio.playback_error.empty()) { if (loaded.document) {
if (auto position = audio_->position(*loaded.document, app.audio.tempo_bpm)) {
state_.update_performance(app.score_session, position->page, position->phase, app.catalog);
}
}
} else if (vamp_zero::automatic(app.audio) && vamp_zero::selected_score_reference(app) && app.audio.playback_error.empty()) {
state_.finish_playback(app.score_session, "Audio output is unavailable"); state_.finish_playback(app.score_session, "Audio output is unavailable");
} }
schedule(); schedule();
+23 -58
View File
@@ -10,7 +10,7 @@
#include <vamp_zero/audio.hpp> #include <vamp_zero/audio.hpp>
#include <vamp_zero/navigation.hpp> #include <vamp_zero/navigation.hpp>
#include <vamp_zero/playback.hpp> #include <vamp_zero/performance.hpp>
#include <vamp_zero/errors.hpp> #include <vamp_zero/errors.hpp>
@@ -23,74 +23,40 @@ auto score_active(vamp_zero::app_state const &state) -> std::bool_t {
return false; return false;
} }
auto log_audio_error(std::string const &action, modulus::error const &error) -> void {
modulus::log_error(action + ": " + error.describe());
}
} }
vamp_zero::audio_controller::audio_controller(vamp_zero::audio_config config, sonus::engine engine) vamp_zero::audio_controller::audio_controller(vamp_zero::audio_config config, sonus::engine engine)
: config_{config}, engine_{std::move(engine)} {} : config_{config}, engine_{std::move(engine)} {}
vamp_zero::audio_controller::~audio_controller() { vamp_zero::audio_controller::~audio_controller() {
playback_.stop();
worker_.join(); worker_.join();
auto lock = std::scoped_lock{mutex_}; auto lock = std::scoped_lock{mutex_};
engine_.stop_metronome();
engine_.stop_all(); engine_.stop_all();
engine_.stop(); engine_.stop();
} }
auto vamp_zero::audio_controller::make_metronome_config(vamp_zero::app_state const &state) const -> sonus::metronome_config {
auto result = sonus::metronome_config{};
result.bpm = state.audio.metronome_bpm;
result.beats_per_bar = config_.beats_per_bar;
result.volume = config_.metronome_volume;
return result;
}
auto vamp_zero::audio_controller::sync(vamp_zero::app_state const &state) -> void { auto vamp_zero::audio_controller::sync(vamp_zero::app_state const &state) -> void {
auto lock = std::scoped_lock{mutex_}; auto lock = std::scoped_lock{mutex_};
auto const should_run_metronome = state.audio.metronome_enabled && !state.audio.playback_enabled && score_active(state); if (!vamp_zero::automatic(state.audio) || !score_active(state) || attempted_session_ != state.score_session || catalog_ != state.catalog || !state.audio.playback_error.empty()) {
if (!state.audio.playback_enabled || !score_active(state) || attempted_session_ != state.score_session || catalog_ != state.catalog) {
playback_.stop(); playback_.stop();
reported_ = true;
} }
if (!should_run_metronome) {
if (metronome_running_) {
engine_.stop_metronome();
metronome_running_ = false;
} }
return;
} auto vamp_zero::audio_controller::position(vamp_zero::score_document const &score, std::uint32_t bpm) const -> std::optional<vamp_zero::performance_position> {
auto metronome = make_metronome_config(state); auto lock = std::scoped_lock{mutex_};
if (!metronome_running_) { if (reported_) {
auto started = engine_.start_metronome(metronome); return std::nullopt;
if (!started) {
log_audio_error("failed to start metronome", started.error());
engine_.stop_metronome();
metronome_running_ = false;
return;
}
metronome_running_ = true;
last_metronome_bpm_ = metronome.bpm;
return;
}
if (last_metronome_bpm_ != metronome.bpm) {
auto updated = engine_.update_metronome(metronome);
if (!updated) {
log_audio_error("failed to update metronome", updated.error());
engine_.stop_metronome();
metronome_running_ = false;
return;
}
last_metronome_bpm_ = metronome.bpm;
} }
return vamp_zero::performance_at(score, playback_.frames(), bpm);
} }
auto vamp_zero::audio_controller::tick(vamp_zero::app_state const &state, vamp_zero::score_load_state const &loaded, std::filesystem::path const &assets) -> std::optional<std::expected<void, modulus::error>> { auto vamp_zero::audio_controller::tick(vamp_zero::app_state const &state, vamp_zero::score_load_state const &loaded, std::filesystem::path const &assets) -> std::optional<std::expected<void, modulus::error>> {
sync(state); sync(state);
auto lock = std::scoped_lock{mutex_}; auto lock = std::scoped_lock{mutex_};
engine_.collect_finished(); engine_.collect_finished();
auto wanted = state.audio.playback_enabled && score_active(state); auto wanted = vamp_zero::automatic(state.audio) && score_active(state) && state.audio.playback_error.empty();
if (!wanted || catalog_ != state.catalog) { if (!wanted || catalog_ != state.catalog) {
attempted_session_.reset(); attempted_session_.reset();
reported_ = true; reported_ = true;
@@ -101,12 +67,12 @@ auto vamp_zero::audio_controller::tick(vamp_zero::app_state const &state, vamp_z
return std::nullopt; return std::nullopt;
} }
auto prepared = preparation_.get(); auto prepared = preparation_.get();
if (wanted && attempted_session_ == state.score_session && preparing_session_ == state.score_session) { if (wanted && !loaded.error && loaded.pages_ready && attempted_session_ == state.score_session && preparing_session_ == state.score_session && preparing_catalog_ == state.catalog) {
if (!prepared) { if (!prepared) {
reported_ = true; reported_ = true;
return std::unexpected(prepared.error()); return std::unexpected(prepared.error());
} }
auto started = engine_.play_sequence(std::move(*prepared)); auto started = engine_.play_transport(std::move(*prepared));
if (!started) { if (!started) {
reported_ = true; reported_ = true;
return std::unexpected(started.error()); return std::unexpected(started.error());
@@ -118,27 +84,26 @@ auto vamp_zero::audio_controller::tick(vamp_zero::app_state const &state, vamp_z
if (!wanted) { if (!wanted) {
return std::nullopt; return std::nullopt;
} }
if (loaded.error) {
playback_.stop();
reported_ = true;
return std::unexpected(*loaded.error);
}
if (attempted_session_ != state.score_session) { if (attempted_session_ != state.score_session) {
auto reference = vamp_zero::selected_score_reference(state); auto reference = vamp_zero::selected_score_reference(state);
if (loaded.document && (!reference || loaded.document->id != reference->id)) { if (loaded.document && (!reference || loaded.document->id != reference->id)) {
return std::nullopt; return std::nullopt;
} }
if (loaded.loading || (!loaded.document && !loaded.error)) { if (loaded.loading || !loaded.pages_ready || !loaded.document) {
return std::nullopt; return std::nullopt;
} }
attempted_session_ = state.score_session; attempted_session_ = state.score_session;
reported_ = true; reported_ = true;
if (loaded.error) {
return std::unexpected(*loaded.error);
}
preparing_session_ = state.score_session; preparing_session_ = state.score_session;
auto task = std::make_shared<std::packaged_task<std::expected<sonus::prepared_sequence, modulus::error>()>>([score = loaded.document, assets, bpm = state.audio.metronome_bpm]() -> std::expected<sonus::prepared_sequence, modulus::error> { preparing_catalog_ = state.catalog;
auto task = std::make_shared<std::packaged_task<std::expected<sonus::prepared_transport, modulus::error>()>>([score = loaded.document, assets, state, volume = config_.metronome_volume]() -> std::expected<sonus::prepared_transport, modulus::error> {
try { try {
auto sequence = vamp_zero::prepare_playback(*score, assets, bpm); return vamp_zero::prepare_performance(*score, state, assets, volume);
if (!sequence) {
return std::unexpected(sequence.error());
}
return sonus::prepared_sequence::create(std::move(*sequence), 48000U);
} catch (...) { } catch (...) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INTERNAL, "audio preparation failed")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INTERNAL, "audio preparation failed"));
} }
@@ -153,7 +118,7 @@ auto vamp_zero::audio_controller::tick(vamp_zero::app_state const &state, vamp_z
return std::expected<void, modulus::error>{}; return std::expected<void, modulus::error>{};
} }
if (playback_.status() == sonus::playback_status::FAILED) { if (playback_.status() == sonus::playback_status::FAILED) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "sample playback failed")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "Recording playback failed"));
} }
} }
return std::nullopt; return std::nullopt;
+14 -35
View File
@@ -171,42 +171,28 @@ auto parse_audio(boost::json::object const &root, vamp_zero::audio_config fallba
if (*object == nullptr) { if (*object == nullptr) {
return fallback; return fallback;
} }
if ((*object)->if_contains("track_enabled")) { for (auto name : {"track_enabled", "playback_enabled", "metronome_bpm", "metronome_min_bpm", "metronome_max_bpm", "metronome_step", "beats_per_bar"}) {
return std::unexpected(invalid("audio.track_enabled is no longer supported; use audio.playback_enabled")); if ((*object)->if_contains(name)) {
return std::unexpected(invalid("obsolete audio option: " + std::string{name} + "; use backing_enabled, metronome_enabled and tempo_bpm"));
}
} }
auto enabled = modulus::json_config::optional_bool_field(**object, "enabled", fallback.enabled, field_options); auto enabled = modulus::json_config::optional_bool_field(**object, "enabled", fallback.enabled, field_options);
auto playback_enabled = modulus::json_config::optional_bool_field(**object, "playback_enabled", fallback.playback_enabled, field_options); auto backing_enabled = modulus::json_config::optional_bool_field(**object, "backing_enabled", fallback.backing_enabled, field_options);
auto metronome_enabled = modulus::json_config::optional_bool_field(**object, "metronome_enabled", fallback.metronome_enabled, field_options); auto metronome_enabled = modulus::json_config::optional_bool_field(**object, "metronome_enabled", fallback.metronome_enabled, field_options);
auto metronome_bpm = optional_uint32(**object, "metronome_bpm", fallback.metronome_bpm, 1U, 400U); auto tempo_bpm = optional_uint32(**object, "tempo_bpm", fallback.tempo_bpm, 1U, 400U);
auto metronome_min_bpm = optional_uint32(**object, "metronome_min_bpm", fallback.metronome_min_bpm, 1U, 400U);
auto metronome_max_bpm = optional_uint32(**object, "metronome_max_bpm", fallback.metronome_max_bpm, 1U, 400U);
auto metronome_step = optional_uint32(**object, "metronome_step", fallback.metronome_step, 1U, 100U);
auto beats_per_bar = optional_uint32(**object, "beats_per_bar", fallback.beats_per_bar, 1U, 16U);
auto master_volume = optional_float(**object, "master_volume", fallback.master_volume, 0.0F, 1.0F); auto master_volume = optional_float(**object, "master_volume", fallback.master_volume, 0.0F, 1.0F);
auto metronome_volume = optional_float(**object, "metronome_volume", fallback.metronome_volume, 0.0F, 1.0F); auto metronome_volume = optional_float(**object, "metronome_volume", fallback.metronome_volume, 0.0F, 1.0F);
if (!enabled) { if (!enabled) {
return std::unexpected(enabled.error()); return std::unexpected(enabled.error());
} }
if (!playback_enabled) { if (!backing_enabled) {
return std::unexpected(playback_enabled.error()); return std::unexpected(backing_enabled.error());
} }
if (!metronome_enabled) { if (!metronome_enabled) {
return std::unexpected(metronome_enabled.error()); return std::unexpected(metronome_enabled.error());
} }
if (!metronome_bpm) { if (!tempo_bpm) {
return std::unexpected(metronome_bpm.error()); return std::unexpected(tempo_bpm.error());
}
if (!metronome_min_bpm) {
return std::unexpected(metronome_min_bpm.error());
}
if (!metronome_max_bpm) {
return std::unexpected(metronome_max_bpm.error());
}
if (!metronome_step) {
return std::unexpected(metronome_step.error());
}
if (!beats_per_bar) {
return std::unexpected(beats_per_bar.error());
} }
if (!master_volume) { if (!master_volume) {
return std::unexpected(master_volume.error()); return std::unexpected(master_volume.error());
@@ -214,21 +200,14 @@ auto parse_audio(boost::json::object const &root, vamp_zero::audio_config fallba
if (!metronome_volume) { if (!metronome_volume) {
return std::unexpected(metronome_volume.error()); return std::unexpected(metronome_volume.error());
} }
if (*metronome_min_bpm > *metronome_max_bpm) { if (*tempo_bpm < 60U || *tempo_bpm > 120U || *tempo_bpm % 10U != 0U) {
return std::unexpected(invalid("audio.metronome_min_bpm must be less than or equal to audio.metronome_max_bpm")); return std::unexpected(invalid("audio.tempo_bpm must be 60, 70, 80, 90, 100, 110 or 120"));
}
if (*metronome_bpm < *metronome_min_bpm || *metronome_bpm > *metronome_max_bpm) {
return std::unexpected(invalid("audio.metronome_bpm must be within the configured metronome range"));
} }
return vamp_zero::audio_config{ return vamp_zero::audio_config{
.enabled = *enabled, .enabled = *enabled,
.playback_enabled = *playback_enabled, .backing_enabled = *backing_enabled,
.metronome_enabled = *metronome_enabled, .metronome_enabled = *metronome_enabled,
.metronome_bpm = *metronome_bpm, .tempo_bpm = *tempo_bpm,
.metronome_min_bpm = *metronome_min_bpm,
.metronome_max_bpm = *metronome_max_bpm,
.metronome_step = *metronome_step,
.beats_per_bar = *beats_per_bar,
.master_volume = *master_volume, .master_volume = *master_volume,
.metronome_volume = *metronome_volume, .metronome_volume = *metronome_volume,
}; };
+25 -1
View File
@@ -1,4 +1,5 @@
#include <vamp_zero/control.hpp> #include <vamp_zero/control.hpp>
#include <vamp_zero/performance.hpp>
#include <chrono> #include <chrono>
#include <array> #include <array>
@@ -218,13 +219,14 @@ auto vamp_zero::control_state::finish_playback(std::uint64_t session, std::strin
auto updated = vamp_zero::app_state{}; auto updated = vamp_zero::app_state{};
{ {
auto lock = std::scoped_lock{mutex_}; auto lock = std::scoped_lock{mutex_};
if (!state_.audio.playback_enabled || state_.score_session != session || (catalog && catalog != state_.catalog) || state_.mode != vamp_zero::app_mode::PERFORMANCE_SCORE) { if (!vamp_zero::automatic(state_.audio) || state_.score_session != session || (catalog && catalog != state_.catalog) || state_.mode != vamp_zero::app_mode::PERFORMANCE_SCORE) {
return; return;
} }
if (error.empty()) { if (error.empty()) {
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);
} else { } else {
state_.audio.playback_error = std::move(error); state_.audio.playback_error = std::move(error);
state_.audio.phase = "Error";
} }
updated = snapshot_locked(); updated = snapshot_locked();
observer = observer_; observer = observer_;
@@ -277,3 +279,25 @@ auto vamp_zero::make_router(vamp_zero::control_state &state) -> ligo::service_ro
}); });
return router; return router;
} }
auto vamp_zero::control_state::update_performance(std::uint64_t session, std::size_t page, std::string phase, std::shared_ptr<vamp_zero::catalog const> catalog) -> void {
std::function<void(vamp_zero::app_state)> observer;
vamp_zero::app_state updated;
{
auto lock = std::scoped_lock{mutex_};
if (!vamp_zero::automatic(state_.audio) || state_.mode != vamp_zero::app_mode::PERFORMANCE_SCORE || state_.score_session != session || state_.catalog != catalog || !state_.audio.playback_error.empty()) {
return;
}
if (state_.page_index == page && state_.audio.phase == phase) {
return;
}
state_.page_index = page;
state_.audio.phase = std::move(phase);
++state_.input_revision;
updated = snapshot_locked();
observer = observer_;
}
if (observer) {
observer(std::move(updated));
}
}
+19 -3
View File
@@ -20,6 +20,7 @@
#include <vamp_zero/errors.hpp> #include <vamp_zero/errors.hpp>
#include <vamp_zero/library.hpp> #include <vamp_zero/library.hpp>
#include <vamp_zero/score.hpp> #include <vamp_zero/score.hpp>
#include <vamp_zero/performance.hpp>
namespace { namespace {
@@ -158,7 +159,7 @@ auto vamp_zero::async_load_catalog(boost::asio::any_io_executor executor, vamp_z
co_return std::unexpected(connected.error()); co_return std::unexpected(connected.error());
} }
auto selected = auto selected =
co_await connection.async_prepare("SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages, title_image FROM " co_await connection.async_prepare("SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages, title_image, json_extract(source_json, '$.music.metadata.song_id') FROM "
"songs ORDER BY title, composer, part, id"); "songs ORDER BY title, composer, part, id");
if (!selected) { if (!selected) {
auto close = co_await connection.async_close(); auto close = co_await connection.async_close();
@@ -179,7 +180,7 @@ auto vamp_zero::async_load_catalog(boost::asio::any_io_executor executor, vamp_z
break; break;
} }
auto const &row = **next; auto const &row = **next;
if (row.size() != 9U || count_value(row[5]) == 0U || count_value(row[7]) == 0U || !valid_title_image(row[8])) { if (row.size() != 10U || count_value(row[5]) == 0U || count_value(row[7]) == 0U || !valid_title_image(row[8])) {
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid performance metadata")); status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid performance metadata"));
break; break;
} }
@@ -198,13 +199,28 @@ auto vamp_zero::async_load_catalog(boost::asio::any_io_executor executor, vamp_z
.measure_count = count_value(row[5]), .measure_count = count_value(row[5]),
.total_pages = count_value(row[7]), .total_pages = count_value(row[7]),
.title_image = text_value(row[8]), .title_image = text_value(row[8]),
.song_id = text_value(row[9]),
}); });
} }
auto audio_assets = std::expected<std::vector<vamp_zero::audio_asset>, modulus::error>{};
if (status) {
auto resources = co_await connection.async_execute("SELECT value FROM resources WHERE id = 'audio'");
if (!resources || resources->rows.size() != 1 || resources->rows.front().size() != 1) {
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "missing audio catalog; regenerate assets"));
} else {
audio_assets = vamp_zero::parse_audio_assets(text_value(resources->rows.front().front()));
if (!audio_assets) {
status = std::unexpected(audio_assets.error());
}
}
}
auto closed = co_await close_catalog_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());
} }
co_return vamp_zero::group_ordered_parts(std::move(parts)); auto catalog = vamp_zero::group_ordered_parts(std::move(parts));
catalog.audio_assets = std::move(*audio_assets);
co_return catalog;
} }
auto vamp_zero::async_load_score(boost::asio::any_io_executor executor, vamp_zero::database_config config, vamp_zero::score_reference reference, std::string assets_path) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>> { auto vamp_zero::async_load_score(boost::asio::any_io_executor executor, vamp_zero::database_config config, vamp_zero::score_reference reference, std::string assets_path) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>> {
+6 -2
View File
@@ -4,6 +4,7 @@
#include <vamp_zero/navigation.hpp> #include <vamp_zero/navigation.hpp>
#include <vamp_zero/page_control.hpp> #include <vamp_zero/page_control.hpp>
#include <vamp_zero/performance.hpp>
auto vamp_zero::menu_count(vamp_zero::app_state const &state) -> std::size_t { auto vamp_zero::menu_count(vamp_zero::app_state const &state) -> std::size_t {
@@ -72,12 +73,15 @@ auto vamp_zero::menu_row(vamp_zero::app_state const &state, std::size_t row) ->
} }
if (state.mode == vamp_zero::app_mode::SETTINGS_MENU) { if (state.mode == vamp_zero::app_mode::SETTINGS_MENU) {
if (row == 1U) { if (row == 1U) {
return {"Playback", state.audio.playback_enabled ? "On" : "Off"}; auto enabled = state;
enabled.audio.backing_enabled = true;
return {"Backing", vamp_zero::available_tempos(enabled).empty() ? "Unavailable" : state.audio.backing_enabled ? "On"
: "Off"};
} }
if (row == 2U) { if (row == 2U) {
return {"Metronome", state.audio.metronome_enabled ? "On" : "Off"}; return {"Metronome", state.audio.metronome_enabled ? "On" : "Off"};
} }
return {"Tempo", std::to_string(state.audio.metronome_bpm) + " BPM"}; return {"Tempo", std::to_string(state.audio.tempo_bpm) + " BPM"};
} }
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU) { if (state.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU) {
auto const &group = state.catalog->groups[row - 1U]; auto const &group = state.catalog->groups[row - 1U];
+9 -5
View File
@@ -5,6 +5,7 @@
#include <vamp_zero/color.hpp> #include <vamp_zero/color.hpp>
#include <vamp_zero/navigation.hpp> #include <vamp_zero/navigation.hpp>
#include <vamp_zero/performance.hpp>
namespace { namespace {
@@ -87,12 +88,13 @@ auto vamp_zero::draw_label(luma::draw_context &context, luma::rect bounds, std::
} }
auto vamp_zero::draw_header(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void { auto vamp_zero::draw_header(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void {
context.fill_rect({bounds.x, bounds.y, bounds.width, 80.0F}, vamp_zero::to_luma(vamp_style::panel)); context.fill_rect({bounds.x, bounds.y, bounds.width, 80.0F}, vamp_zero::to_luma(vamp_style::background));
context.stroke_line({bounds.x + 36.0F, bounds.y + 79.0F}, {bounds.x + bounds.width - 36.0F, bounds.y + 79.0F}, 1.0F, vamp_zero::to_luma(vamp_style::rule)); context.stroke_line({bounds.x + 36.0F, bounds.y + 79.0F}, {bounds.x + bounds.width - 36.0F, bounds.y + 79.0F}, 1.0F, vamp_zero::to_luma(vamp_style::rule));
draw_label(context, {bounds.x + 36.0F, bounds.y + 8.0F, 260.0F, 60.0F}, "Product Vamp", 30.0F, vamp_style::ink); draw_label(context, {bounds.x + 36.0F, bounds.y + 8.0F, 260.0F, 60.0F}, "Product Vamp", 30.0F, vamp_style::ink);
if (auto part = active_part(state); state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE && part != nullptr) { if (auto part = active_part(state); state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE && part != nullptr) {
auto summary = part->part + " " + std::to_string(state.page_index + 1U) + " / " + std::to_string(part->total_pages); auto summary = state.audio.phase + " " + std::to_string(state.audio.tempo_bpm) + " BPM " + std::to_string(state.page_index + 1U) + "/" + std::to_string(part->total_pages);
draw_label(context, {bounds.x + 310.0F, bounds.y + 14.0F, bounds.width - 550.0F, 48.0F}, state.audio.playback_error.empty() ? summary : state.audio.playback_error, 20.0F, state.audio.playback_error.empty() ? vamp_style::muted : vamp_style::danger); draw_label(context, {bounds.x + 310.0F, bounds.y + 46.0F, bounds.width - 550.0F, 26.0F}, "Hold to exit", 15.0F, vamp_style::muted);
draw_label(context, {bounds.x + 310.0F, bounds.y + 7.0F, bounds.width - 550.0F, 38.0F}, state.audio.playback_error.empty() ? summary : state.audio.playback_error, 20.0F, state.audio.playback_error.empty() ? vamp_style::muted : vamp_style::danger);
} }
context.fill_ellipse({bounds.x + bounds.width - 184.0F, bounds.y + 33.0F, 10.0F, 10.0F}, vamp_zero::to_luma(state.pico_connected ? vamp_style::connected : vamp_style::muted)); context.fill_ellipse({bounds.x + bounds.width - 184.0F, bounds.y + 33.0F, 10.0F, 10.0F}, vamp_zero::to_luma(state.pico_connected ? vamp_style::connected : vamp_style::muted));
draw_label(context, {bounds.x + bounds.width - 162.0F, bounds.y + 12.0F, 132.0F, 52.0F}, state.pico_connected ? "Pico connected" : "Pico offline", 17.0F, vamp_style::muted); draw_label(context, {bounds.x + bounds.width - 162.0F, bounds.y + 12.0F, 132.0F, 52.0F}, state.pico_connected ? "Pico connected" : "Pico offline", 17.0F, vamp_style::muted);
@@ -129,7 +131,8 @@ auto vamp_zero::draw_menu(luma::draw_context &context, luma::rect bounds, vamp_z
if (songs) { if (songs) {
draw_label(context, {row.x + 22.0F, row.y + 39.0F, row.width - 90.0F, 28.0F}, item.detail, 17.0F, vamp_style::muted); draw_label(context, {row.x + 22.0F, row.y + 39.0F, row.width - 90.0F, 28.0F}, item.detail, 17.0F, vamp_style::muted);
} else if (settings && action.row < 3U) { } else if (settings && action.row < 3U) {
draw_switch(context, row, action.row == 1U ? state.audio.playback_enabled : state.audio.metronome_enabled); draw_label(context, {row.x + row.width - 245.0F, row.y + 12.0F, 145.0F, 48.0F}, item.detail, 20.0F, vamp_style::muted);
draw_switch(context, row, action.row == 1U ? state.audio.backing_enabled : state.audio.metronome_enabled);
} else { } else {
draw_label(context, {row.x + row.width - (settings ? 181.0F : 250.0F), row.y + 12.0F, settings ? 112.0F : 180.0F, 48.0F}, item.detail, 20.0F, settings && state.audio.tempo_editing ? vamp_style::accent : vamp_style::muted); draw_label(context, {row.x + row.width - (settings ? 181.0F : 250.0F), row.y + 12.0F, settings ? 112.0F : 180.0F, 48.0F}, item.detail, 20.0F, settings && state.audio.tempo_editing ? vamp_style::accent : vamp_style::muted);
} }
@@ -149,7 +152,8 @@ auto vamp_zero::draw_menu(luma::draw_context &context, luma::rect bounds, vamp_z
continue; continue;
} }
auto up = kind == vamp_zero::menu_action_kind::TEMPO_UP; auto up = kind == vamp_zero::menu_action_kind::TEMPO_UP;
auto enabled = up ? state.audio.metronome_bpm < state.audio.metronome_max_bpm : state.audio.metronome_bpm > state.audio.metronome_min_bpm; auto tempos = vamp_zero::available_tempos(state);
auto enabled = !tempos.empty() && (up ? state.audio.tempo_bpm < tempos.back() : state.audio.tempo_bpm > tempos.front());
auto color = vamp_zero::to_luma(enabled ? vamp_style::accent : vamp_style::rule); auto color = vamp_zero::to_luma(enabled ? vamp_style::accent : vamp_style::rule);
context.stroke_rect(rect, 1.0F, color); context.stroke_rect(rect, 1.0F, color);
context.stroke_line({rect.x + 16.0F, rect.y + 24.0F}, {rect.x + 32.0F, rect.y + 24.0F}, 2.0F, color); context.stroke_line({rect.x + 16.0F, rect.y + 24.0F}, {rect.x + 32.0F, rect.y + 24.0F}, 2.0F, color);
+55 -1
View File
@@ -10,6 +10,8 @@
#include <vamp_zero/errors.hpp> #include <vamp_zero/errors.hpp>
#include <vamp_zero/notation.hpp> #include <vamp_zero/notation.hpp>
#include <vamp_zero/performance.hpp>
#include <boost/multiprecision/cpp_int.hpp>
namespace { namespace {
@@ -90,6 +92,44 @@ auto vamp_zero::parse_notation_document(std::string_view json, std::string sourc
} }
document.arrangement_part_type = std::move(*type); document.arrangement_part_type = std::move(*type);
} }
auto times = std::vector<boost::multiprecision::cpp_rational>{0};
auto rational = [](vamp_zero::beat_position beat) -> boost::multiprecision::cpp_rational { return boost::multiprecision::cpp_rational{beat.numerator} / beat.denominator; };
try {
for (auto const &measure : *measures) {
auto duration = vamp_zero::parse_beat(measure.at("duration"));
if (!duration || duration->numerator == 0) {
return invalid("invalid measure duration");
}
times.push_back(times.back() + rational(*duration));
}
auto const &performance = root.at("performance").as_object();
document.song_id = std::string{performance.at("song_id").as_string()};
auto duration = vamp_zero::parse_beat(performance.at("duration"));
if (!duration || rational(*duration) != times.back() || !integer_equals(performance, "count_in_beats", 4)) {
return invalid("invalid performance duration or count-in");
}
document.duration = *duration;
auto const &clicks = performance.at("clicks").as_array();
auto click = std::size_t{};
for (auto measure = std::size_t{}; measure + 1 < times.size(); ++measure) {
for (auto beat = times[measure]; beat < times[measure + 1]; ++beat) {
if (click >= clicks.size()) {
return invalid("missing metronome beat");
}
auto position = vamp_zero::parse_beat(clicks[click].at("beat"));
if (!position || rational(*position) != beat || clicks[click].at("downbeat").as_bool() != (beat == times[measure])) {
return invalid("invalid metronome accent or beat");
}
++click;
}
}
if (click != clicks.size()) {
return invalid("unexpected metronome beats");
}
} catch (...) {
return invalid("missing performance timing; regenerate score assets");
}
auto cursor = std::size_t{};
for (auto const &item : *pages) { for (auto const &item : *pages) {
if (!item.is_object()) { if (!item.is_object()) {
return invalid("page must be an object"); return invalid("page must be an object");
@@ -110,7 +150,21 @@ auto vamp_zero::parse_notation_document(std::string_view json, std::string sourc
})) { })) {
return invalid("page sha256 must be 64 lowercase hexadecimal digits"); return invalid("page sha256 must be 64 lowercase hexadecimal digits");
} }
document.pages.push_back({std::move(*path), std::move(*hash)}); try {
auto first = item.at("first_measure").to_number<std::size_t>();
auto count = item.at("measure_count").to_number<std::size_t>();
auto start = vamp_zero::parse_beat(item.at("start_beat")), end = vamp_zero::parse_beat(item.at("end_beat"));
if (first != cursor + 1 || count == 0 || count > measures->size() - cursor || !start || !end || rational(*start) != times[cursor] || rational(*end) != times[cursor + count]) {
return invalid("invalid page measure coverage");
}
document.pages.push_back({std::move(*path), std::move(*hash), first, count, *start, *end});
cursor += count;
} catch (...) {
return invalid("missing page timing; regenerate score assets");
}
}
if (cursor != measures->size()) {
return invalid("incomplete page measure coverage");
} }
document.id = std::filesystem::path{source_path}.stem().string(); document.id = std::filesystem::path{source_path}.stem().string();
document.source_path = std::move(source_path); document.source_path = std::move(source_path);
+40 -36
View File
@@ -12,12 +12,13 @@
#include <vamp_zero/errors.hpp> #include <vamp_zero/errors.hpp>
#include <vamp_zero/navigation.hpp> #include <vamp_zero/navigation.hpp>
#include <vamp_zero/menu.hpp> #include <vamp_zero/menu.hpp>
#include <vamp_zero/performance.hpp>
namespace { namespace {
constexpr auto main_menu_performance = std::size_t{0}; constexpr auto main_menu_performance = std::size_t{0};
constexpr auto settings_playback = std::size_t{1}; constexpr auto settings_backing = std::size_t{1};
constexpr auto settings_metronome = std::size_t{2}; constexpr auto settings_metronome = std::size_t{2};
constexpr auto settings_tempo = std::size_t{3}; constexpr auto settings_tempo = std::size_t{3};
constexpr auto main_menu_count = std::size_t{2}; constexpr auto main_menu_count = std::size_t{2};
@@ -40,32 +41,35 @@ auto menu_row_count(std::size_t item_count) -> std::size_t {
return item_count + 1U; return item_count + 1U;
} }
auto clamp_audio_state(vamp_zero::audio_state audio) -> vamp_zero::audio_state { auto normalize_tempo(vamp_zero::app_state &state) -> void {
audio.metronome_min_bpm = std::clamp<std::uint32_t>(audio.metronome_min_bpm, 1U, 400U); auto tempos = vamp_zero::available_tempos(state);
audio.metronome_max_bpm = std::clamp<std::uint32_t>(audio.metronome_max_bpm, audio.metronome_min_bpm, 400U); if (tempos.empty()) {
audio.metronome_step = std::clamp<std::uint32_t>(audio.metronome_step, 1U, 100U); state.audio.backing_enabled = false;
audio.metronome_bpm = std::clamp(audio.metronome_bpm, audio.metronome_min_bpm, audio.metronome_max_bpm); tempos = vamp_zero::available_tempos(state);
return audio; }
auto distance = [&](std::uint32_t bpm) { return std::abs(static_cast<int>(bpm) - static_cast<int>(state.audio.tempo_bpm)); };
state.audio.tempo_bpm = *std::min_element(tempos.begin(), tempos.end(), [&](auto a, auto b) { return distance(a) < distance(b); });
} }
auto increase_bpm(vamp_zero::audio_state &audio) -> void { auto step_tempo(vamp_zero::app_state &state, bool forward) -> void {
audio.metronome_bpm = auto tempos = vamp_zero::available_tempos(state);
audio.metronome_bpm > audio.metronome_max_bpm - std::min(audio.metronome_step, audio.metronome_max_bpm) auto current = std::find(tempos.begin(), tempos.end(), state.audio.tempo_bpm);
? audio.metronome_max_bpm if (current == tempos.end()) {
: audio.metronome_bpm + audio.metronome_step; return;
}
if (forward && current + 1 != tempos.end()) {
state.audio.tempo_bpm = *++current;
}
if (!forward && current != tempos.begin()) {
state.audio.tempo_bpm = *--current;
} }
auto decrease_bpm(vamp_zero::audio_state &audio) -> void {
audio.metronome_bpm = audio.metronome_bpm < audio.metronome_min_bpm + audio.metronome_step
? audio.metronome_min_bpm
: audio.metronome_bpm - audio.metronome_step;
} }
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_catalog(); state.catalog = vamp_zero::empty_catalog();
} }
state.audio = clamp_audio_state(state.audio); normalize_tempo(state);
state.selected_settings_index = std::min<std::size_t>(state.selected_settings_index, 3U); state.selected_settings_index = std::min<std::size_t>(state.selected_settings_index, 3U);
state.remembered_settings_index = std::clamp<std::size_t>(state.remembered_settings_index, 1U, 3U); state.remembered_settings_index = std::clamp<std::size_t>(state.remembered_settings_index, 1U, 3U);
if (state.mode == vamp_zero::app_mode::SETTINGS_MENU && state.selected_settings_index != 0U) { if (state.mode == vamp_zero::app_mode::SETTINGS_MENU && state.selected_settings_index != 0U) {
@@ -130,9 +134,9 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
case vamp_zero::app_mode::SETTINGS_MENU: case vamp_zero::app_mode::SETTINGS_MENU:
if (state.audio.tempo_editing) { if (state.audio.tempo_editing) {
if (command == vamp_protocol::page_command::NEXT) { if (command == vamp_protocol::page_command::NEXT) {
increase_bpm(state.audio); step_tempo(state, true);
} else if (command == vamp_protocol::page_command::PREVIOUS) { } else if (command == vamp_protocol::page_command::PREVIOUS) {
decrease_bpm(state.audio); step_tempo(state, false);
} else { } else {
state.audio.tempo_editing = false; state.audio.tempo_editing = false;
} }
@@ -142,8 +146,8 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
state.selected_settings_index = wrap_previous(state.selected_settings_index, 4U); state.selected_settings_index = wrap_previous(state.selected_settings_index, 4U);
} else if (state.selected_settings_index == 0U) { } else if (state.selected_settings_index == 0U) {
state.mode = vamp_zero::app_mode::MAIN_MENU; state.mode = vamp_zero::app_mode::MAIN_MENU;
} else if (state.selected_settings_index == settings_playback) { } else if (state.selected_settings_index == settings_backing) {
state.audio.playback_enabled = !state.audio.playback_enabled; state.audio.backing_enabled = !state.audio.backing_enabled;
} else if (state.selected_settings_index == settings_metronome) { } else if (state.selected_settings_index == settings_metronome) {
state.audio.metronome_enabled = !state.audio.metronome_enabled; state.audio.metronome_enabled = !state.audio.metronome_enabled;
} else if (state.selected_settings_index == settings_tempo) { } else if (state.selected_settings_index == settings_tempo) {
@@ -181,10 +185,14 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE; state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
++state.score_session; ++state.score_session;
state.audio.playback_error.clear(); state.audio.playback_error.clear();
state.audio.phase = vamp_zero::automatic(state.audio) ? "Preparing" : "Manual";
} }
} }
return clamp_state(std::move(state)); return clamp_state(std::move(state));
case vamp_zero::app_mode::PERFORMANCE_SCORE: case vamp_zero::app_mode::PERFORMANCE_SCORE:
if (vamp_zero::automatic(state.audio) && command != vamp_protocol::page_command::SELECT) {
return state;
}
if (auto const *part = active_part(state); part != nullptr) { if (auto const *part = active_part(state); part != nullptr) {
if (command == vamp_protocol::page_command::NEXT) { if (command == vamp_protocol::page_command::NEXT) {
state.page_index = std::min(state.page_index + 1U, part->total_pages == 0U ? 0U : part->total_pages - 1U); state.page_index = std::min(state.page_index + 1U, part->total_pages == 0U ? 0U : part->total_pages - 1U);
@@ -231,9 +239,14 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s
auto output = std::ostringstream{}; auto output = std::ostringstream{};
output << "mode=" << vamp_zero::to_string(state.mode) << "\n"; output << "mode=" << vamp_zero::to_string(state.mode) << "\n";
output << "focus=" << sanitize_line_value(focus) << "\n"; output << "focus=" << sanitize_line_value(focus) << "\n";
output << "playback=" << (state.audio.playback_enabled ? "1" : "0") << "\n"; auto backing_state = state;
backing_state.audio.backing_enabled = true;
output << "backing_available=" << (vamp_zero::available_tempos(backing_state).empty() ? "0" : "1") << "\n";
output << "backing=" << (state.audio.backing_enabled ? "1" : "0") << "\n";
output << "metronome=" << (state.audio.metronome_enabled ? "1" : "0") << "\n"; output << "metronome=" << (state.audio.metronome_enabled ? "1" : "0") << "\n";
output << "bpm=" << state.audio.metronome_bpm << "\n"; output << "phase=" << sanitize_line_value(state.audio.phase) << "\n";
output << "error=" << (state.audio.playback_error.empty() ? "0" : "1") << "\n";
output << "bpm=" << state.audio.tempo_bpm << "\n";
output << "editing=" << (state.audio.tempo_editing ? "1" : "0") << "\n"; output << "editing=" << (state.audio.tempo_editing ? "1" : "0") << "\n";
output << "active=" << (active ? "1" : "0") << "\n"; output << "active=" << (active ? "1" : "0") << "\n";
output << "title=" << sanitize_line_value(title) << "\n"; output << "title=" << sanitize_line_value(title) << "\n";
@@ -254,18 +267,9 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::ex
if (state.selected_settings_index >= 4U) { if (state.selected_settings_index >= 4U) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected settings index is out of range")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected settings index is out of range"));
} }
if (state.audio.metronome_min_bpm == 0U || state.audio.metronome_min_bpm > 400U) { auto tempos = vamp_zero::available_tempos(state);
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "metronome minimum BPM is out of range")); if (std::find(tempos.begin(), tempos.end(), state.audio.tempo_bpm) == tempos.end()) {
} return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "tempo unavailable"));
if (state.audio.metronome_max_bpm < state.audio.metronome_min_bpm || state.audio.metronome_max_bpm > 400U) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "metronome maximum BPM is out of range"));
}
if (state.audio.metronome_step == 0U || state.audio.metronome_step > 100U) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "metronome BPM step is out of range"));
}
if (state.audio.metronome_bpm < state.audio.metronome_min_bpm ||
state.audio.metronome_bpm > state.audio.metronome_max_bpm) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "metronome BPM is out of range"));
} }
if (state.audio.tempo_editing && state.mode != vamp_zero::app_mode::SETTINGS_MENU) { if (state.audio.tempo_editing && state.mode != vamp_zero::app_mode::SETTINGS_MENU) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "tempo edit mode requires settings")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "tempo edit mode requires settings"));
+218
View File
@@ -0,0 +1,218 @@
#include <vamp_zero/performance.hpp>
#include <vamp_zero/errors.hpp>
#include <algorithm>
#include <array>
#include <fstream>
#include <numeric>
#include <set>
#include <boost/multiprecision/cpp_int.hpp>
#include <openssl/evp.h>
namespace {
auto invalid(std::string message) -> std::unexpected<modulus::error> {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "performance: " + std::move(message)));
}
auto number(boost::json::value const &value) -> std::uint64_t {
if (value.is_uint64()) {
return value.as_uint64();
}
if (value.is_int64() && value.as_int64() >= 0) {
return static_cast<std::uint64_t>(value.as_int64());
}
throw std::invalid_argument("expected nonnegative integer");
}
auto verified_path(vamp_zero::audio_asset const &asset, std::filesystem::path const &root) -> std::expected<std::filesystem::path, modulus::error> {
auto relative = std::filesystem::path{asset.path};
if (relative.empty() || relative.is_absolute() || relative.extension() != ".wav" || asset.path.find('\\') != std::string::npos || asset.path.find('\0') != std::string::npos || std::any_of(relative.begin(), relative.end(), [](auto const &part) { return part == "." || part == ".."; })) {
return invalid("unsafe recording path");
}
auto error = std::error_code{};
auto base = std::filesystem::canonical(root, error);
if (error) {
return invalid("asset root is missing");
}
auto path = std::filesystem::canonical(base / relative, error);
if (error || path.lexically_relative(base).empty() || *path.lexically_relative(base).begin() == "..") {
return invalid("recording is missing or outside assets");
}
auto input = std::ifstream{path, std::ios::binary};
auto context = std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)>{EVP_MD_CTX_new(), EVP_MD_CTX_free};
if (!input || !context || EVP_DigestInit_ex(context.get(), EVP_sha256(), nullptr) != 1) {
return invalid("cannot read recording");
}
auto buffer = std::array<char, 65536>{};
while (input.read(buffer.data(), buffer.size()) || input.gcount()) {
if (EVP_DigestUpdate(context.get(), buffer.data(), static_cast<std::size_t>(input.gcount())) != 1) {
return invalid("cannot verify recording");
}
}
if (!input.eof()) {
return invalid("recording read failed");
}
auto digest = std::array<unsigned char, EVP_MAX_MD_SIZE>{};
unsigned length{};
if (EVP_DigestFinal_ex(context.get(), digest.data(), &length) != 1) {
return invalid("cannot verify recording");
}
std::string hash;
constexpr std::string_view hex{"0123456789abcdef"};
for (unsigned i = 0; i < length; ++i) {
hash += hex[digest[i] >> 4];
hash += hex[digest[i] & 15];
}
if (hash != asset.sha256) {
return invalid("recording checksum mismatch: " + asset.path);
}
return path;
}
}
auto vamp_zero::automatic(vamp_zero::audio_state const &audio) -> bool {
return audio.backing_enabled || audio.metronome_enabled;
}
auto vamp_zero::parse_beat(boost::json::value const &value) -> std::expected<vamp_zero::beat_position, modulus::error> {
try {
auto const &object = value.as_object();
auto n = number(object.at("numerator")), d = number(object.at("denominator"));
if (n > INT64_MAX || d == 0 || d > INT64_MAX || std::gcd(n, d) != 1 || boost::multiprecision::uint128_t{n} > boost::multiprecision::uint128_t{d} * 86400U) {
return invalid("invalid beat position");
}
return vamp_zero::beat_position{n, d};
} catch (...) {
return invalid("expected rational beat position");
}
}
auto vamp_zero::frame_at(vamp_zero::beat_position beat, std::uint32_t bpm) -> std::uint64_t {
auto divisor = boost::multiprecision::uint128_t{beat.denominator} * bpm;
return ((boost::multiprecision::uint128_t{beat.numerator} * 48000U * 60U + divisor / 2U) / divisor).convert_to<std::uint64_t>();
}
auto vamp_zero::available_tempos(vamp_zero::app_state const &state) -> std::vector<std::uint32_t> {
auto result = std::vector<std::uint32_t>{};
for (std::uint32_t bpm = 60; bpm <= 120; bpm += 10) {
if (!state.audio.backing_enabled) {
result.push_back(bpm);
continue;
}
if (!state.catalog || state.catalog->parts.empty()) {
continue;
}
auto all = std::all_of(state.catalog->parts.begin(), state.catalog->parts.end(), [&](auto const &part) {
auto const &id = part.song_id;
return !id.empty() && std::any_of(state.catalog->audio_assets.begin(), state.catalog->audio_assets.end(), [&](auto const &asset) { return asset.song_id == id && asset.bpm == bpm; });
});
if (all) {
result.push_back(bpm);
}
}
return result;
}
auto vamp_zero::parse_audio_assets(std::string_view json) -> std::expected<std::vector<vamp_zero::audio_asset>, modulus::error> {
try {
auto value = boost::json::parse(json);
auto result = std::vector<vamp_zero::audio_asset>{};
auto paths = std::set<std::string>{};
auto recordings = std::set<std::pair<std::string, std::uint32_t>>{};
for (auto const &item : value.as_array()) {
auto const &o = item.as_object();
auto asset = vamp_zero::audio_asset{std::string{o.at("path").as_string()}, std::string{o.at("sha256").as_string()}, std::string{o.at("song_id").as_string()}};
auto bpm = number(o.at("bpm")), rate = number(o.at("sample_rate")), channels = number(o.at("channels"));
asset.frames = number(o.at("frames"));
if (rate < 8000 || rate > 192000 || channels < 1 || channels > 2 || !asset.frames || asset.sha256.size() != 64 || !std::all_of(asset.sha256.begin(), asset.sha256.end(), [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); }) || !paths.insert(asset.path).second) {
return invalid("invalid recording metadata");
}
if (asset.song_id.empty() ? (bpm != 0 || (asset.path != "sounds/beat1.wav" && asset.path != "sounds/beat2.wav")) : (bpm < 60 || bpm > 120 || bpm % 10 || asset.path != "backing/" + asset.song_id + "_" + std::to_string(bpm) + ".wav" || !recordings.emplace(asset.song_id, bpm).second)) {
return invalid("invalid recording identity");
}
asset.bpm = static_cast<std::uint32_t>(bpm);
asset.sample_rate = static_cast<std::uint32_t>(rate);
asset.channels = static_cast<std::uint32_t>(channels);
result.push_back(std::move(asset));
}
return result;
} catch (...) {
return invalid("invalid recording manifest");
}
}
auto vamp_zero::prepare_performance(vamp_zero::score_document const &score, vamp_zero::app_state const &state, std::filesystem::path const &assets, std::float32_t volume) -> std::expected<sonus::prepared_transport, modulus::error> {
auto tempos = available_tempos(state);
auto bpm = state.audio.tempo_bpm;
if (!automatic(state.audio) || std::find(tempos.begin(), tempos.end(), bpm) == tempos.end()) {
return invalid("tempo unavailable");
}
auto count_in = frame_at({score.count_in_beats, 1}, bpm);
auto config = sonus::transport_config{.duration_frames = count_in + frame_at(score.duration, bpm), .click_volume = volume};
auto resolve = [&](std::string const &name) -> std::expected<std::filesystem::path, modulus::error> {
for (auto const &asset : state.catalog->audio_assets) {
if (asset.path == name) {
return verified_path(asset, assets);
}
}
return invalid("missing recording: " + name);
};
if (state.audio.backing_enabled) {
auto path = resolve("backing/" + score.song_id + "_" + std::to_string(bpm) + ".wav");
if (!path) {
return std::unexpected(path.error());
}
config.backing = std::move(*path);
}
if (state.audio.metronome_enabled) {
auto first = resolve("sounds/beat1.wav"), other = resolve("sounds/beat2.wav");
if (!first || !other) {
return !first ? std::unexpected(first.error()) : std::unexpected(other.error());
}
config.beat1 = *first;
config.beat2 = *other;
if (!state.audio.backing_enabled) {
for (std::uint32_t beat = 0; beat < score.count_in_beats; ++beat) {
config.clicks.push_back({frame_at({beat, 1}, bpm), beat == 0});
}
}
try {
auto value = boost::json::parse(score.source_json);
for (auto const &item : value.at("performance").at("clicks").as_array()) {
auto beat = parse_beat(item.at("beat"));
if (!beat) {
return std::unexpected(beat.error());
}
config.clicks.push_back({count_in + frame_at(*beat, bpm), item.at("downbeat").as_bool()});
}
} catch (...) {
return invalid("invalid click timing");
}
}
return sonus::prepared_transport::create(std::move(config), 48000, 2);
}
auto vamp_zero::performance_at(vamp_zero::score_document const &score, std::uint64_t frames, std::uint32_t bpm) -> vamp_zero::performance_position {
auto count_in = frame_at({score.count_in_beats, 1}, bpm);
if (frames < count_in) {
return {0, "Count-in"};
}
auto beat_frames = frames - count_in;
auto page = std::size_t{};
while (page + 1 < score.pages.size() && frame_at(score.pages[page + 1].start_beat, bpm) <= beat_frames) {
++page;
}
return {page, beat_frames < frame_at(score.duration, bpm) ? "Playing" : "Sustain"};
}
auto vamp_zero::validate_audio_assets(vamp_zero::catalog const &catalog, std::filesystem::path const &assets) -> std::expected<void, modulus::error> {
for (auto const &asset : catalog.audio_assets) {
auto path = verified_path(asset, assets);
if (!path) {
return std::unexpected(path.error());
}
}
return {};
}
-222
View File
@@ -1,222 +0,0 @@
#include <vamp_zero/playback.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <fstream>
#include <numeric>
#include <string>
#include <unordered_map>
#include <boost/json.hpp>
#include <boost/multiprecision/cpp_int.hpp>
#include <openssl/sha.h>
#include <vamp_zero/errors.hpp>
namespace {
auto invalid(std::string message) -> modulus::error {
return vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "playback: " + std::move(message));
}
auto number(boost::json::object const &object, std::string_view key) -> std::expected<std::uint64_t, modulus::error> {
auto value = object.if_contains(key);
if (value && value->is_uint64()) {
return value->as_uint64();
}
if (value && value->is_int64() && value->as_int64() >= 0) {
return static_cast<std::uint64_t>(value->as_int64());
}
return std::unexpected(invalid(std::string{key} + " must be a nonnegative integer"));
}
auto text(boost::json::object const &object, std::string_view key) -> std::expected<std::string, modulus::error> {
auto value = object.if_contains(key);
if (value && value->is_string()) {
return std::string{value->as_string()};
}
return std::unexpected(invalid(std::string{key} + " must be a string"));
}
auto frame_at(boost::json::object const &object, std::string_view key, std::uint32_t bpm) -> std::expected<std::uint64_t, modulus::error> {
auto value = object.if_contains(key);
if (!value || !value->is_object()) {
return std::unexpected(invalid(std::string{key} + " must be a rational beat position"));
}
auto numerator = number(value->as_object(), "numerator");
auto denominator = number(value->as_object(), "denominator");
if (!numerator || !denominator || *denominator == 0U || *numerator > INT64_MAX || *denominator > INT64_MAX || std::gcd(*numerator, *denominator) != 1U) {
return std::unexpected(invalid("invalid reduced beat position"));
}
boost::multiprecision::uint128_t divisor = boost::multiprecision::uint128_t{*denominator} * bpm;
boost::multiprecision::uint128_t frames = (boost::multiprecision::uint128_t{*numerator} * 48000U * 60U + divisor / 2U) / divisor;
if (frames > std::uint64_t{48000U} * 86400U) {
return std::unexpected(invalid("sequence exceeds 24 hours"));
}
return frames.convert_to<std::uint64_t>();
}
auto sample(boost::json::object const &descriptor, std::filesystem::path const &assets) -> std::expected<sonus::sample_buffer, modulus::error> {
auto name = text(descriptor, "path");
auto hash = text(descriptor, "sha256");
auto rate = number(descriptor, "sample_rate");
auto frames = number(descriptor, "frames");
if (!name || !hash || !rate || !frames || *rate != 48000U || *frames == 0U || *frames > 48000U * 2U || hash->size() != 64U) {
return std::unexpected(invalid("invalid sample manifest"));
}
auto relative = std::filesystem::path{*name};
if (relative.is_absolute() || name->find('\0') != std::string::npos || name->find('\\') != std::string::npos || relative.extension() != ".wav" || relative.begin() == relative.end() || *relative.begin() != "audio" || std::any_of(relative.begin(), relative.end(), [](auto const &part) { return part == ".." || part == "."; })) {
return std::unexpected(invalid("sample must be a safe audio/*.wav asset"));
}
auto error = std::error_code{};
auto root = std::filesystem::canonical(assets, error);
if (error) {
return std::unexpected(invalid("asset root is missing"));
}
auto path = std::filesystem::canonical(root / relative, error);
if (error || path.lexically_relative(root).empty() || *path.lexically_relative(root).begin() == "..") {
return std::unexpected(invalid("sample is missing or escapes asset root"));
}
auto size = std::filesystem::file_size(path, error);
if (error || size != 44U + *frames * 2U) {
return std::unexpected(invalid("sample PCM length does not match manifest"));
}
auto bytes = std::vector<std::uchar_t>(static_cast<std::size_t>(size));
auto input = std::ifstream{path, std::ios::binary};
if (!input.read(reinterpret_cast<std::char_t *>(bytes.data()), static_cast<std::streamsize>(bytes.size()))) {
return std::unexpected(invalid("failed to read sample"));
}
auto digest = std::array<std::uchar_t, SHA256_DIGEST_LENGTH>{};
SHA256(bytes.data(), bytes.size(), digest.data());
auto actual = std::string{};
constexpr auto hex = std::string_view{"0123456789abcdef"};
for (auto byte : digest) {
actual.push_back(hex[byte >> 4U]);
actual.push_back(hex[byte & 15U]);
}
if (actual != *hash) {
return std::unexpected(invalid("sample checksum mismatch"));
}
auto word = [&](std::size_t offset) -> std::uint32_t {
return static_cast<std::uint32_t>(bytes[offset]) | static_cast<std::uint32_t>(bytes[offset + 1U]) << 8U;
};
auto dword = [&](std::size_t offset) -> std::uint32_t { return word(offset) | word(offset + 2U) << 16U; };
auto tag = [&](std::size_t offset, std::string_view expected) -> std::bool_t {
return std::equal(expected.begin(), expected.end(), bytes.begin() + static_cast<std::ptrdiff_t>(offset));
};
if (!tag(0U, "RIFF") || dword(4U) != size - 8U || !tag(8U, "WAVEfmt ") || dword(16U) != 16U || word(20U) != 1U || word(22U) != 1U || dword(24U) != 48000U || dword(28U) != 96000U || word(32U) != 2U || word(34U) != 16U || !tag(36U, "data") || dword(40U) != *frames * 2U) {
return std::unexpected(invalid("expected generated mono 48 kHz PCM16 WAV"));
}
auto result = sonus::sample_buffer{.sample_rate = 48000U};
result.frames.reserve(*frames);
for (auto index = std::size_t{44U}; index < bytes.size(); index += 2U) {
auto raw = static_cast<std::int32_t>(word(index));
auto value = raw >= 32768 ? raw - 65536 : raw;
result.frames.push_back(static_cast<std::float32_t>(value) / 32767.0F);
}
return result;
}
}
auto vamp_zero::prepare_playback(vamp_zero::score_document const &score, std::filesystem::path const &assets, std::uint32_t bpm) -> std::expected<sonus::sample_sequence, modulus::error> {
if (bpm == 0U || bpm > 400U) {
return std::unexpected(invalid("BPM must be between 1 and 400"));
}
auto error = boost::system::error_code{};
auto root = boost::json::parse(score.source_json, error);
auto playback = !error && root.is_object() ? root.as_object().if_contains("playback") : nullptr;
if (!playback || !playback->is_object()) {
return std::unexpected(invalid("regenerate this score to enable playback"));
}
auto const &object = playback->as_object();
auto available = object.if_contains("available");
if (!available || !available->is_bool()) {
return std::unexpected(invalid("unsupported playback manifest"));
}
if (!available->as_bool()) {
return std::unexpected(invalid(text(object, "reason").value_or("not available")));
}
auto duration = frame_at(object, "duration", bpm);
auto samples = object.if_contains("samples");
auto events = object.if_contains("events");
auto release = object.if_contains("release");
if (!duration || *duration == 0U || !samples || !samples->is_array() || samples->as_array().size() > 512U || !events || !events->is_array() || events->as_array().size() > 1000000U || !release || !release->is_object()) {
return std::unexpected(invalid("invalid timeline, samples or release envelope"));
}
auto result = sonus::sample_sequence{.duration_frames = *duration};
auto envelope = sample(release->as_object(), assets);
if (!envelope || envelope->frames.size() > 24000U || envelope->frames.front() != 1.0F || envelope->frames.back() != 0.0F || !std::is_sorted(envelope->frames.rbegin(), envelope->frames.rend()) || envelope->frames.back() < 0.0F) {
return std::unexpected(envelope ? invalid("invalid release envelope") : envelope.error());
}
result.release_envelope = std::move(envelope->frames);
auto handles = std::unordered_map<std::string, std::uint32_t>{};
auto total_frames = std::uint64_t{};
for (auto const &entry : samples->as_array()) {
if (!entry.is_object()) {
return std::unexpected(invalid("sample descriptor must be an object"));
}
auto id = text(entry.as_object(), "id");
auto first = number(entry.as_object(), "loop_start");
auto last = number(entry.as_object(), "loop_end");
if (!id || id->empty() || handles.contains(*id) || !first || !last || *first >= *last) {
return std::unexpected(invalid("invalid sample identity or sustain loop"));
}
auto loaded = sample(entry.as_object(), assets);
if (!loaded) {
return std::unexpected(loaded.error());
}
total_frames += loaded->frames.size();
if (*last != loaded->frames.size() || total_frames > 16U * 1024U * 1024U) {
return std::unexpected(invalid("invalid loop endpoint or sample memory limit"));
}
loaded->loop_start = *first;
loaded->loop_end = *last;
handles.emplace(*id, static_cast<std::uint32_t>(result.samples.size()));
result.samples.push_back(std::move(*loaded));
}
auto previous = std::uint64_t{};
for (auto const &entry : events->as_array()) {
if (!entry.is_object()) {
return std::unexpected(invalid("sample event must be an object"));
}
auto id = text(entry.as_object(), "sample");
auto start = frame_at(entry.as_object(), "onset", bpm);
auto end = frame_at(entry.as_object(), "end", bpm);
if (!id || !handles.contains(*id) || !start || !end || *start < previous || *end <= *start || *end > *duration) {
return std::unexpected(invalid("invalid sample reference or unrenderable event timing"));
}
result.events.push_back({.sample = handles.at(*id), .start_frame = *start, .duration_frames = *end - *start});
previous = *start;
}
return result;
}
auto vamp_zero::validate_playback_assets(vamp_zero::score_document const &score, std::filesystem::path const &assets) -> std::expected<void, modulus::error> {
auto error = boost::system::error_code{};
auto root = boost::json::parse(score.source_json, error);
auto playback = !error && root.is_object() ? root.as_object().if_contains("playback") : nullptr;
if (!playback) {
return {};
}
if (playback->is_object()) {
auto available = playback->as_object().if_contains("available");
if (available && available->is_bool() && !available->as_bool()) {
auto reason = text(playback->as_object(), "reason");
if (reason && !reason->empty()) {
return {};
}
}
}
auto prepared = prepare_playback(score, assets, 120U);
if (!prepared) {
return std::unexpected(prepared.error());
}
auto sequence = sonus::prepared_sequence::create(std::move(*prepared), 48000U);
if (!sequence) {
return std::unexpected(sequence.error());
}
return {};
}
+11
View File
@@ -136,3 +136,14 @@ auto vamp_zero::selected_score_reference(vamp_zero::app_state const &state) -> s
} }
return std::nullopt; return std::nullopt;
} }
auto vamp_zero::score_loader::pages_prepared(std::shared_ptr<vamp_zero::score_document const> const &document, std::expected<void, modulus::error> result) -> void {
auto lock = std::scoped_lock{state_->mutex};
if (state_->stopped || !document || state_->value.document != document) {
return;
}
state_->value.pages_ready = result.has_value();
if (!result) {
state_->value.error = result.error();
}
}
+11 -3
View File
@@ -69,6 +69,12 @@ struct score_draw_cache {
if (!texture) { if (!texture) {
return std::unexpected(texture.error()); return std::unexpected(texture.error());
} }
if (index + 1 < score->pages.size()) {
auto next = textures.get(score->pages[index + 1].path);
if (!next) {
return std::unexpected(next.error());
}
}
auto scale = std::min(bounds.width / 976.0F, bounds.height / 484.0F); auto scale = std::min(bounds.width / 976.0F, bounds.height / 484.0F);
auto page = luma::rect{.x = bounds.x + (bounds.width - 976.0F * scale) / 2.0F, auto page = luma::rect{.x = bounds.x + (bounds.width - 976.0F * scale) / 2.0F,
.y = bounds.y + (bounds.height - 484.0F * scale) / 2.0F, .y = bounds.y + (bounds.height - 484.0F * scale) / 2.0F,
@@ -82,7 +88,7 @@ struct score_draw_cache {
std::weak_ptr<vamp_zero::score_document const> document{}; std::weak_ptr<vamp_zero::score_document const> document{};
}; };
auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state, vamp_zero::score_load_state const &loaded, score_draw_cache &score_cache) -> void { auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state, vamp_zero::score_load_state const &loaded, score_draw_cache &score_cache, vamp_zero::score_loader &loader) -> void {
if (active_part(state) == nullptr) { if (active_part(state) == nullptr) {
vamp_zero::draw_menu(context, bounds, state); vamp_zero::draw_menu(context, bounds, state);
return; return;
@@ -102,7 +108,9 @@ auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_s
.width = bounds.width - 48.0F, .width = bounds.width - 48.0F,
.height = bounds.height - 36.0F, .height = bounds.height - 36.0F,
}; };
if (auto status = score_cache.draw(context, paper_bounds, state.page_index); !status) { auto status = score_cache.draw(context, paper_bounds, state.page_index);
loader.pages_prepared(loaded.document, status);
if (!status) {
vamp_zero::draw_label(context, {bounds.x + 36.0F, bounds.y + 38.0F, bounds.width - 72.0F, 48.0F}, "SCORE PAGE ERROR", 26.0F, vamp_style::danger); vamp_zero::draw_label(context, {bounds.x + 36.0F, bounds.y + 38.0F, bounds.width - 72.0F, 48.0F}, "SCORE PAGE ERROR", 26.0F, vamp_style::danger);
vamp_zero::draw_label(context, {bounds.x + 38.0F, bounds.y + 86.0F, bounds.width - 72.0F, 32.0F}, status.error().describe(), 17.0F, vamp_style::muted); vamp_zero::draw_label(context, {bounds.x + 38.0F, bounds.y + 86.0F, bounds.width - 72.0F, 32.0F}, status.error().describe(), 17.0F, vamp_style::muted);
} }
@@ -134,7 +142,7 @@ public:
.height = std::max<std::float32_t>(0.0F, area.height - 80.0F), .height = std::max<std::float32_t>(0.0F, area.height - 80.0F),
}; };
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) { if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
draw_score(context, content, state, loaded, score_cache_); draw_score(context, content, state, loaded, score_cache_, *loader_);
} else { } else {
vamp_zero::draw_menu(context, content, state); vamp_zero::draw_menu(context, content, state);
} }
+8 -8
View File
@@ -9,7 +9,7 @@ add_executable(product_vamp_test
test_menu.cpp test_menu.cpp
test_pico.cpp test_pico.cpp
test_control_artwork.cpp test_control_artwork.cpp
test_playback.cpp test_performance.cpp
test_score_loader.cpp test_score_loader.cpp
test_touch_input.cpp test_touch_input.cpp
test_ui.cpp test_ui.cpp
@@ -29,7 +29,7 @@ target_compile_definitions(product_vamp_test PRIVATE
VAMP_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}" VAMP_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
VAMP_TEST_PYTHON="${Python3_EXECUTABLE}" VAMP_TEST_PYTHON="${Python3_EXECUTABLE}"
VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated_assets" VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated_assets"
VAMP_TEST_PLAYBACK_ASSETS_DIR="${CMAKE_CURRENT_BINARY_DIR}/playback_assets" VAMP_TEST_PERFORMANCE_ASSETS_DIR="${CMAKE_CURRENT_BINARY_DIR}/performance_assets"
VAMP_TEST_UI_DIR="${CMAKE_CURRENT_BINARY_DIR}/ui_previews" VAMP_TEST_UI_DIR="${CMAKE_CURRENT_BINARY_DIR}/ui_previews"
) )
add_custom_command( add_custom_command(
@@ -41,16 +41,16 @@ add_custom_command(
) )
add_custom_target(vamp_ui_test_assets DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/ui_previews/static.p4) add_custom_target(vamp_ui_test_assets DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/ui_previews/static.p4)
add_dependencies(product_vamp_test vamp_ui_test_assets) add_dependencies(product_vamp_test vamp_ui_test_assets)
file(GLOB_RECURSE VAMP_PLAYBACK_TEST_INPUTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/generator/*.py) file(GLOB_RECURSE VAMP_PERFORMANCE_TEST_INPUTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/generator/*.py)
add_custom_command( add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/playback_assets/score.json OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/performance_assets/score.json
COMMAND ${VAMP_PYTHON_COMMAND} -m tests.test_playback --fixture ${CMAKE_CURRENT_BINARY_DIR}/playback_assets COMMAND ${VAMP_PYTHON_COMMAND} -m tests.test_performance --fixture ${CMAKE_CURRENT_BINARY_DIR}/performance_assets
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
DEPENDS test_playback.py test_musicxml.py ${VAMP_PLAYBACK_TEST_INPUTS} DEPENDS test_performance.py test_musicxml.py ${VAMP_PERFORMANCE_TEST_INPUTS}
VERBATIM VERBATIM
) )
add_custom_target(vamp_playback_test_assets DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/playback_assets/score.json) add_custom_target(vamp_performance_test_assets DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/performance_assets/score.json)
add_dependencies(product_vamp_test vamp_playback_test_assets) add_dependencies(product_vamp_test vamp_performance_test_assets)
add_test(NAME product_vamp COMMAND product_vamp_test) add_test(NAME product_vamp COMMAND product_vamp_test)
set_tests_properties(product_vamp PROPERTIES TIMEOUT 180) set_tests_properties(product_vamp PROPERTIES TIMEOUT 180)
add_test( add_test(
+10 -7
View File
@@ -1,11 +1,14 @@
import unittest.mock import unittest.mock
from generator import comping, vocabulary, parts import generator.comping
from generator.arrange import form, generate import generator.vocabulary
import generator.parts
import generator.arrange.form
import generator.arrange.generate
def section_document(document, part_type): def section_document(document, part_type):
prepared=parts.prepare_document(document) prepared = generator.parts.prepare_document(document)
role = {'acoustic': 'accompaniment', 'electric': 'melody', 'vocal': 'vocal'}[part_type] role = {'acoustic': 'accompaniment', 'electric': 'melody', 'vocal': 'vocal'}[part_type]
section=form.ArrangementSection('',0,len(prepared['staves'][0]['measures']),0,role) section = generator.arrange.form.ArrangementSection('', 0, len(prepared['staves'][0]['measures']), 0, role)
with unittest.mock.patch('generator.arrange.form.full_arrangement_sections',return_value=[section]), vocabulary.using(vocabulary.load(),part_type+'_accompaniment'): with unittest.mock.patch('generator.arrange.form.full_arrangement_sections', return_value=[section]), generator.vocabulary.using(generator.vocabulary.load(), part_type + '_accompaniment'):
backing=comping.Canonical(prepared,part_type,'fixture') if part_type=='acoustic' else None backing = generator.comping.Canonical(prepared, part_type, 'fixture') if part_type == 'acoustic' else None
return generate._arrange_document(prepared,part_type,accompaniment=backing) return generator.arrange.generate._arrange_document(prepared, part_type, accompaniment=backing)
+30
View File
@@ -8,12 +8,42 @@
#include <unistd.h> #include <unistd.h>
#include <boost/test/unit_test.hpp> #include <boost/test/unit_test.hpp>
#include <boost/json.hpp>
#include <modulus/types.hpp> #include <modulus/types.hpp>
namespace vamp_test { namespace vamp_test {
inline auto add_timing(boost::json::value &value) -> void {
auto &music = value.at("music");
music.at("metadata").as_object()["song_id"] = "fixture";
auto &measures = music.at("staves").as_array().front().at("measures").as_array();
auto &pages = value.at("presentation").at("pages").as_array();
while (measures.size() < pages.size()) {
measures.push_back(boost::json::object{});
}
auto beat = [](std::size_t n) { return boost::json::object{{"numerator", n}, {"denominator", 1}}; };
auto clicks = boost::json::array{};
for (std::size_t i = 0; i < measures.size(); ++i) {
measures[i].as_object()["duration"] = beat(4);
for (std::size_t b = 0; b < 4; ++b) {
clicks.push_back(boost::json::object{{"beat", beat(i * 4 + b)}, {"downbeat", b == 0}});
}
}
std::size_t cursor{};
for (std::size_t i = 0; i < pages.size(); ++i) {
auto count = i + 1 == pages.size() ? measures.size() - cursor : 1U;
auto &page = pages[i].as_object();
page["first_measure"] = cursor + 1;
page["measure_count"] = count;
page["start_beat"] = beat(cursor * 4);
page["end_beat"] = beat((cursor + count) * 4);
cursor += count;
}
value.as_object()["performance"] = boost::json::object{{"song_id", "fixture"}, {"duration", beat(measures.size() * 4)}, {"count_in_beats", 4}, {"clicks", clicks}};
}
inline auto build_catalog(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();
+12 -725
View File
@@ -1,10 +1,10 @@
{ {
"all_of_me_acoustic.json": { "all_of_me_acoustic.json": {
"title": "all-of-me", "title": "All Of Me",
"composer": "", "composer": "",
"key_signature": "G", "key_signature": "G",
"part_type": "acoustic", "part_type": "acoustic",
"measure_count": 176, "measure_count": 168,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -29,10 +29,6 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -1824,10 +1820,6 @@
{ {
"onset": "0", "onset": "0",
"symbol": "GM6" "symbol": "GM6"
},
{
"onset": "2",
"symbol": "(Bbdim7)"
} }
], ],
"duration": "4", "duration": "4",
@@ -1836,110 +1828,6 @@
{ {
"measure": 168, "measure": 168,
"section_label": "", "section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "(Am7)"
},
{
"onset": "2",
"symbol": "(D7)"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "CM6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Cm6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "GM7"
},
{
"onset": "2",
"symbol": "Bm7b5/F"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 172,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "E7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 173,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Am7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 174,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "D7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 175,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "GM6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 176,
"section_label": "",
"harmonies": [], "harmonies": [],
"duration": "4", "duration": "4",
"slash_count": 0 "slash_count": 0
@@ -1947,11 +1835,11 @@
] ]
}, },
"all_of_me_electric.json": { "all_of_me_electric.json": {
"title": "all-of-me", "title": "All Of Me",
"composer": "", "composer": "",
"key_signature": "G", "key_signature": "G",
"part_type": "electric", "part_type": "electric",
"measure_count": 176, "measure_count": 168,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -1976,10 +1864,6 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -3771,10 +3655,6 @@
{ {
"onset": "0", "onset": "0",
"symbol": "GM6" "symbol": "GM6"
},
{
"onset": "2",
"symbol": "(Bbdim7)"
} }
], ],
"duration": "4", "duration": "4",
@@ -3783,110 +3663,6 @@
{ {
"measure": 168, "measure": 168,
"section_label": "", "section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "(Am7)"
},
{
"onset": "2",
"symbol": "(D7)"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "CM6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Cm6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "GM7"
},
{
"onset": "2",
"symbol": "Bm7b5/F"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 172,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "E7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 173,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Am7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 174,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "D7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 175,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "GM6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 176,
"section_label": "",
"harmonies": [], "harmonies": [],
"duration": "4", "duration": "4",
"slash_count": 0 "slash_count": 0
@@ -3894,11 +3670,11 @@
] ]
}, },
"all_of_me_vocal.json": { "all_of_me_vocal.json": {
"title": "all-of-me", "title": "All Of Me",
"composer": "", "composer": "",
"key_signature": "G", "key_signature": "G",
"part_type": "vocal", "part_type": "vocal",
"measure_count": 176, "measure_count": 168,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -3923,10 +3699,6 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -6322,10 +6094,6 @@
{ {
"onset": "0", "onset": "0",
"symbol": "GM6" "symbol": "GM6"
},
{
"onset": "2",
"symbol": "(Bbdim7)"
} }
], ],
"duration": "4", "duration": "4",
@@ -6338,145 +6106,6 @@
{ {
"measure": 168, "measure": 168,
"section_label": "", "section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "(Am7)"
},
{
"onset": "2",
"symbol": "(D7)"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note G4 ",
"2+2 half note G4 "
]
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "CM6"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note A4 You",
"2+1 quarter note G4 took",
"3+1 quarter note F#4 the"
]
},
{
"measure": 170,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Cm6"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+3 half. note A4 part",
"3+1 quarter note G4 that"
]
},
{
"measure": 171,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "GM7"
},
{
"onset": "2",
"symbol": "Bm7b5/F"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note F#4 once",
"2+1 quarter note B3 was",
"3+1 quarter note D4 my"
]
},
{
"measure": 172,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "E7"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+3 half. note F#4 heart,",
"3+1 quarter note D4 so"
]
},
{
"measure": 173,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Am7"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note G4 why",
"2+1 quarter note E4 not",
"3+1 quarter note G4 take"
]
},
{
"measure": 174,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "D7"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note B4 all",
"2+2 half note B4 of"
]
},
{
"measure": 175,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "GM6"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note G4 me?",
"2+2 half note G4 "
]
},
{
"measure": 176,
"section_label": "",
"harmonies": [], "harmonies": [],
"duration": "4", "duration": "4",
"slash_count": 0, "slash_count": 0,
@@ -6488,11 +6117,11 @@
] ]
}, },
"autumn_leaves_acoustic.json": { "autumn_leaves_acoustic.json": {
"title": "autumn_leaves", "title": "Autumn Leaves",
"composer": "", "composer": "",
"key_signature": "D minor", "key_signature": "D minor",
"part_type": "acoustic", "part_type": "acoustic",
"measure_count": 176, "measure_count": 168,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -6517,10 +6146,6 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -8457,114 +8082,15 @@
"harmonies": [], "harmonies": [],
"duration": "4", "duration": "4",
"slash_count": 0 "slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "Em7b5"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "A7b9"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Dm7"
},
{
"onset": "2",
"symbol": "G7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 172,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Cm7"
},
{
"onset": "2",
"symbol": "F7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 173,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Em7b5"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 174,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "A7b9"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 175,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Dm"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 176,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
} }
] ]
}, },
"autumn_leaves_electric.json": { "autumn_leaves_electric.json": {
"title": "autumn_leaves", "title": "Autumn Leaves",
"composer": "", "composer": "",
"key_signature": "D minor", "key_signature": "D minor",
"part_type": "electric", "part_type": "electric",
"measure_count": 176, "measure_count": 168,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -8589,10 +8115,6 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -10529,114 +10051,15 @@
"harmonies": [], "harmonies": [],
"duration": "4", "duration": "4",
"slash_count": 0 "slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "Em7b5"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "A7b9"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Dm7"
},
{
"onset": "2",
"symbol": "G7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 172,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Cm7"
},
{
"onset": "2",
"symbol": "F7"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 173,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Em7b5"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 174,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "A7b9"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 175,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Dm"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 176,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
} }
] ]
}, },
"autumn_leaves_vocal.json": { "autumn_leaves_vocal.json": {
"title": "autumn_leaves", "title": "Autumn Leaves",
"composer": "", "composer": "",
"key_signature": "D minor", "key_signature": "D minor",
"part_type": "vocal", "part_type": "vocal",
"measure_count": 176, "measure_count": 168,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -10661,10 +10084,6 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -13209,138 +12628,6 @@
"vocal_skeleton": [ "vocal_skeleton": [
"0+4 whole rest " "0+4 whole rest "
] ]
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "Em7b5"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+1 quarter note Bb4 miss",
"1+1 quarter note Bb4 you",
"2+1 quarter note G4 most",
"3+1 quarter note G4 of"
]
},
{
"measure": 170,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "A7b9"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+3 half. note E4 all,",
"3+1 quarter note Bb4 my"
]
},
{
"measure": 171,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Dm7"
},
{
"onset": "2",
"symbol": "G7"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note A4 dar-",
"2+2 half note A4 ling,"
]
},
{
"measure": 172,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Cm7"
},
{
"onset": "2",
"symbol": "F7"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note A4 ",
"2+2 half note D4 when"
]
},
{
"measure": 173,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Em7b5"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+3 half. note G4 au-",
"3+1 quarter note F4 tumn"
]
},
{
"measure": 174,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "A7b9"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+2 half note E4 leaves",
"2+1 quarter note F4 start",
"3+1 quarter note A3 to"
]
},
{
"measure": 175,
"section_label": "",
"harmonies": [
{
"onset": "0",
"symbol": "Dm"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole note D4 fall."
]
},
{
"measure": 176,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
} }
] ]
} }
+37 -27
View File
@@ -2,39 +2,49 @@
Learned decoding and authored-repertoire tests separately use the real path. Learned decoding and authored-repertoire tests separately use the real path.
""" """
from fractions import Fraction as F import fractions
from pathlib import Path import pathlib
from unittest.mock import patch import unittest.mock
from generator import production,vocabulary,harmonic_time import generator.production
from generator.solo import types,placement,profiles,decoder,learned,harmonic import generator.vocabulary
import generator.harmonic_time
import generator.solo.types
def compose(analysis,measures,part,end,*,initial=None,start=F(),**kwargs): import generator.solo.placement
import generator.solo.profiles
import generator.solo.decoder
import generator.solo.learned
import generator.solo.harmonic
import dataclasses import dataclasses
spans=tuple(dataclasses.replace(s,start=s.start+start,end=s.end+start) for s in harmonic_time.read({'measures':measures},initial=initial)) import generator.solo.expressions
h=harmonic.Harmony(spans);notes=[] import generator.harmony
def compose(analysis, measures, part, end, *, initial=None, start=fractions.Fraction(), **kwargs):
spans = tuple(dataclasses.replace(s, start=s.start + start, end=s.end + start) for s in generator.harmonic_time.read({'measures': measures}, initial=initial))
h = generator.solo.harmonic.Harmony(spans)
notes = []
for s in h.spans: for s in h.spans:
a, b = max(start, s.start), min(end, s.end) a, b = max(start, s.start), min(end, s.end)
if a>=b or s.chord=='N.C.':continue if a >= b or s.chord == 'N.C.':
continue
number = next(n for n in range(55, 72) if n % 12 in s.roles.basic) number = next(n for n in range(55, 72) if n % 12 in s.roles.basic)
from generator.solo import expressions chord = generator.solo.expressions.parsed_chord(s.chord)
chord=expressions.parsed_chord(s.chord) pitch = next(generator.solo.expressions.degree_pitch(chord, d, x, number) for d, x in generator.harmony.degrees(chord).items() if generator.solo.expressions.degree_pitch(chord, d, x, number).number() == number)
from generator import harmony notes.append(generator.solo.types.Note(a, b, pitch, event_id=str(a)))
pitch=next(expressions.degree_pitch(chord,d,x,number) for d,x in harmony.degrees(chord).items() if expressions.degree_pitch(chord,d,x,number).number()==number) placed = generator.solo.placement.place(tuple(notes), analysis.tempo)[1] if notes else ()
notes.append(types.Note(a,b,pitch,event_id=str(a))) return generator.solo.types.Solo(part, spans, (generator.solo.types.Phrase('fixture', start, end, placed, 'test', 'test', 'handoff'),), analysis.tempo, end, profile=generator.solo.profiles.for_part(part), start=start)
placed=placement.place(tuple(notes),analysis.tempo)[1] if notes else ()
return types.Solo(part,spans,(types.Phrase('fixture',start,end,placed,'test','test','handoff'),),analysis.tempo,end,profile=profiles.for_part(part),start=start)
def install(test): def install(test):
resources=vocabulary.load();predictor=learned.Model.load(Path(__file__).resolve().parents[1]/'data/models/solo.json') resources = generator.vocabulary.load()
tree=production.generate_tree predictor = generator.solo.learned.Model.load(pathlib.Path(__file__).resolve().parents[1] / 'data/models/solo.json')
tree = generator.production.generate_tree
def serial(*args, **kwargs): def serial(*args, **kwargs):
kwargs['jobs'] = 1 kwargs['jobs'] = 1
return tree(*args, **kwargs) return tree(*args, **kwargs)
test.enterContext(patch('generator.production.generate_tree',side_effect=serial))
test.enterContext(patch('generator.vocabulary.load',return_value=resources)) test.enterContext(unittest.mock.patch('generator.production.generate_tree', side_effect=serial))
test.enterContext(patch('generator.production.repertoire.lookup',return_value=None)) test.enterContext(unittest.mock.patch('generator.vocabulary.load', return_value=resources))
test.enterContext(patch('generator.production._solo_model',return_value=predictor)) test.enterContext(unittest.mock.patch('generator.solo.authored.lookup', return_value=None))
test.enterContext(patch('generator.production._resource_key',return_value={'model':'synthetic infrastructure fixture'})) test.enterContext(unittest.mock.patch('generator.production._solo_model', return_value=predictor))
test.enterContext(patch('generator.solo.composer.compose',side_effect=compose)) test.enterContext(unittest.mock.patch('generator.production._resource_key', return_value={'model': 'synthetic infrastructure fixture'}))
test.enterContext(unittest.mock.patch('generator.solo.composer.compose', side_effect=compose))
+38
View File
@@ -0,0 +1,38 @@
"""Exact notated attacks used to compare arrangements; no audio rendering."""
import fractions
import generator.model
import generator.pitch
def timeline(document):
events, linked = [], {}
absolute = fractions.Fraction()
for measure in document['staves'][0]['measures']:
for voice in measure['voices']:
state = {key: measure[key] for key in ('transpose', 'clef', 'staff_details')}
changes = iter(sorted(measure.get('attributes', []), key=lambda a: generator.model.fraction(a['onset'])))
change = next(changes, None)
for note in voice['notes']:
onset = generator.model.fraction(note['onset'])
while change is not None and generator.model.fraction(change['onset']) <= onset:
state.update({key: change[key] for key in state if key in change})
change = next(changes, None)
if note.get('cue') or note.get('grace'):
continue
for pitch in note['pitches']:
if pitch.get('cue'):
continue
length = generator.model.fraction(pitch['duration'])
value = generator.pitch.sounding_pitch(pitch, state)
if pitch.get('tie_from'):
previous = linked[pitch['tie_from']]
assert generator.model.fraction(previous['onset']) + generator.model.fraction(previous['duration']) == absolute + onset
assert generator.model.fraction(previous['pitch']) == value
previous['duration'] = generator.model.rational(generator.model.fraction(previous['duration']) + length)
linked[pitch['id']] = previous
else:
event = dict(id=pitch['id'], pitch=generator.model.rational(value), onset=generator.model.rational(absolute + onset), duration=generator.model.rational(length))
events.append(event)
linked[pitch['id']] = event
absolute += generator.model.fraction(measure['duration'])
events.sort(key=lambda e: (generator.model.fraction(e['onset']), e['id']))
return dict(duration=generator.model.rational(absolute), events=events)
+71 -154
View File
@@ -1,85 +1,93 @@
"""Read optional reference MusicXML literally, without repairing it or composing.""" """Read optional reference MusicXML literally, without repairing it or composing."""
from __future__ import annotations from __future__ import annotations
import argparse,array,collections,html,os,sys,wave import argparse, collections, html, os
import hashlib import hashlib
import json import json
from pathlib import Path import pathlib
from fractions import Fraction as F import fractions
import xml.etree.ElementTree as ET import xml.etree.ElementTree
from generator.training_io import read
from generator import fretboard
from generator.solo import placement,types
import generator.training_io
import generator.fretboard
import generator.solo.placement
import generator.solo.types
def events(review): def events(review):
offset = F(review['alignment']['encoded_offset'] or 0) offset = fractions.Fraction(review['alignment']['encoded_offset'] or 0)
return [dict(start=str(F(n['start'])-offset), end=str(F(n['end'])-offset), return [dict(start=str(fractions.Fraction(n['start']) - offset), end=str(fractions.Fraction(n['end']) - offset),
pitch=n['sounding_midi'], spelling=n['pitch'], measure=n['measure']) for n in review['sounding_events']] pitch=n['sounding_midi'], spelling=n['pitch'], measure=n['measure']) for n in review['sounding_events']]
def rests(review): def rests(review):
offset = F(review['alignment']['encoded_offset'] or 0) offset = fractions.Fraction(review['alignment']['encoded_offset'] or 0)
cursor = -offset cursor = -offset
result = [] result = []
for n in sorted(events(review), key=lambda n:F(n['start'])): for n in sorted(events(review), key=lambda n: fractions.Fraction(n['start'])):
start,end = F(n['start']),F(n['end']) start, end = fractions.Fraction(n['start']), fractions.Fraction(n['end'])
if start > cursor: result.append((str(cursor),str(start))) if start > cursor:
result.append((str(cursor), str(start)))
cursor = max(cursor, end) cursor = max(cursor, end)
finish = max((F(m['start'])+F(m['duration']) for m in review['measures']),default=F())-offset finish = max((fractions.Fraction(m['start']) + fractions.Fraction(m['duration']) for m in review['measures']), default=fractions.Fraction()) - offset
if cursor < finish: result.append((str(cursor),str(finish))) if cursor < finish:
result.append((str(cursor), str(finish)))
return result return result
def playability(review, tempo=fractions.Fraction(120)):
def playability(review, tempo=F(120)): notes = []
notes=[];conflicts=[] conflicts = []
for i, n in enumerate(review['sounding_events']): for i, n in enumerate(review['sounding_events']):
p=n['pitch'];shift=F(n['sounding_midi'])-F(n['encoded_midi']) p = n['pitch']
shift = fractions.Fraction(n['sounding_midi']) - fractions.Fraction(n['encoded_midi'])
if shift % 12: if shift % 12:
raise ValueError('Guitar audition expects octave-only transposition') raise ValueError('Guitar comparison expects octave-only transposition')
pitch=types.Pitch(p['step'].lower(),F(p['alter']),p['octave']+int(shift/12)) pitch = generator.solo.types.Pitch(p['step'].lower(), fractions.Fraction(p['alter']), p['octave'] + int(shift / 12))
note=types.Note(F(n['start']),F(n['end']),pitch,event_id=str(i)) note = generator.solo.types.Note(fractions.Fraction(n['start']), fractions.Fraction(n['end']), pitch, event_id=str(i))
notes.append(note) notes.append(note)
if not fretboard.single_positions_for_pitch(F(pitch.number())): if not generator.fretboard.single_positions_for_pitch(fractions.Fraction(pitch.number())):
conflicts.append({'measure': n['measure'], 'written': p, 'sounding_midi': pitch.number(), conflicts.append({'measure': n['measure'], 'written': p, 'sounding_midi': pitch.number(),
'constraint': 'No standard-tuning position on strings 1–6, frets 1–12, without open strings.'}) 'constraint': 'No standard-tuning position on strings 1–6, frets 1–12, without open strings.'})
overlaps = [{'first': a.event_id, 'second': b.event_id} for a, b in zip(notes, notes[1:]) if a.end > b.start] overlaps = [{'first': a.event_id, 'second': b.event_id} for a, b in zip(notes, notes[1:]) if a.end > b.start]
placed=placement.place(tuple(notes),tempo) if notes and not conflicts and not overlaps else None placed = generator.solo.placement.place(tuple(notes), tempo) if notes and not conflicts and not overlaps else None
return {'conflicts': conflicts, 'overlaps': overlaps, 'complete_path_found': placed is not None, return {'conflicts': conflicts, 'overlaps': overlaps, 'complete_path_found': placed is not None,
'range': [min(n.pitch.number() for n in notes), max(n.pitch.number() for n in notes)] if notes else [], 'range': [min(n.pitch.number() for n in notes), max(n.pitch.number() for n in notes)] if notes else [],
'positions': [list(n.position[:2]) for n in placed[1]] if placed else [], 'positions': [list(n.position[:2]) for n in placed[1]] if placed else [],
'limitation': 'Legal placement only. Shift cost is a preference, not a verified human speed limit.'} 'limitation': 'Legal placement only. Shift cost is a preference, not a verified human speed limit.'}
def musical_summary(review): def musical_summary(review):
ns = events(review) ns = events(review)
durations=collections.Counter(str(F(n['end'])-F(n['start'])) for n in ns) durations = collections.Counter(str(fractions.Fraction(n['end']) - fractions.Fraction(n['start'])) for n in ns)
intervals=collections.Counter(str(F(b['pitch'])-F(a['pitch'])) for a,b in zip(ns,ns[1:])) intervals = collections.Counter(str(fractions.Fraction(b['pitch']) - fractions.Fraction(a['pitch'])) for a, b in zip(ns, ns[1:]))
holds = [] holds = []
for event, n in zip(review['sounding_events'], ns): for event, n in zip(review['sounding_events'], ns):
if F(n['end'])-F(n['start'])<2:continue if fractions.Fraction(n['end']) - fractions.Fraction(n['start']) < 2:
continue
spans = [s for i in event['notation_events'] for s in review['notes'][i]['harmonic_spans']] spans = [s for i in event['notation_events'] for s in review['notes'][i]['harmonic_spans']]
holds.append({**n, 'harmonic_spans': spans}) holds.append({**n, 'harmonic_spans': spans})
return {'attacks': len(ns), 'duration_counts': dict(durations), 'interval_counts': dict(intervals), return {'attacks': len(ns), 'duration_counts': dict(durations), 'interval_counts': dict(intervals),
'rests': rests(review), 'sustained_events': holds, 'rests': rests(review), 'sustained_events': holds,
'annotations': review['annotations'], 'interpretation': 'Post-generation diagnostics; does not train or reselect music.'} 'annotations': review['annotations'], 'interpretation': 'Post-generation diagnostics; does not train or reselect music.'}
def compare(generated, reference): def compare(generated, reference):
a, b = events(generated), events(reference) a, b = events(generated), events(reference)
def grouped(ns): def grouped(ns):
result = collections.defaultdict(list) result = collections.defaultdict(list)
for n in ns:result[n['start']].append(n) for n in ns:
result[n['start']].append(n)
return result return result
left, right = grouped(a), grouped(b) left, right = grouped(a), grouped(b)
differences=[];spelling=[];equal=0 differences = []
for start in sorted(left.keys()|right.keys(),key=F): spelling = []
equal = 0
for start in sorted(left.keys() | right.keys(), key=fractions.Fraction):
x, y = left[start], right[start] x, y = left[start], right[start]
sounded=lambda ns:sorted((n['pitch'],n['end']) for n in ns)
def sounded(ns):
return sorted((n['pitch'], n['end']) for n in ns)
if sounded(x) != sounded(y): if sounded(x) != sounded(y):
differences.append({'time': start, 'generated': x, 'reference': y, differences.append({'time': start, 'generated': x, 'reference': y,
'context':'reference lead-in before form' if F(start)<0 else 'aligned form'}) 'context': 'reference lead-in before form' if fractions.Fraction(start) < 0 else 'aligned form'})
else: else:
equal += len(x) equal += len(x)
if [n['spelling'] for n in x] != [n['spelling'] for n in y]: if [n['spelling'] for n in x] != [n['spelling'] for n in y]:
@@ -91,9 +99,9 @@ def compare(generated,reference):
'generated': musical_summary(generated), 'reference': musical_summary(reference), 'generated': musical_summary(generated), 'reference': musical_summary(reference),
'reference_playability': playability(reference), 'reference_original': reference} 'reference_playability': playability(reference), 'reference_original': reference}
def relationships(a, b): def relationships(a, b):
"""Read-only comparisons on the aligned clock; no fitted aggregate loss.""" """Read-only comparisons on the aligned clock; no fitted aggregate loss."""
def distance(left, right): def distance(left, right):
row = list(range(len(right) + 1)) row = list(range(len(right) + 1))
for i, x in enumerate(left, 1): for i, x in enumerate(left, 1):
@@ -102,15 +110,18 @@ def relationships(a,b):
next_row.append(min(next_row[-1] + 1, row[j] + 1, row[j - 1] + (x != y))) next_row.append(min(next_row[-1] + 1, row[j] + 1, row[j - 1] + (x != y)))
row = next_row row = next_row
return row[-1] return row[-1]
ns=[[dict(start=F(n['start']),end=F(n['end']),pitch=F(n['pitch'])) for n in notes] for notes in (a,b)]
ns = [[dict(start=fractions.Fraction(n['start']), end=fractions.Fraction(n['end']), pitch=fractions.Fraction(n['pitch'])) for n in notes] for notes in (a, b)]
points = sorted({n[k] for notes in ns for n in notes for k in ('start', 'end')}) points = sorted({n[k] for notes in ns for n in notes for k in ('start', 'end')})
occupied=equal=classes=F() occupied = equal = classes = fractions.Fraction()
for start, end in zip(points, points[1:]): for start, end in zip(points, points[1:]):
pitches = [{n['pitch'] for n in notes if n['start'] <= start < n['end']} for notes in ns] pitches = [{n['pitch'] for n in notes if n['start'] <= start < n['end']} for notes in ns]
if any(pitches): if any(pitches):
occupied += end - start occupied += end - start
if pitches[0]==pitches[1]:equal+=end-start if pitches[0] == pitches[1]:
if {p%12 for p in pitches[0]}=={p%12 for p in pitches[1]}:classes+=end-start equal += end - start
if {p % 12 for p in pitches[0]} == {p % 12 for p in pitches[1]}:
classes += end - start
pitches = [[n['pitch'] for n in notes] for notes in ns] pitches = [[n['pitch'] for n in notes] for notes in ns]
rhythms = [[(n['start'], n['end'] - n['start']) for n in notes] for notes in ns] rhythms = [[(n['start'], n['end'] - n['start']) for n in notes] for notes in ns]
return {'pitch_sequence_edit_distance': distance(*pitches), return {'pitch_sequence_edit_distance': distance(*pitches),
@@ -120,124 +131,32 @@ def relationships(a,b):
'either_sounding_beats': str(occupied), 'either_sounding_beats': str(occupied),
'interpretation': 'Separate descriptive comparisons, not a quality score. Pitch distance ignores timing; timing distance ignores pitch. Held overlap uses the original aligned register and includes lead-ins.'} 'interpretation': 'Separate descriptive comparisons, not a quality score. Pitch distance ignores timing; timing distance ignores pitch. Held overlap uses the original aligned register and includes lead-ins.'}
def run(generated_dir, reference_paths, starts, pitch_convention, output, baseline_dir=None):
def render_events(notes, duration, tempo, root, cache): generated_dir, output = pathlib.Path(generated_dir), pathlib.Path(output)
"""Use the production neutral note envelopes; no expression edits."""
from generator import playback
factor=F(60*playback.SAMPLE_RATE)/tempo
output=array.array('f',[0])* (round(duration*factor)+2400)
for start,end,pitch in notes:
if pitch not in cache:
sample=playback.generate_tone(root,pitch)
with wave.open(str(root/sample['path'])) as audio:
values=array.array('h',audio.readframes(audio.getnframes()))
if sys.byteorder!='little':values.byteswap()
cache[pitch]=(sample,values)
sample,values=cache[pitch]
first,last=round(start*factor),round(end*factor)
for frame in range(max(0,first),min(len(output),last+2400)):
age=frame-first
index=age if age<sample['loop_end'] else sample['loop_start']+(age-sample['loop_start'])%(sample['loop_end']-sample['loop_start'])
release=1 if frame<last else (1-(frame-last)/2399)**2
output[frame]+=release*values[index]/32767
return output
def audition_bounds(*reviews):
points=[(F(m['start'])-F(r['alignment']['encoded_offset'] or 0),F(m['duration']))
for r in reviews for m in r['measures']]
return min(a for a,b in points),max(a+b for a,b in points)
def audition_pair(actual,expected,role,assets,output,cache,baseline=None):
from generator import model, playback, score
from generator.solo.export import excerpt,region
song,part=role.split('/')
other='electric' if part=='acoustic' else 'acoustic'
completed=score.load_json(Path(assets)/'songs'/f'{song}_{part}.json')
first,count=region(completed,part)
leadin=actual['alignment']['form_start_measure']-1
backing=excerpt(score.load_json(Path(assets)/'songs'/f'{song}_{other}.json'),first-leadin,count+leadin)
backing_offset=F(actual['alignment']['encoded_offset'])
tempo=next((model.fraction(t['bpm'])*model.written_length(t['written']) for m in completed['staves'][0]['measures'] for t in m.get('tempos',[]) if 'bpm' in t),F(120))
# Include the entire notated lead-in, even when it is a full bar of rest.
# Using the first attack here would silently erase a silent initial measure.
origin,end=audition_bounds(actual,expected)
duration=end-origin
timeline=playback.compile_timeline(backing)
back=render_events([(model.fraction(e['onset'])-backing_offset-origin,model.fraction(e['onset'])+model.fraction(e['duration'])-backing_offset-origin,model.fraction(e['pitch'])) for e in timeline['events']],duration,tempo,output,cache)
(output/'audio').mkdir(parents=True,exist_ok=True)
clips=[]
for label,r in (('generated',actual),('reference',expected),*((('baseline',baseline),) if baseline else ())):
audio=render_events([(F(n['start'])-origin,F(n['end'])-origin,F(n['pitch'])) for n in events(r)],duration,tempo,output,cache)
combined=array.array('f',(a+.55*b for a,b in zip(audio,back)))
for context,values in (('solo',audio),('fixed_backing',combined)):
peak=.65*max(map(abs,values),default=0)
if peak>=1:raise ValueError('Comparison would clip at the shared rendering gain')
name='audio/'+role.replace('/','_')+'_'+label+'_'+context+'.wav'
playback.write_pcm(output/name,(.65*v for v in values))
clips.append({'file':name,'version':label,'context':context,'peak':peak})
if context=='fixed_backing':
# An excerpt uses the completed source meter, never reference-derived
# rhythm statistics. Full references and their lead-ins remain above.
beats=sum((model.fraction(m['duration']) for m in backing['staves'][0]['measures'][leadin:leadin+8]),F())
stop=round((min(end,beats)-origin)*60*playback.SAMPLE_RATE/tempo)
short=name.replace('.wav','_opening.wav')
playback.write_pcm(output/short,(.65*v for v in values[:stop]))
clips.append({'file':short,'version':label,'context':'opening with lead-in context','peak':peak})
return {'clips':clips,'tempo':str(tempo),'gain':.65,'backing_gain':.55,'form_begins_at_beat':str(-origin),
'backing':'Exact independently generated other-guitar part, including the production lead-in. Fixed unchanged for all three comparison versions.',
'alignment':'Shared audition clock includes reference-only lead-in; no harmony or production notes were added.',
'listening_status':'Rendered and signal-checked, not subjectively auditioned.'}
def candidate_clips(candidates,role,tempo,output,cache):
"""Render the already completed source-only alternatives, never reselect."""
from generator import playback
clips=[]
for index,candidate in enumerate(candidates,1):
origin,end=F(candidate['start']),F(candidate['end'])
audio=render_events([(F(a)-origin,F(b)-origin,F(p)) for a,b,p in candidate['events']],end-origin,tempo,output,cache)
peak=.65*max(map(abs,audio),default=0)
if peak>=1:raise ValueError('Candidate excerpt would clip at comparison gain')
name='audio/'+role.replace('/','_')+f'_candidate_{index}.wav'
playback.write_pcm(output/name,(.65*v for v in audio))
selected=candidate['selected_in_final_solo']
clips.append({'file':name,'version':f'Completed opening {index}'+(' — selected in final solo' if selected else ' — alternative'),
'context':'source-only candidate, solo','peak':peak})
return clips
def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None,baseline_dir=None):
generated_dir,output=Path(generated_dir),Path(output)
manifest = json.loads((generated_dir / 'manifest.solo').read_text()) manifest = json.loads((generated_dir / 'manifest.solo').read_text())
output.mkdir(parents=True, exist_ok=True) output.mkdir(parents=True, exist_ok=True)
tone_cache={} baseline_manifest = json.loads((pathlib.Path(baseline_dir) / 'manifest.solo').read_text()) if baseline_dir else None
baseline_manifest=json.loads((Path(baseline_dir)/'manifest.solo').read_text()) if baseline_dir else None
result = {'comparison_only': True, 'feeds_generation': False, 'solos': {}} result = {'comparison_only': True, 'feeds_generation': False, 'solos': {}}
for role, path in sorted(reference_paths.items()): for role, path in sorted(reference_paths.items()):
item = manifest['solos'][role] item = manifest['solos'][role]
actual=read(generated_dir/item['file'],role,item['form_start_measure']) actual = generator.training_io.read(generated_dir / item['file'], role, item['form_start_measure'])
expected=read(path,role,starts.get(role),pitch_convention) expected = generator.training_io.read(path, role, starts.get(role), pitch_convention)
review = compare(actual, expected) review = compare(actual, expected)
baseline = None baseline = None
if baseline_manifest: if baseline_manifest:
old = baseline_manifest['solos'][role] old = baseline_manifest['solos'][role]
baseline=read(Path(baseline_dir)/old['file'],role,old['form_start_measure']) baseline = generator.training_io.read(pathlib.Path(baseline_dir) / old['file'], role, old['form_start_measure'])
review['baseline'] = musical_summary(baseline) review['baseline'] = musical_summary(baseline)
review['baseline_relationships'] = relationships(events(baseline), events(expected)) review['baseline_relationships'] = relationships(events(baseline), events(expected))
if assets:review['audition']=audition_pair(actual,expected,role,assets,output,tone_cache,baseline)
review['production_trace'] = item['trace'] review['production_trace'] = item['trace']
review['production_phrases'] = item.get('phrase_report', {}) review['production_phrases'] = item.get('phrase_report', {})
candidates = review['production_phrases'].get('selection_trace', {}).get('opening_candidates', []) candidates = review['production_phrases'].get('selection_trace', {}).get('opening_candidates', [])
if assets and candidates:
review['audition']['clips']+=candidate_clips(candidates,role,F(review['audition']['tempo']),output,tone_cache)
# Preserve the real role cutoff; reference material beyond it is not trimmed. # Preserve the real role cutoff; reference material beyond it is not trimmed.
phrases = review['production_phrases'].get('phrases', []) phrases = review['production_phrases'].get('phrases', [])
if phrases: if phrases:
cutoff=max(F(p['end']) for p in phrases) cutoff = max(fractions.Fraction(p['end']) for p in phrases)
review['production_end'] = str(cutoff) review['production_end'] = str(cutoff)
review['reference_events_after_production_end']=[n for n in events(expected) if F(n['end'])>cutoff] review['reference_events_after_production_end'] = [n for n in events(expected) if fractions.Fraction(n['end']) > cutoff]
result['solos'][role] = review result['solos'][role] = review
output.mkdir(parents=True, exist_ok=True) output.mkdir(parents=True, exist_ok=True)
(output / 'comparison.json').write_text(json.dumps(result, indent=2) + '\n') (output / 'comparison.json').write_text(json.dumps(result, indent=2) + '\n')
@@ -247,11 +166,11 @@ def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None
'<p><a href="comparison.json">Exact timing, notes, harmony, annotations, placement, and phrase reports</a></p>'] '<p><a href="comparison.json">Exact timing, notes, harmony, annotations, placement, and phrase reports</a></p>']
for role, r in result['solos'].items(): for role, r in result['solos'].items():
actual_path = generated_dir / manifest['solos'][role]['file'] actual_path = generated_dir / manifest['solos'][role]['file']
reference_path=Path(reference_paths[role]) reference_path = pathlib.Path(reference_paths[role])
rows += ['<h2>' + html.escape(role) + '</h2>', f'<p>Exact event match: <strong>{"yes" if r["exact_musical_match"] else "no"}</strong>. {r["exact_matching_attacks"]} exactly matching attacks; {len(r["differences"])} differing onset positions. This is not a musical-quality score.</p>', rows += ['<h2>' + html.escape(role) + '</h2>', f'<p>Exact event match: <strong>{"yes" if r["exact_musical_match"] else "no"}</strong>. {r["exact_matching_attacks"]} exactly matching attacks; {len(r["differences"])} differing onset positions. This is not a musical-quality score.</p>',
'<p><a href="' + html.escape(os.path.relpath(actual_path, output)) + '">Generated MusicXML</a> · <a href="' + html.escape(os.path.relpath(reference_path, output)) + '">Untouched reference MusicXML</a></p>'] '<p><a href="' + html.escape(os.path.relpath(actual_path, output)) + '">Generated MusicXML</a> · <a href="' + html.escape(os.path.relpath(reference_path, output)) + '">Untouched reference MusicXML</a></p>']
if baseline_manifest: if baseline_manifest:
baseline_path=Path(baseline_dir)/baseline_manifest['solos'][role]['file'] baseline_path = pathlib.Path(baseline_dir) / baseline_manifest['solos'][role]['file']
rows.append('<p><a href="' + html.escape(os.path.relpath(baseline_path, output)) + '">Previous generated MusicXML</a></p>') rows.append('<p><a href="' + html.escape(os.path.relpath(baseline_path, output)) + '">Previous generated MusicXML</a></p>')
rows.append('<table><tr><th>Diagnostic against reference</th><th>Current</th><th>Baseline</th></tr>') rows.append('<table><tr><th>Diagnostic against reference</th><th>Current</th><th>Baseline</th></tr>')
for key in ('pitch_sequence_edit_distance', 'timed_attack_edit_distance', 'same_sounding_pitch_beats', 'same_pitch_class_beats', 'either_sounding_beats'): for key in ('pitch_sequence_edit_distance', 'timed_attack_edit_distance', 'same_sounding_pitch_beats', 'same_pitch_class_beats', 'either_sounding_beats'):
@@ -274,35 +193,33 @@ def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None
for index, c in enumerate(candidates, 1): for index, c in enumerate(candidates, 1):
rows.append('<tr>' + ''.join('<td>' + html.escape(str(v)) + '</td>' for v in (index, c['selected_in_final_solo'], c['purpose'], c['evaluation']['contributions'])) + '</tr>') rows.append('<tr>' + ''.join('<td>' + html.escape(str(v)) + '</td>' for v in (index, c['selected_in_final_solo'], c['purpose'], c['evaluation']['contributions'])) + '</tr>')
rows.append('</table></details>') rows.append('</table></details>')
for clip in r.get('audition',{}).get('clips',[]):
rows.append('<p>'+html.escape(clip['version']+' / '+clip['context'])+'<br><audio controls preload="none" src="'+html.escape(clip['file'])+'"></audio></p>')
rows.append('<details><summary>Exact note differences</summary><table><tr><th>Beat relative to form</th><th>Generated</th><th>Reference</th></tr>') rows.append('<details><summary>Exact note differences</summary><table><tr><th>Beat relative to form</th><th>Generated</th><th>Reference</th></tr>')
for d in r['differences']: for d in r['differences']:
def describe(ns): return '; '.join(f'MIDI {n["pitch"]}, until {n["end"]}' for n in ns) or 'No attack' def describe(ns): return '; '.join(f'MIDI {n["pitch"]}, until {n["end"]}' for n in ns) or 'No attack'
rows.append('<tr>' + ''.join('<td>' + html.escape(v) + '</td>' for v in (d['time'], describe(d['generated']), describe(d['reference']))) + '</tr>') rows.append('<tr>' + ''.join('<td>' + html.escape(v) + '</td>' for v in (d['time'], describe(d['generated']), describe(d['reference']))) + '</tr>')
rows.append('</table></details>') rows.append('</table></details>')
(output / 'index.html').write_text('\n'.join(rows)) (output / 'index.html').write_text('\n'.join(rows))
return result return result
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--generated-dir',type=Path,required=True) parser.add_argument('--generated-dir', type=pathlib.Path, required=True)
parser.add_argument('--reference', action='append', default=[], metavar='SONG/ROLE=PATH') parser.add_argument('--reference', action='append', default=[], metavar='SONG/ROLE=PATH')
parser.add_argument('--form-start', action='append', default=[], metavar='SONG/ROLE=MEASURE') parser.add_argument('--form-start', action='append', default=[], metavar='SONG/ROLE=MEASURE')
parser.add_argument('--reference-pitch', choices=('encoded', 'guitar-written'), default='encoded') parser.add_argument('--reference-pitch', choices=('encoded', 'guitar-written'), default='encoded')
parser.add_argument('--output',type=Path,required=True) parser.add_argument('--output', type=pathlib.Path, required=True)
parser.add_argument('--baseline-dir',type=Path,help='Optional previous completed solo exports; never generation inputs.') parser.add_argument('--baseline-dir', type=pathlib.Path, help='Optional previous completed solo exports; never generation inputs.')
parser.add_argument('--assets',type=Path,help='Optional completed assets for neutral reference/generated audio with identical backing.')
parser.add_argument('--require-exact', action='store_true', help='Fail unless every requested reference has identical complete sounding events and rests.') parser.add_argument('--require-exact', action='store_true', help='Fail unless every requested reference has identical complete sounding events and rests.')
args = parser.parse_args() args = parser.parse_args()
if args.require_exact and not args.reference: if args.require_exact and not args.reference:
parser.error('--require-exact requires at least one explicit --reference') parser.error('--require-exact requires at least one explicit --reference')
result = run(args.generated_dir, dict(x.split('=', 1) for x in args.reference), result = run(args.generated_dir, dict(x.split('=', 1) for x in args.reference),
{k:int(v) for k,v in (x.split('=',1) for x in args.form_start)},args.reference_pitch,args.output,args.assets,args.baseline_dir) {k: int(v) for k, v in (x.split('=', 1) for x in args.form_start)}, args.reference_pitch, args.output, args.baseline_dir)
for role, review in result['solos'].items(): for role, review in result['solos'].items():
print(f'{role}: exact={review["exact_musical_match"]}; matching attacks={review["exact_matching_attacks"]}; differing onsets={len(review["differences"])}; rests differ={review["rest_difference"]}') print(f'{role}: exact={review["exact_musical_match"]}; matching attacks={review["exact_matching_attacks"]}; differing onsets={len(review["differences"])}; rests differ={review["rest_difference"]}')
return 1 if args.require_exact and any(not r['exact_musical_match'] for r in result['solos'].values()) else 0 return 1 if args.require_exact and any(not r['exact_musical_match'] for r in result['solos'].values()) else 0
if __name__ == '__main__':
if __name__=='__main__':raise SystemExit(main()) raise SystemExit(main())
+2 -2
View File
@@ -2,6 +2,7 @@ import copy, fractions
import generator.model, generator.parts, generator.harmony import generator.model, generator.parts, generator.harmony
import generator.solo.analysis, generator.solo.composer, generator.solo.types import generator.solo.analysis, generator.solo.composer, generator.solo.types
import tests.test_musicxml import tests.test_musicxml
import generator.harmonic_time
def song(meter=4, count=6, chords=('Dm7', 'G7', 'CM7')): def song(meter=4, count=6, chords=('Dm7', 'G7', 'CM7')):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join(tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G')))) document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join(tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G'))))
@@ -28,5 +29,4 @@ def note(start,end,letter='g',alter=0,octave=4,**kwargs):
return generator.solo.types.Note(fractions.Fraction(start), fractions.Fraction(end), generator.solo.types.Pitch(letter, fractions.Fraction(alter), octave), **kwargs) return generator.solo.types.Note(fractions.Fraction(start), fractions.Fraction(end), generator.solo.types.Pitch(letter, fractions.Fraction(alter), octave), **kwargs)
def choices(symbols, duration=4): def choices(symbols, duration=4):
from generator import harmonic_time return tuple(generator.harmonic_time.ChordSpan(fractions.Fraction(i * duration), fractions.Fraction((i + 1) * duration), i + 1, str(i + 1), fractions.Fraction(1), generator.model.key_from_fifths(-1), generator.harmony.parse_symbol(symbol)) for i, symbol in enumerate(symbols))
return tuple(harmonic_time.ChordSpan(fractions.Fraction(i*duration),fractions.Fraction((i+1)*duration),i+1,str(i+1),fractions.Fraction(1),generator.model.key_from_fifths(-1),generator.harmony.parse_symbol(symbol)) for i,symbol in enumerate(symbols))
+19 -30
View File
@@ -1,4 +1,5 @@
#include <test.hpp> #include <test.hpp>
#include "catalog_fixture.hpp"
#include <algorithm> #include <algorithm>
#include <chrono> #include <chrono>
@@ -59,6 +60,7 @@ auto score_asset_json(std::string title, std::string key = "C", std::string part
metadata["title"] = std::move(title); metadata["title"] = std::move(title);
metadata["key_signature"] = std::move(key); metadata["key_signature"] = std::move(key);
value.at("music").at("staves").as_array().front().as_object()["name"] = std::move(part); value.at("music").at("staves").as_array().front().as_object()["name"] = std::move(part);
vamp_test::add_timing(value);
return boost::json::serialize(value); return boost::json::serialize(value);
} }
@@ -108,35 +110,24 @@ BOOST_AUTO_TEST_CASE(product_vamp_audio_config_parses_defaults_and_values) {
auto defaults = vamp_zero::parse_config(boost::json::parse(R"json({})json")); auto defaults = vamp_zero::parse_config(boost::json::parse(R"json({})json"));
BOOST_REQUIRE(defaults); BOOST_REQUIRE(defaults);
BOOST_TEST(defaults->audio.enabled); BOOST_TEST(defaults->audio.enabled);
BOOST_TEST(!defaults->audio.playback_enabled); BOOST_TEST(!defaults->audio.backing_enabled);
BOOST_TEST(!defaults->audio.metronome_enabled); BOOST_TEST(!defaults->audio.metronome_enabled);
BOOST_TEST(defaults->audio.metronome_bpm == 120U); BOOST_TEST(defaults->audio.tempo_bpm == 120U);
BOOST_TEST(defaults->audio.metronome_min_bpm == 40U);
BOOST_TEST(defaults->audio.metronome_max_bpm == 240U);
BOOST_TEST(defaults->audio.metronome_step == 5U);
auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({ auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({
"audio": { "audio": {
"enabled": false, "enabled": false,
"playback_enabled": true, "backing_enabled": true,
"metronome_enabled": true, "metronome_enabled": true,
"metronome_bpm": 90, "tempo_bpm": 90,
"metronome_min_bpm": 50,
"metronome_max_bpm": 180,
"metronome_step": 10,
"beats_per_bar": 3,
"master_volume": 0.5, "master_volume": 0.5,
"metronome_volume": 0.25 "metronome_volume": 0.25
} }
})json")); })json"));
BOOST_REQUIRE(parsed); BOOST_REQUIRE(parsed);
BOOST_TEST(!parsed->audio.enabled); BOOST_TEST(!parsed->audio.enabled);
BOOST_TEST(parsed->audio.playback_enabled); BOOST_TEST(parsed->audio.backing_enabled);
BOOST_TEST(parsed->audio.metronome_enabled); BOOST_TEST(parsed->audio.metronome_enabled);
BOOST_TEST(parsed->audio.metronome_bpm == 90U); BOOST_TEST(parsed->audio.tempo_bpm == 90U);
BOOST_TEST(parsed->audio.metronome_min_bpm == 50U);
BOOST_TEST(parsed->audio.metronome_max_bpm == 180U);
BOOST_TEST(parsed->audio.metronome_step == 10U);
BOOST_TEST(parsed->audio.beats_per_bar == 3U);
BOOST_TEST(parsed->audio.master_volume == 0.5F); BOOST_TEST(parsed->audio.master_volume == 0.5F);
BOOST_TEST(parsed->audio.metronome_volume == 0.25F); BOOST_TEST(parsed->audio.metronome_volume == 0.25F);
} }
@@ -144,9 +135,7 @@ BOOST_AUTO_TEST_CASE(product_vamp_audio_config_parses_defaults_and_values) {
BOOST_AUTO_TEST_CASE(product_vamp_audio_config_rejects_invalid_tempo_range) { BOOST_AUTO_TEST_CASE(product_vamp_audio_config_rejects_invalid_tempo_range) {
auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({ auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({
"audio": { "audio": {
"metronome_bpm": 120, "tempo_bpm": 125
"metronome_min_bpm": 160,
"metronome_max_bpm": 80
} }
})json")); })json"));
BOOST_TEST(!parsed); BOOST_TEST(!parsed);
@@ -160,7 +149,7 @@ BOOST_AUTO_TEST_CASE(settings_menu_toggles_audio_settings_and_edits_tempo) {
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU); BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
BOOST_TEST(state.selected_settings_index == 1U); BOOST_TEST(state.selected_settings_index == 1U);
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_TEST(state.audio.playback_enabled); BOOST_TEST(!state.audio.backing_enabled);
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::SETTINGS_MENU)); BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::SETTINGS_MENU));
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_TEST(state.selected_settings_index == 2U); BOOST_TEST(state.selected_settings_index == 2U);
@@ -172,17 +161,17 @@ BOOST_AUTO_TEST_CASE(settings_menu_toggles_audio_settings_and_edits_tempo) {
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_TEST(state.audio.tempo_editing); BOOST_TEST(state.audio.tempo_editing);
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_TEST(state.audio.metronome_bpm == 125U); BOOST_TEST(state.audio.tempo_bpm == 120U);
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);
BOOST_TEST(state.audio.metronome_bpm == 120U); BOOST_TEST(state.audio.tempo_bpm == 110U);
for (auto index = std::int32_t{}; index < 40; ++index) { for (auto index = std::int32_t{}; index < 40; ++index) {
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);
} }
BOOST_TEST(state.audio.metronome_bpm == 40U); BOOST_TEST(state.audio.tempo_bpm == 60U);
for (auto index = std::int32_t{}; index < 80; ++index) { for (auto index = std::int32_t{}; index < 80; ++index) {
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_TEST(state.audio.metronome_bpm == 240U); BOOST_TEST(state.audio.tempo_bpm == 120U);
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_TEST(!state.audio.tempo_editing); BOOST_TEST(!state.audio.tempo_editing);
} }
@@ -191,17 +180,17 @@ BOOST_AUTO_TEST_CASE(remote_display_reports_settings_without_a_catalog_dump) {
auto state = navigation_state(); auto state = navigation_state();
state.mode = vamp_zero::app_mode::SETTINGS_MENU; state.mode = vamp_zero::app_mode::SETTINGS_MENU;
state.selected_settings_index = 3U; state.selected_settings_index = 3U;
state.audio.playback_enabled = true; state.audio.backing_enabled = true;
state.audio.metronome_enabled = true; state.audio.metronome_enabled = true;
state.audio.metronome_bpm = 135; state.audio.tempo_bpm = 110;
state.audio.tempo_editing = true; state.audio.tempo_editing = true;
auto remote = vamp_pico::remote_display_state{}; auto remote = vamp_pico::remote_display_state{};
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote)); BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
BOOST_TEST(std::string{remote.focus} == "Tempo"); BOOST_TEST(std::string{remote.focus} == "Tempo");
BOOST_TEST(std::string{remote.mode} == "settings_menu"); BOOST_TEST(std::string{remote.mode} == "settings_menu");
BOOST_TEST(remote.playback); BOOST_TEST(remote.backing);
BOOST_TEST(remote.metronome); BOOST_TEST(remote.metronome);
BOOST_TEST(remote.bpm == 135U); BOOST_TEST(remote.bpm == 110U);
BOOST_TEST(remote.editing); BOOST_TEST(remote.editing);
} }
@@ -279,7 +268,7 @@ BOOST_AUTO_TEST_CASE(song_part_navigation_preserves_selection_and_remote_display
for (auto index = std::uint32_t{}; index < 4U; ++index) { for (auto index = std::uint32_t{}; index < 4U; ++index) {
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_TEST(state.page_index == 2U); BOOST_TEST(state.page_index == 0U);
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::PERFORMANCE_PART_MENU); BOOST_CHECK(state.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
BOOST_TEST(state.selected_part_index == selection); BOOST_TEST(state.selected_part_index == selection);
+2 -2
View File
@@ -11,14 +11,14 @@ import generator.engraving.fonts
import generator.engraving.layout import generator.engraving.layout
import generator.engraving.types import generator.engraving.types
import generator.model import generator.model
import generator.playback import tests.score_events
import tests.arrangement_fixtures import tests.arrangement_fixtures
import tests.test_accompaniment import tests.test_accompaniment
import tests.test_musicxml import tests.test_musicxml
import tests.test_parts import tests.test_parts
def sounding(document): def sounding(document):
return sorted(tuple(generator.model.fraction(event[key]) for key in ('onset', 'duration', 'pitch')) for event in generator.playback.compile_timeline(document)['events']) return sorted(tuple(generator.model.fraction(event[key]) for key in ('onset', 'duration', 'pitch')) for event in tests.score_events.timeline(document)['events'])
def acoustic_document(bars): def acoustic_document(bars):
document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * len(bars))) document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * len(bars)))
+8 -8
View File
@@ -9,7 +9,7 @@ import generator.arrange.pickup
import generator.model import generator.model
import generator.musicxml import generator.musicxml
import generator.parts import generator.parts
import generator.playback import tests.score_events
import generator.score import generator.score
import tests.test_musicxml import tests.test_musicxml
import tests.test_parts import tests.test_parts
@@ -31,10 +31,10 @@ class ArrangementPickupTests(unittest.TestCase):
result = generator.arrange.generate.arrange_document(document, part) result = generator.arrange.generate.arrange_document(document, part)
generator.model.validate_document(result) generator.model.validate_document(result)
measures = result['staves'][0]['measures'] measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 176) self.assertEqual(len(measures), 168)
self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 177)]) self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 169)])
self.assertEqual([m['number'] for m in measures if m.get('section_label')], ['1', '9', '41', '73', '105', '137', '169']) self.assertEqual([m['number'] for m in measures if m.get('section_label')], ['1', '9', '41', '73', '105', '137'])
self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 704) self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 672)
timelines.append([m['duration'] for m in measures]) timelines.append([m['duration'] for m in measures])
if path.stem == 'autumn_leaves' and part in ('vocal', 'electric'): if path.stem == 'autumn_leaves' and part in ('vocal', 'electric'):
for boundary in ([8, 136] if part == 'vocal' else [40]): for boundary in ([8, 136] if part == 'vocal' else [40]):
@@ -49,7 +49,7 @@ class ArrangementPickupTests(unittest.TestCase):
opening = '<note><rest/><duration>1</duration><type>quarter</type></note>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>') opening = '<note><rest/><duration>1</duration><type>quarter</type></note>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')
document = source(opening, implicit=False) document = source(opening, implicit=False)
result = generator.arrange.generate.arrange_document(document, 'vocal') result = generator.arrange.generate.arrange_document(document, 'vocal')
self.assertEqual(len(result['staves'][0]['measures']), 14) self.assertEqual(len(result['staves'][0]['measures']), 12)
self.assertEqual(generator.score.body_start_measure_index(document['staves'][0]['measures']), 0) self.assertEqual(generator.score.body_start_measure_index(document['staves'][0]['measures']), 0)
def test_short_pickup_ties_and_octave_spans_survive_each_melody_entry(self): def test_short_pickup_ties_and_octave_spans_survive_each_melody_entry(self):
document = source(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), tests.test_musicxml.xml_note(extra='<tie type="stop"/>')) document = source(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), tests.test_musicxml.xml_note(extra='<tie type="stop"/>'))
@@ -59,7 +59,7 @@ class ArrangementPickupTests(unittest.TestCase):
result = generator.arrange.generate.arrange_document(document, part) result = generator.arrange.generate.arrange_document(document, part)
generator.model.validate_document(result) generator.model.validate_document(result)
measures = result['staves'][0]['measures'] measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 7) self.assertEqual(len(measures), 6)
for boundary in boundaries: for boundary in boundaries:
pickup = generator.score.measure_notes(measures[boundary - 1])[-1] pickup = generator.score.measure_notes(measures[boundary - 1])[-1]
downbeat = generator.score.measure_notes(measures[boundary])[0] downbeat = generator.score.measure_notes(measures[boundary])[0]
@@ -67,7 +67,7 @@ class ArrangementPickupTests(unittest.TestCase):
self.assertEqual(pickup['pitches'][0]['tie_to'], downbeat['pitches'][0]['id']) self.assertEqual(pickup['pitches'][0]['tie_to'], downbeat['pitches'][0]['id'])
self.assertEqual(downbeat['pitches'][0]['tie_from'], pickup['pitches'][0]['id']) self.assertEqual(downbeat['pitches'][0]['tie_from'], pickup['pitches'][0]['id'])
self.assertTrue(any(generator.model.fraction(s['start']) == boundary * 4 - 1 and generator.model.fraction(s['end']) == boundary * 4 for s in result['staves'][0]['octave_shifts'])) self.assertTrue(any(generator.model.fraction(s['start']) == boundary * 4 - 1 and generator.model.fraction(s['end']) == boundary * 4 for s in result['staves'][0]['octave_shifts']))
self.assertTrue(generator.playback.compile_timeline(result)['events']) self.assertTrue(tests.score_events.timeline(result)['events'])
def test_pickup_truncates_outgoing_music_without_discarding_earlier_notes(self): def test_pickup_truncates_outgoing_music_without_discarding_earlier_notes(self):
opening = '<note><rest/><duration>1</duration><type>quarter</type></note>' + tests.test_musicxml.xml_note('D', 3, 'half', extra='<dot/><lyric><text>doo</text></lyric>') opening = '<note><rest/><duration>1</duration><type>quarter</type></note>' + tests.test_musicxml.xml_note('D', 3, 'half', extra='<dot/><lyric><text>doo</text></lyric>')
document = source(opening) document = source(opening)
+1 -1
View File
@@ -45,7 +45,7 @@ class CatalogTests(unittest.TestCase):
with contextlib.closing(sqlite3.connect(database)) as connection: with contextlib.closing(sqlite3.connect(database)) as connection:
self.assertEqual(connection.execute('PRAGMA application_id').fetchone()[0], generator.catalog.APPLICATION_ID) self.assertEqual(connection.execute('PRAGMA application_id').fetchone()[0], generator.catalog.APPLICATION_ID)
self.assertEqual(connection.execute('PRAGMA integrity_check').fetchall(), [('ok',)]) self.assertEqual(connection.execute('PRAGMA integrity_check').fetchall(), [('ok',)])
self.assertEqual(connection.execute("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name").fetchall(), [('songs',)]) self.assertEqual(connection.execute("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name").fetchall(), [('resources',), ('songs',)])
columns = {row[1]: row for row in connection.execute('PRAGMA table_info(songs)')} columns = {row[1]: row for row in connection.execute('PRAGMA table_info(songs)')}
self.assertEqual((columns['title_image'][2], columns['title_image'][3]), ('TEXT', 1)) self.assertEqual((columns['title_image'][2], columns['title_image'][3]), ('TEXT', 1))
self.assertFalse(pathlib.Path(str(database) + '-wal').exists()) self.assertFalse(pathlib.Path(str(database) + '-wal').exists())
+1
View File
@@ -90,6 +90,7 @@ auto catalog_bundle(std::string_view title, std::string_view key = "C", std::str
metadata["title"] = title; metadata["title"] = title;
metadata["key_signature"] = key; metadata["key_signature"] = key;
value.at("music").at("staves").as_array().front().as_object()["name"] = part; value.at("music").at("staves").as_array().front().as_object()["name"] = part;
vamp_test::add_timing(value);
return boost::json::serialize(value); return boost::json::serialize(value);
} }
+3 -3
View File
@@ -21,7 +21,7 @@ import generator.engraving.types
import generator.guitar import generator.guitar
import generator.model import generator.model
import generator.musicxml import generator.musicxml
import generator.playback import tests.score_events
import tests.arrangement_fixtures import tests.arrangement_fixtures
import tests.test_accompaniment import tests.test_accompaniment
import tests.test_musicxml import tests.test_musicxml
@@ -77,7 +77,7 @@ class EngravingTests(unittest.TestCase):
for measure in without_tempos['staves'][0]['measures']: for measure in without_tempos['staves'][0]['measures']:
measure.pop('tempos', None) measure.pop('tempos', None)
self.assertEqual(layout, self.layout(without_tempos)) self.assertEqual(layout, self.layout(without_tempos))
self.assertEqual(generator.playback.compile_timeline(source), generator.playback.compile_timeline(without_tempos)) self.assertEqual(tests.score_events.timeline(source), tests.score_events.timeline(without_tempos))
def test_long_credits_wrap_without_overflow_or_page_specific_sizing(self): def test_long_credits_wrap_without_overflow_or_page_specific_sizing(self):
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * 32)) source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * 32))
source['metadata']['composer'] = 'A moderately long composer credit ' * 6 source['metadata']['composer'] = 'A moderately long composer credit ' * 6
@@ -398,7 +398,7 @@ class EngravingTests(unittest.TestCase):
self.assertLess(max(p.box.bottom for p in system.primitives if not p.role.startswith('tab')), min(p.box.top for p in system.primitives if p.role.startswith('tab'))) self.assertLess(max(p.box.bottom for p in system.primitives if not p.role.startswith('tab')), min(p.box.top for p in system.primitives if p.role.startswith('tab')))
self.assertEqual({p.sources[-1] for p in heads}, {p.sources[-1] for p in tabs}) self.assertEqual({p.sources[-1] for p in heads}, {p.sources[-1] for p in tabs})
self.assertTrue(all(any(abs(head.points[0][0] - tab.points[0][0]) < 0.01 for head in heads if head.sources[-1] == tab.sources[-1]) for tab in tabs)) self.assertTrue(all(any(abs(head.points[0][0] - tab.points[0][0]) < 0.01 for head in heads if head.sources[-1] == tab.sources[-1]) for tab in tabs))
self.assertEqual(len(generator.playback.compile_timeline(source)['events']), 64) self.assertEqual(len(tests.score_events.timeline(source)['events']), 64)
def test_generated_tab_ties_and_barline_styles(self): def test_generated_tab_ties_and_barline_styles(self):
content = tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="stop"/>') + '<barline location="right"><bar-style>dashed</bar-style></barline>' content = tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="stop"/>') + '<barline location="right"><bar-style>dashed</bar-style></barline>'
source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)), 'electric') source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)), 'electric')
+9 -7
View File
@@ -198,15 +198,17 @@ class EngravingCacheTests(unittest.TestCase):
with unittest.mock.patch('generator.engraving.raster.rasterize_page', side_effect=AssertionError('cache miss')): with unittest.mock.patch('generator.engraving.raster.rasterize_page', side_effect=AssertionError('cache miss')):
generator.engraving.pipeline.engrave_tree(warm, self.fonts, cache_dir=self.cache) generator.engraving.pipeline.engrave_tree(warm, self.fonts, cache_dir=self.cache)
self.assertEqual(self.files(cold), self.files(warm)) self.assertEqual(self.files(cold), self.files(warm))
def test_playback_is_not_restored_from_page_cache(self): def test_invalid_cached_page_timing_is_rebuilt(self):
self.seed_cache() entry = self.seed_cache()
changed = copy.deepcopy(self.document) manifest = entry / 'pages.json'
changed['playback'] = {'new-playback': 'independent of page rendering'} pages = json.loads(manifest.read_text())
pages[0]['measure_count'] += 1
manifest.write_text(json.dumps(pages))
root = self.root / 'warm' root = self.root / 'warm'
self.write_scores(root, [changed]) self.write_scores(root, [self.document])
with unittest.mock.patch('generator.engraving.layout.layout_systems', side_effect=AssertionError('cache miss')):
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache) generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
self.assertEqual(json.loads((root / '000.json').read_text())['playback'], changed['playback']) bundle = json.loads((root / '000.json').read_text())
self.assertEqual(bundle['presentation']['pages'][0]['measure_count'], 1)
def test_changed_music_and_renderer_invalidate_pages(self): def test_changed_music_and_renderer_invalidate_pages(self):
self.seed_cache() self.seed_cache()
changed = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D')).replace('Fixture', 'Changed')) changed = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D')).replace('Fixture', 'Changed'))
+9 -9
View File
@@ -17,7 +17,7 @@ import generator.harmony
import generator.model import generator.model
import generator.parts import generator.parts
import generator.pitch import generator.pitch
import generator.playback import tests.score_events
import tests.arrangement_fixtures import tests.arrangement_fixtures
import tests.solo_fixtures import tests.solo_fixtures
import tests.test_musicxml import tests.test_musicxml
@@ -137,8 +137,8 @@ class GuitarTests(unittest.TestCase):
vocal = tests.arrangement_fixtures.section_document(source, 'vocal') vocal = tests.arrangement_fixtures.section_document(source, 'vocal')
generator.model.validate_document(electric) generator.model.validate_document(electric)
self.assertEqual(source, before) self.assertEqual(source, before)
self.assertEqual([(e['onset'], e['duration'], generator.model.fraction(e['pitch']) - 12) for e in generator.playback.compile_timeline(electric)['events']], [(e['onset'], e['duration'], generator.model.fraction(e['pitch'])) for e in generator.playback.compile_timeline(vocal)['events']]) self.assertEqual([(e['onset'], e['duration'], generator.model.fraction(e['pitch']) - 12) for e in tests.score_events.timeline(electric)['events']], [(e['onset'], e['duration'], generator.model.fraction(e['pitch'])) for e in tests.score_events.timeline(vocal)['events']])
self.assertEqual([(e['pitch'], e['onset'], e['duration']) for e in generator.playback.compile_timeline(electric)['events']], [(e['pitch'], e['onset'], e['duration']) for e in generator.playback.compile_timeline(source)['events']]) self.assertEqual([(e['pitch'], e['onset'], e['duration']) for e in tests.score_events.timeline(electric)['events']], [(e['pitch'], e['onset'], e['duration']) for e in tests.score_events.timeline(source)['events']])
for original, generated in zip(tests.test_musicxml.notes(source), tests.test_musicxml.notes(electric)): for original, generated in zip(tests.test_musicxml.notes(source), tests.test_musicxml.notes(electric)):
self.assertEqual(generator.model.pitch_number(generated['pitches'][0]), generator.model.pitch_number(original['pitches'][0]) + 12) self.assertEqual(generator.model.pitch_number(generated['pitches'][0]), generator.model.pitch_number(original['pitches'][0]) + 12)
self.assertIn('tablature', generated['pitches'][0]) self.assertIn('tablature', generated['pitches'][0])
@@ -147,7 +147,7 @@ class GuitarTests(unittest.TestCase):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes)) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes))
for part in ('electric', 'vocal'): for part in ('electric', 'vocal'):
document = tests.arrangement_fixtures.section_document(source, part) document = tests.arrangement_fixtures.section_document(source, part)
self.assertEqual(generator.model.fraction(generator.playback.compile_timeline(document)['events'][0]['pitch']), expected + (12 if part == 'electric' else 0)) self.assertEqual(generator.model.fraction(tests.score_events.timeline(document)['events'][0]['pitch']), expected + (12 if part == 'electric' else 0))
def test_written_middle_c_and_c_sharp_six_have_matching_tab_and_audio(self): def test_written_middle_c_and_c_sharp_six_have_matching_tab_and_audio(self):
for octave, alter, expected, tab in ((4, 0, 48, {'string': 5, 'fret': 3}), (6, 1, 73, {'string': 1, 'fret': 9})): for octave, alter, expected, tab in ((4, 0, 48, {'string': 5, 'fret': 3}), (6, 1, 73, {'string': 1, 'fret': 9})):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=octave - 1, alter=alter))) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=octave - 1, alter=alter)))
@@ -155,7 +155,7 @@ class GuitarTests(unittest.TestCase):
tone = tests.test_musicxml.notes(document)[0]['pitches'][0] tone = tests.test_musicxml.notes(document)[0]['pitches'][0]
self.assertEqual(generator.model.pitch_number(tone), 12 * (octave + 1) + alter) self.assertEqual(generator.model.pitch_number(tone), 12 * (octave + 1) + alter)
self.assertEqual(tone['tablature'], tab) self.assertEqual(tone['tablature'], tab)
self.assertEqual(generator.model.fraction(generator.playback.compile_timeline(document)['events'][0]['pitch']), expected) self.assertEqual(generator.model.fraction(tests.score_events.timeline(document)['events'][0]['pitch']), expected)
generator.model.validate_document(document) generator.model.validate_document(document)
def test_guitar_normalization_is_idempotent_and_respects_timed_transposition(self): def test_guitar_normalization_is_idempotent_and_respects_timed_transposition(self):
change = '<attributes><transpose><chromatic>0</chromatic></transpose></attributes>' change = '<attributes><transpose><chromatic>0</chromatic></transpose></attributes>'
@@ -165,7 +165,7 @@ class GuitarTests(unittest.TestCase):
self.assertEqual(generator.pitch.concert_document(concert, octave_transposing=True), concert) self.assertEqual(generator.pitch.concert_document(concert, octave_transposing=True), concert)
self.assertEqual(source, before) self.assertEqual(source, before)
document = tests.arrangement_fixtures.section_document(source, 'electric') document = tests.arrangement_fixtures.section_document(source, 'electric')
self.assertEqual([generator.model.fraction(e['pitch']) for e in generator.playback.compile_timeline(document)['events']], [60, 72]) self.assertEqual([generator.model.fraction(e['pitch']) for e in tests.score_events.timeline(document)['events']], [60, 72])
def test_all_parts_share_written_octave_convention(self): def test_all_parts_share_written_octave_convention(self):
chord = '<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' chord = '<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>'
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord + tests.test_musicxml.xml_note())) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord + tests.test_musicxml.xml_note()))
@@ -175,7 +175,7 @@ class GuitarTests(unittest.TestCase):
document = generate(source, part) document = generate(source, part)
generator.model.validate_document(document) generator.model.validate_document(document)
written = {p['id']: generator.model.pitch_number(p) for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']} written = {p['id']: generator.model.pitch_number(p) for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']}
events = generator.playback.compile_timeline(document)['events'] events = tests.score_events.timeline(document)['events']
self.assertTrue(events) self.assertTrue(events)
self.assertTrue(all(generator.model.fraction(e['pitch']) == written[e['id']] - 12 for e in events)) self.assertTrue(all(generator.model.fraction(e['pitch']) == written[e['id']] - 12 for e in events))
if part == 'vocal': if part == 'vocal':
@@ -189,7 +189,7 @@ class GuitarTests(unittest.TestCase):
first, continuation, other = [p['tablature'] for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']] first, continuation, other = [p['tablature'] for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']]
self.assertEqual(first, continuation) self.assertEqual(first, continuation)
self.assertNotEqual(continuation['string'], other['string']) self.assertNotEqual(continuation['string'], other['string'])
self.assertEqual(len(generator.playback.compile_timeline(document)['events']), 2) self.assertEqual(len(tests.score_events.timeline(document)['events']), 2)
def test_overlapping_tones_cannot_expand_the_span_or_relocate_a_tie(self): def test_overlapping_tones_cannot_expand_the_span_or_relocate_a_tie(self):
content = tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 3, 'half', octave=4, extra='<dot/><tie type="stop"/>') content = tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 3, 'half', octave=4, extra='<dot/><tie type="stop"/>')
content += '<backup><duration>4</duration></backup><forward><duration>1</duration></forward>' + tests.test_musicxml.xml_note('F', 3, 'half', octave=4, extra='<dot/><voice>2</voice>') content += '<backup><duration>4</duration></backup><forward><duration>1</duration></forward>' + tests.test_musicxml.xml_note('F', 3, 'half', octave=4, extra='<dot/><voice>2</voice>')
@@ -229,7 +229,7 @@ class GuitarTests(unittest.TestCase):
generator.model.validate_document(document) generator.model.validate_document(document)
self.assertEqual(melody['pitches'][0]['tablature'], {'string': 1, 'fret': 12}) self.assertEqual(melody['pitches'][0]['tablature'], {'string': 1, 'fret': 12})
self.assertNotIn(1, [p['tablature']['string'] for p in chord['pitches']]) self.assertNotIn(1, [p['tablature']['string'] for p in chord['pitches']])
self.assertEqual(len(generator.playback.compile_timeline(document)['events']), 3) self.assertEqual(len(tests.score_events.timeline(document)['events']), 3)
def test_more_than_six_simultaneous_tones_fail_without_omission(self): def test_more_than_six_simultaneous_tones_fail_without_omission(self):
content = '<backup><duration>4</duration></backup>'.join(tests.test_musicxml.xml_note(extra=f'<voice>{i}</voice>') for i in range(7)) content = '<backup><duration>4</duration></backup>'.join(tests.test_musicxml.xml_note(extra=f'<voice>{i}</voice>') for i in range(7))
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)) document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
+5 -5
View File
@@ -10,7 +10,7 @@ import generator.guitar
import generator.harmony import generator.harmony
import generator.model import generator.model
import generator.parts import generator.parts
import generator.playback import tests.score_events
import tests.arrangement_fixtures import tests.arrangement_fixtures
import tests.test_accompaniment import tests.test_accompaniment
import tests.test_musicxml import tests.test_musicxml
@@ -77,7 +77,7 @@ class GuitarPartTests(unittest.TestCase):
for before, after in zip(first['pitches'], second['pitches']): for before, after in zip(first['pitches'], second['pitches']):
self.assertEqual(before['tie_to'], after['id']) self.assertEqual(before['tie_to'], after['id'])
self.assertEqual(after['tablature'], before['tablature']) self.assertEqual(after['tablature'], before['tablature'])
self.assertEqual({generator.model.fraction(e['duration']) for e in generator.playback.compile_timeline(result)['events']}, {2}) self.assertEqual({generator.model.fraction(e['duration']) for e in tests.score_events.timeline(result)['events']}, {2})
def test_chord_change_reattacks_even_for_final_eighth(self): def test_chord_change_reattacks_even_for_final_eighth(self):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=7) + tests.test_musicxml.xml_note('G', 8, 'whole', octave=4) content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=7) + tests.test_musicxml.xml_note('G', 8, 'whole', octave=4)
result = self.section(content, divisions=2) result = self.section(content, divisions=2)
@@ -87,7 +87,7 @@ class GuitarPartTests(unittest.TestCase):
self.assertIn(len(note['pitches']), (3, 4)) self.assertIn(len(note['pitches']), (3, 4))
self.assertFalse(any('tie_from' in p or 'tie_to' in p for p in note['pitches'])) self.assertFalse(any('tie_from' in p or 'tie_to' in p for p in note['pitches']))
self.assertEqual(generator.model.pitch_number(note['pitches'][0]), 79) self.assertEqual(generator.model.pitch_number(note['pitches'][0]), 79)
self.assertEqual({generator.model.fraction(e['onset']) for e in generator.playback.compile_timeline(result)['events']}, {0, fractions.Fraction(7, 2)}) self.assertEqual({generator.model.fraction(e['onset']) for e in tests.score_events.timeline(result)['events']}, {0, fractions.Fraction(7, 2)})
def test_cross_measure_ties_continue_only_without_a_chord_change(self): def test_cross_measure_ties_continue_only_without_a_chord_change(self):
for root in ('C', 'F'): for root in ('C', 'F'):
with self.subTest(root=root): with self.subTest(root=root):
@@ -99,7 +99,7 @@ class GuitarPartTests(unittest.TestCase):
notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes']] notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes']]
self.assertTrue(all(len(n['pitches']) in (3, 4) for n in notes)) self.assertTrue(all(len(n['pitches']) in (3, 4) for n in notes))
self.assertEqual(bool(notes[0]['pitches'][0].get('tie_to')), root == 'C') self.assertEqual(bool(notes[0]['pitches'][0].get('tie_to')), root == 'C')
self.assertEqual({generator.model.fraction(e['onset']) for e in generator.playback.compile_timeline(result)['events']}, {0} if root == 'C' else {0, 4}) self.assertEqual({generator.model.fraction(e['onset']) for e in tests.score_events.timeline(result)['events']}, {0} if root == 'C' else {0, 4})
def test_split_tuplet_keeps_exact_timing_and_span_end(self): def test_split_tuplet_keeps_exact_timing_and_span_end(self):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=10) content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=10)
for index in range(3): for index in range(3):
@@ -120,7 +120,7 @@ class GuitarPartTests(unittest.TestCase):
self.assertIn(len(first['pitches']), (3, 4)) self.assertIn(len(first['pitches']), (3, 4))
self.assertEqual(len(second['pitches']), 1) self.assertEqual(len(second['pitches']), 1)
self.assertEqual(first['pitches'][0]['tie_to'], second['pitches'][0]['id']) self.assertEqual(first['pitches'][0]['tie_to'], second['pitches'][0]['id'])
timeline = generator.playback.compile_timeline(result)['events'] timeline = tests.score_events.timeline(result)['events']
self.assertEqual(sum(generator.model.fraction(e['duration']) == 4 for e in timeline), 1) self.assertEqual(sum(generator.model.fraction(e['duration']) == 4 for e in timeline), 1)
self.assertTrue(all(generator.model.fraction(e['onset']) == 0 for e in timeline)) self.assertTrue(all(generator.model.fraction(e['onset']) == 0 for e in timeline))
def test_unplayable_harmonization_preserves_low_melody(self): def test_unplayable_harmonization_preserves_low_melody(self):
+90 -85
View File
@@ -1,163 +1,168 @@
"""Contextual musical validity needs notes and chords, not author annotations.""" """Contextual musical validity needs notes and chords, not author annotations."""
import dataclasses import dataclasses
import json import json
from fractions import Fraction as F import fractions
from pathlib import Path import pathlib
import unittest import unittest
from generator import train,training_io,vocabulary,harmonic_time,harmony as chords import generator.train
from generator.solo import harmonic,types,decoder,learned,analysis,expressions import generator.training_io
from tests.solo_fixtures import choices,note,song import generator.vocabulary
import generator.harmonic_time
ROOT=Path(__file__).resolve().parents[1] import generator.harmony
import generator.solo.harmonic
import generator.solo.types
import generator.solo.decoder
import generator.solo.learned
import generator.solo.analysis
import generator.solo.expressions
import tests.solo_fixtures
import generator.musicxml
import generator.parts
import generator.solo.authored
ROOT = pathlib.Path(__file__).resolve().parents[1]
class ContextualHarmony(unittest.TestCase): class ContextualHarmony(unittest.TestCase):
def test_approved_corpus_passes_without_annotations_or_authored_privileges(self): def test_approved_corpus_passes_without_annotations_or_authored_privileges(self):
examples,_=train.load_examples(ROOT/'data/training/manifest.json') examples, _ = generator.train.load_examples(ROOT / 'data/training/manifest.json')
manifest = json.loads((ROOT / 'data/training/manifest.json').read_text()) manifest = json.loads((ROOT / 'data/training/manifest.json').read_text())
from generator import musicxml,parts
from generator.solo.authored import sounding
for entry in manifest['examples']: for entry in manifest['examples']:
raw=training_io.read(ROOT/'data/training'/entry['solo'],'',pitch_convention=entry['pitch_convention']) raw = generator.training_io.read(ROOT / 'data/training' / entry['solo'], '', pitch_convention=entry['pitch_convention'])
self.assertEqual(raw['annotations'], []) self.assertEqual(raw['annotations'], [])
doc=parts.prepare_document(musicxml.load_musicxml(ROOT/'data/training'/entry['source'])) doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source']))
h=harmonic.Harmony(harmonic_time.solo_source(doc['staves'][0])) h = generator.solo.harmonic.Harmony(generator.harmonic_time.solo_source(doc['staves'][0]))
ns=tuple(dataclasses.replace(n,role='ordinary',sources=(),event_id='') for n in sounding(raw)) ns = tuple(dataclasses.replace(n, role='ordinary', sources=(), event_id='') for n in generator.solo.authored.sounding(raw))
checked = h.evaluate(ns, complete=True) checked = h.evaluate(ns, complete=True)
self.assertTrue(checked.valid, (entry['id'], checked.error)) self.assertTrue(checked.valid, (entry['id'], checked.error))
self.assertTrue(checked.contracts) self.assertTrue(checked.contracts)
self.assertEqual(len(examples), 4) self.assertEqual(len(examples), 4)
def test_passing_to_nonchord_scale_note(self): def test_passing_to_nonchord_scale_note(self):
h=harmonic.Harmony(choices(('G6',))) h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('G6',)))
notes=(note(0,F(1,2),'g'),note(F(1,2),1,'g',alter=1),note(1,2,'a')) notes = (tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'g'), tests.solo_fixtures.note(fractions.Fraction(1, 2), 1, 'g', alter=1), tests.solo_fixtures.note(1, 2, 'a'))
result = h.evaluate(notes) result = h.evaluate(notes)
self.assertTrue(result.valid, result.error) self.assertTrue(result.valid, result.error)
self.assertEqual(result.contracts[0].kind, 'passing') self.assertEqual(result.contracts[0].kind, 'passing')
self.assertFalse(h.evaluate(notes, infer=False).valid) self.assertFalse(h.evaluate(notes, infer=False).valid)
self.assertFalse(h.evaluate(notes[:-1]).valid) self.assertFalse(h.evaluate(notes[:-1]).valid)
leap=(*notes[:2],dataclasses.replace(notes[2],pitch=types.Pitch('b',F(),4))) leap = (*notes[:2], dataclasses.replace(notes[2], pitch=generator.solo.types.Pitch('b', fractions.Fraction(), 4)))
self.assertFalse(h.evaluate(leap).valid) self.assertFalse(h.evaluate(leap).valid)
long=(notes[0],dataclasses.replace(notes[1],end=F(3,2)),dataclasses.replace(notes[2],start=F(3,2))) long = (notes[0], dataclasses.replace(notes[1], end=fractions.Fraction(3, 2)), dataclasses.replace(notes[2], start=fractions.Fraction(3, 2)))
self.assertFalse(h.evaluate(long).valid) self.assertFalse(h.evaluate(long).valid)
def test_passing_contract_requires_its_actual_preparation(self): def test_passing_contract_requires_its_actual_preparation(self):
h=harmonic.Harmony(choices(('G6',))) h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('G6',)))
a=note(0,F(1,2),'g',event_id='a');b=note(F(1,2),1,'g',alter=1,event_id='b');c=note(1,2,'a',event_id='c') a = tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'g', event_id='a')
contract=types.ExpressionContract('p','passing','chromatic_passing',(b,),c,'GM6',preparation=a) b = tests.solo_fixtures.note(fractions.Fraction(1, 2), 1, 'g', alter=1, event_id='b')
c = tests.solo_fixtures.note(1, 2, 'a', event_id='c')
contract = generator.solo.types.ExpressionContract('p', 'passing', 'chromatic_passing', (b,), c, 'GM6', preparation=a)
self.assertTrue(h.evaluate((a, b, c), expressions=(contract,)).valid) self.assertTrue(h.evaluate((a, b, c), expressions=(contract,)).valid)
changed=dataclasses.replace(a,end=F(1,4)) changed = dataclasses.replace(a, end=fractions.Fraction(1, 4))
wrong = dataclasses.replace(contract, preparation=changed) wrong = dataclasses.replace(contract, preparation=changed)
self.assertFalse(h.evaluate((changed, b, c), expressions=(wrong,)).valid) self.assertFalse(h.evaluate((changed, b, c), expressions=(wrong,)).valid)
high = dataclasses.replace(c, pitch=dataclasses.replace(c.pitch, octave=5)) high = dataclasses.replace(c, pitch=dataclasses.replace(c.pitch, octave=5))
self.assertFalse(h.evaluate((a, b, high), expressions=(dataclasses.replace(contract, target=high),)).valid) self.assertFalse(h.evaluate((a, b, high), expressions=(dataclasses.replace(contract, target=high),)).valid)
def test_color_resolution_can_have_intervening_valid_motion(self): def test_color_resolution_can_have_intervening_valid_motion(self):
h=harmonic.Harmony(choices(('C7',))) h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7',)))
notes=(note(0,F(3,2),'f',alter=1),note(F(3,2),2,'a'),note(2,4,'g')) notes = (tests.solo_fixtures.note(0, fractions.Fraction(3, 2), 'f', alter=1), tests.solo_fixtures.note(fractions.Fraction(3, 2), 2, 'a'), tests.solo_fixtures.note(2, 4, 'g'))
checked = h.evaluate(notes) checked = h.evaluate(notes)
self.assertTrue(checked.valid, checked.error) self.assertTrue(checked.valid, checked.error)
self.assertEqual(checked.contracts[0].rule, 'lydian_color') self.assertEqual(checked.contracts[0].rule, 'lydian_color')
self.assertFalse(h.evaluate(notes[:2]).valid) self.assertFalse(h.evaluate(notes[:2]).valid)
bad_mid=dataclasses.replace(notes[1],pitch=types.Pitch('g',F(1),5)) bad_mid = dataclasses.replace(notes[1], pitch=generator.solo.types.Pitch('g', fractions.Fraction(1), 5))
self.assertFalse(h.evaluate((notes[0], bad_mid, notes[2])).valid) self.assertFalse(h.evaluate((notes[0], bad_mid, notes[2])).valid)
def test_anticipated_stable_and_resolving_colors(self): def test_anticipated_stable_and_resolving_colors(self):
h=harmonic.Harmony(choices(('Bbdim7','Am7'))) h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('Bbdim7', 'Am7')))
n=note(F(7,2),6,'d') n = tests.solo_fixtures.note(fractions.Fraction(7, 2), 6, 'd')
result = h.evaluate((n,)) result = h.evaluate((n,))
self.assertTrue(result.valid, result.error) self.assertTrue(result.valid, result.error)
self.assertEqual(result.contracts[0].kind, 'anticipated_color') self.assertEqual(result.contracts[0].kind, 'anticipated_color')
self.assertFalse(h.evaluate((dataclasses.replace(n,start=F(2)),)).valid) self.assertFalse(h.evaluate((dataclasses.replace(n, start=fractions.Fraction(2)),)).valid)
h=harmonic.Harmony(choices(('Gm7','C7'))) h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('Gm7', 'C7')))
color=note(F(7,2),F(11,2),'f',alter=1) color = tests.solo_fixtures.note(fractions.Fraction(7, 2), fractions.Fraction(11, 2), 'f', alter=1)
target=note(F(11,2),7,'g') target = tests.solo_fixtures.note(fractions.Fraction(11, 2), 7, 'g')
self.assertTrue(h.evaluate((color, target)).valid) self.assertTrue(h.evaluate((color, target)).valid)
self.assertFalse(h.evaluate((color,)).valid) self.assertFalse(h.evaluate((color,)).valid)
flat=dataclasses.replace(color,pitch=types.Pitch('g',F(-1),4)) flat = dataclasses.replace(color, pitch=generator.solo.types.Pitch('g', fractions.Fraction(-1), 4))
self.assertTrue(h.evaluate((flat, target)).valid) self.assertTrue(h.evaluate((flat, target)).valid)
def test_repeated_harmony_cannot_reset_color_duration(self): def test_repeated_harmony_cannot_reset_color_duration(self):
h=harmonic.Harmony(choices(('C7','C7'))) h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7', 'C7')))
color=note(3,6,'f',alter=1);target=note(6,7,'g') color = tests.solo_fixtures.note(3, 6, 'f', alter=1)
target = tests.solo_fixtures.note(6, 7, 'g')
self.assertFalse(h.evaluate((color, target)).valid) self.assertFalse(h.evaluate((color, target)).valid)
def test_held_color_is_independent_of_notation_partition(self): def test_held_color_is_independent_of_notation_partition(self):
h=harmonic.Harmony(choices(('Bbdim7','Am7'))) h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('Bbdim7', 'Am7')))
whole=note(F(7,2),6,'d') whole = tests.solo_fixtures.note(fractions.Fraction(7, 2), 6, 'd')
split=(dataclasses.replace(whole,end=F(4)),dataclasses.replace(whole,start=F(4),continuation=True)) split = (dataclasses.replace(whole, end=fractions.Fraction(4)), dataclasses.replace(whole, start=fractions.Fraction(4), continuation=True))
self.assertEqual(h.evaluate((whole,)), h.evaluate(split)) self.assertEqual(h.evaluate((whole,)), h.evaluate(split))
self.assertEqual(h.describe((whole,)), h.describe(split)) self.assertEqual(h.describe((whole,)), h.describe(split))
def test_unsupported_notes_are_not_authorized_by_labels(self): def test_unsupported_notes_are_not_authorized_by_labels(self):
h=harmonic.Harmony(choices(('C7',))) h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7',)))
for role in ('ordinary', 'authored', 'quote', 'color', 'Lead in'): for role in ('ordinary', 'authored', 'quote', 'color', 'Lead in'):
self.assertFalse(h.evaluate((note(0,3,'c',alter=1,role=role),)).valid) self.assertFalse(h.evaluate((tests.solo_fixtures.note(0, 3, 'c', alter=1, role=role),)).valid)
missing=harmonic.Harmony(()) missing = generator.solo.harmonic.Harmony(())
self.assertFalse(missing.evaluate((note(0,1,'c'),)).valid) self.assertFalse(missing.evaluate((tests.solo_fixtures.note(0, 1, 'c'),)).valid)
def test_fully_expired_partial_treatments_remain_invalid(self): def test_fully_expired_partial_treatments_remain_invalid(self):
h=harmonic.Harmony(choices(('C7',))) h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7',)))
notes=(note(0,F(1,2),'d',alter=1,event_id='a'),note(F(1,2),2,'e',event_id='b')) notes = (tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'd', alter=1, event_id='a'), tests.solo_fixtures.note(fractions.Fraction(1, 2), 2, 'e', event_id='b'))
contract=types.ExpressionContract('a','approach','below',(notes[0],),notes[1],'C7') contract = generator.solo.types.ExpressionContract('a', 'approach', 'below', (notes[0],), notes[1], 'C7')
partial=h.evaluate(notes[:1],until=F(1,2),expressions=(contract,)) partial = h.evaluate(notes[:1], until=fractions.Fraction(1, 2), expressions=(contract,))
self.assertTrue(partial.valid) self.assertTrue(partial.valid)
self.assertEqual(len(partial.pending), 1) self.assertEqual(len(partial.pending), 1)
self.assertFalse(h.evaluate(notes[:1],until=F(2),expressions=(contract,)).valid) self.assertFalse(h.evaluate(notes[:1], until=fractions.Fraction(2), expressions=(contract,)).valid)
def test_pickup_uses_source_form_context_without_overriding_written_harmony(self): def test_pickup_uses_source_form_context_without_overriding_written_harmony(self):
doc=song(count=3,chords=('C','Dm','G7'));staff=doc['staves'][0] doc = tests.solo_fixtures.song(count=3, chords=('C', 'Dm', 'G7'))
pickup=staff['measures'][0];pickup['implicit']=True;pickup['harmonies']=[] staff = doc['staves'][0]
self.assertEqual(chords.symbol(harmonic_time.solo_source(staff)[0].chord),'G7') pickup = staff['measures'][0]
self.assertIsNone(harmonic_time.read(staff)[0].chord) pickup['implicit'] = True
pickup['harmonies']=[dict(chords.parse_symbol('F'),onset={'numerator':0,'denominator':1})] pickup['harmonies'] = []
self.assertEqual(chords.symbol(harmonic_time.solo_source(staff)[0].chord),'FM') self.assertEqual(generator.harmony.symbol(generator.harmonic_time.solo_source(staff)[0].chord), 'G7')
self.assertIsNone(generator.harmonic_time.read(staff)[0].chord)
pickup['harmonies'] = [dict(generator.harmony.parse_symbol('F'), onset={'numerator': 0, 'denominator': 1})]
self.assertEqual(generator.harmony.symbol(generator.harmonic_time.solo_source(staff)[0].chord), 'FM')
pickup['harmonies'] = [{'kind': 'none', 'onset': {'numerator': 0, 'denominator': 1}}] pickup['harmonies'] = [{'kind': 'none', 'onset': {'numerator': 0, 'denominator': 1}}]
self.assertIsNone(harmonic_time.solo_source(staff)[0].chord) self.assertIsNone(generator.harmonic_time.solo_source(staff)[0].chord)
pickup['harmonies']=[];pickup['implicit']=False pickup['harmonies'] = []
self.assertIsNone(harmonic_time.solo_source(staff)[0].chord) pickup['implicit'] = False
self.assertIsNone(generator.harmonic_time.solo_source(staff)[0].chord)
class PredictionReachability(unittest.TestCase): class PredictionReachability(unittest.TestCase):
def engine(self, chords): def engine(self, chords):
doc=song(count=2,chords=chords) doc = tests.solo_fixtures.song(count=2, chords=chords)
return decoder.Decoder(analysis.analyze(doc),harmonic.Harmony(choices(chords)), return generator.solo.decoder.Decoder(generator.solo.analysis.analyze(doc), generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(chords)), generator.solo.learned.Model.load(ROOT / 'data/models/solo.json'), 'acoustic', fractions.Fraction(), fractions.Fraction(8))
learned.Model.load(ROOT/'data/models/solo.json'),'acoustic',F(),F(8))
def test_joint_resolved_color_reaches_the_real_action_builder(self): def test_joint_resolved_color_reaches_the_real_action_builder(self):
d = self.engine(('C7', 'FM7')) d = self.engine(('C7', 'FM7'))
n=d.note(F(),F(3,2),66) n = d.note(fractions.Fraction(), fractions.Fraction(3, 2), 66)
actions = list(d.resolutions(n)) actions = list(d.resolutions(n))
self.assertTrue(any([p for _, _, p in events] == [66, 69, 67] for events, _, _ in actions)) self.assertTrue(any([p for _, _, p in events] == [66, 69, 67] for events, _, _ in actions))
for _, notes, contracts in actions: for _, notes, contracts in actions:
self.assertTrue(d.harmony.evaluate(notes, expressions=contracts, infer=False).valid) self.assertTrue(d.harmony.evaluate(notes, expressions=contracts, infer=False).valid)
def test_anticipated_color_is_a_normal_single_event_option(self): def test_anticipated_color_is_a_normal_single_event_option(self):
d=self.engine(('Bbdim7','Am7'));n=d.note(F(7,2),F(6),62) d = self.engine(('Bbdim7', 'Am7'))
n = d.note(fractions.Fraction(7, 2), fractions.Fraction(6), 62)
support = d.permission(n) support = d.permission(n)
self.assertTrue(support) self.assertTrue(support)
self.assertEqual(support[0].kind, 'anticipated_color') self.assertEqual(support[0].kind, 'anticipated_color')
d.expressions_enabled=False;d._permissions.clear() d.expressions_enabled = False
d._permissions.clear()
self.assertIsNone(d.permission(n)) self.assertIsNone(d.permission(n))
def test_delayed_action_retains_space_and_intervening_motion(self): def test_delayed_action_retains_space_and_intervening_motion(self):
d=self.engine(('C7','FM7'));n=d.note(F(),F(1,2),63) d = self.engine(('C7', 'FM7'))
n = d.note(fractions.Fraction(), fractions.Fraction(1, 2), 63)
actions = list(d.resolutions(n)) actions = list(d.resolutions(n))
self.assertTrue(any(c[0].kind == 'delayed_approach' and any(p is None for _, _, p in events) for events, _, c in actions)) self.assertTrue(any(c[0].kind == 'delayed_approach' and any(p is None for _, _, p in events) for events, _, c in actions))
self.assertTrue(any(c[0].kind == 'delayed_approach' and len(notes) == 3 for _, notes, c in actions)) self.assertTrue(any(c[0].kind == 'delayed_approach' and len(notes) == 3 for _, notes, c in actions))
def test_passing_action_uses_incoming_state(self): def test_passing_action_uses_incoming_state(self):
d=self.engine(('G6','G6'));first=d.note(F(),F(1,2),67) d = self.engine(('G6', 'G6'))
state=d.extend(decoder.State(),(((first.start,first.end,67),),(first,),())) first = d.note(fractions.Fraction(), fractions.Fraction(1, 2), 67)
actions=[a for a in d.actions(F(1,2)) if a[2] and a[2][0].kind=='passing' state = d.extend(generator.solo.decoder.State(), (((first.start, first.end, 67),), (first,), ()))
and a[0]==((F(1,2),F(1),68),(F(1),F(2),69))] actions = [a for a in d.actions(fractions.Fraction(1, 2)) if a[2] and a[2][0].kind == 'passing'
and a[0] == ((fractions.Fraction(1, 2), fractions.Fraction(1), 68), (fractions.Fraction(1), fractions.Fraction(2), 69))]
self.assertTrue(actions) self.assertTrue(actions)
self.assertIsNotNone(d.extend(state, actions[0])) self.assertIsNotNone(d.extend(state, actions[0]))
self.assertIsNone(d.extend(decoder.State(),actions[0])) self.assertIsNone(d.extend(generator.solo.decoder.State(), actions[0]))
wrong=d.note(F(),F(1,2),64) wrong = d.note(fractions.Fraction(), fractions.Fraction(1, 2), 64)
prior=d.extend(decoder.State(),(((wrong.start,wrong.end,64),),(wrong,),())) prior = d.extend(generator.solo.decoder.State(), (((wrong.start, wrong.end, 64),), (wrong,), ()))
self.assertIsNone(d.extend(prior, actions[0])) self.assertIsNone(d.extend(prior, actions[0]))
if __name__ == '__main__':
if __name__=='__main__':unittest.main() unittest.main()
+10 -18
View File
@@ -135,7 +135,7 @@ auto config_file(std::filesystem::path const &root, vamp_zero::config const &con
{"runtime", {{"assets_path", config.runtime.assets_path}, {"heartbeat_path", config.runtime.heartbeat_path}, {"headless", false}, {"exit_after_startup", config.runtime.exit_after_startup}}}, {"runtime", {{"assets_path", config.runtime.assets_path}, {"heartbeat_path", config.runtime.heartbeat_path}, {"headless", false}, {"exit_after_startup", config.runtime.exit_after_startup}}},
{"database", {{"path", config.database.path}}}, {"database", {{"path", config.database.path}}},
{"control", {{"endpoint", ligo::to_json(config.control.endpoint)}, {"zero_ip", config.control.zero_ip}}}, {"control", {{"endpoint", ligo::to_json(config.control.endpoint)}, {"zero_ip", config.control.zero_ip}}},
{"audio", {{"enabled", true}, {"playback_enabled", true}, {"metronome_enabled", true}}}, {"audio", {{"enabled", true}, {"backing_enabled", false}, {"metronome_enabled", true}}},
}; };
auto path = root / "config.json"; auto path = root / "config.json";
write_file(path, boost::json::serialize(json)); write_file(path, boost::json::serialize(json));
@@ -182,6 +182,7 @@ auto write_score(std::filesystem::path const &path, std::filesystem::path const
write_file(path.parent_path() / relative, bytes); write_file(path.parent_path() / relative, bytes);
pages.push_back(boost::json::object{{"path", relative}, {"sha256", checksum(bytes)}}); pages.push_back(boost::json::object{{"path", relative}, {"sha256", checksum(bytes)}});
} }
vamp_test::add_timing(score);
write_file(path, boost::json::serialize(score)); write_file(path, boost::json::serialize(score));
} }
@@ -405,32 +406,23 @@ BOOST_AUTO_TEST_CASE(readiness_rejects_missing_or_corrupted_pico_titles) {
} }
} }
BOOST_AUTO_TEST_CASE(readiness_validates_generated_audio_using_configured_asset_root) { BOOST_AUTO_TEST_CASE(readiness_validates_supplied_audio_using_configured_asset_root) {
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);
auto assets = std::filesystem::path{config.runtime.assets_path}; auto assets = std::filesystem::path{config.runtime.assets_path};
auto fixture = std::filesystem::path{VAMP_TEST_PLAYBACK_ASSETS_DIR}; auto fixture = std::filesystem::path{VAMP_TEST_PERFORMANCE_ASSETS_DIR};
std::filesystem::copy(fixture / "audio", assets / "audio", std::filesystem::copy_options::recursive); std::filesystem::copy(fixture / "sounds", assets / "sounds", std::filesystem::copy_options::recursive);
auto path = assets / "songs/acoustic.json";
write_score(path, assets, "Acoustic");
auto score = boost::json::parse(read_file(path));
score.as_object()["playback"] = boost::json::parse(read_file(fixture / "score.json")).at("playback");
write_file(path, boost::json::serialize(score));
BOOST_REQUIRE(vamp_test::build_catalog(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 path = assets / "sounds/beat1.wav";
auto audio = assets / std::string{sample.at("path").as_string()}; auto bytes = read_file(path);
auto corrupt = read_file(audio); bytes.back() ^= 1;
corrupt[0] = 'X'; write_file(path, bytes);
sample["sha256"] = checksum(corrupt);
write_file(audio, corrupt);
write_file(path, boost::json::serialize(score));
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("checksum mismatch") != std::string::npos);
} }
BOOST_AUTO_TEST_CASE(readiness_rejects_corrupt_checksums_and_escaping_pages) { BOOST_AUTO_TEST_CASE(readiness_rejects_corrupt_checksums_and_escaping_pages) {
+105 -101
View File
@@ -5,126 +5,137 @@ import contextlib
import dataclasses import dataclasses
import json import json
import multiprocessing import multiprocessing
from pathlib import Path import pathlib
import tempfile import tempfile
import unittest import unittest
from fractions import Fraction as F import fractions
from unittest.mock import patch import unittest.mock
from generator import production, model, vocabulary
from generator.solo import expressions, types, harmonic, placement, notation, composer
from tests.solo_fixtures import song
from tests.solo_fixtures import choices
from tests.solo_fixtures import note
import generator.production
import generator.model
import generator.vocabulary
import generator.solo.expressions
import generator.solo.types
import generator.solo.harmonic
import generator.solo.placement
import generator.solo.notation
import generator.solo.composer
import tests.solo_fixtures
import tests.solo_fixtures
import tests.solo_fixtures
def music(result): def music(result):
return result['document'] return result['document']
class IndependentRoles(unittest.TestCase): class IndependentRoles(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.source=production.SourceAnalysis.read(song(count=1,chords=('CM7',))) cls.source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=1, chords=('CM7',)))
cls.root=Path('data').resolve() cls.root = pathlib.Path('data').resolve()
def test_alone_order_parallel_and_assembly(self): def test_alone_order_parallel_and_assembly(self):
serial={role:production.generate_role(self.source,role,self.root) for role in production.ROLES} serial = {role: generator.production.generate_role(self.source, role, self.root) for role in generator.production.ROLES}
with concurrent.futures.ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as pool: with concurrent.futures.ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as pool:
jobs={role:pool.submit(production._job,(self.source,role,self.root,{},None)) for role in reversed(production.ROLES)} jobs = {role: pool.submit(generator.production._job, (self.source, role, self.root, {}, None)) for role in reversed(generator.production.ROLES)}
parallel = {role: future.result() for role, future in jobs.items()} parallel = {role: future.result() for role, future in jobs.items()}
for role in production.ROLES:self.assertEqual(music(serial[role]),music(parallel[role]),role) for role in generator.production.ROLES:
for doc in production.assemble(self.source,serial).values():model.validate_document(doc) self.assertEqual(music(serial[role]), music(parallel[role]), role)
for doc in generator.production.assemble(self.source, serial).values():
generator.model.validate_document(doc)
def test_role_setting_and_cache_are_independent(self): def test_role_setting_and_cache_are_independent(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
a=production.generate_role(self.source,'acoustic_solo',self.root,cache_dir=directory) a = generator.production.generate_role(self.source, 'acoustic_solo', self.root, cache_dir=directory)
b=production.generate_role(self.source,'electric_solo',self.root,{'seed':'changed'},directory) b = generator.production.generate_role(self.source, 'electric_solo', self.root, {'seed': 'changed'}, directory)
again=production.generate_role(self.source,'acoustic_solo',self.root,cache_dir=directory) again = generator.production.generate_role(self.source, 'acoustic_solo', self.root, cache_dir=directory)
clean=production.generate_role(self.source,'acoustic_solo',self.root) clean = generator.production.generate_role(self.source, 'acoustic_solo', self.root)
self.assertTrue(again['cache_hit']);self.assertEqual(music(a),music(again));self.assertEqual(music(a),music(clean)) self.assertTrue(again['cache_hit'])
self.assertEqual(music(a), music(again))
self.assertEqual(music(a), music(clean))
self.assertEqual(json.dumps(music(a)), json.dumps(music(again))) self.assertEqual(json.dumps(music(a)), json.dumps(music(again)))
self.assertNotEqual(a['trace']['cache_key'], b['trace']['cache_key']) self.assertNotEqual(a['trace']['cache_key'], b['trace']['cache_key'])
def test_optional_inputs_cannot_be_opened_by_composition(self): def test_optional_inputs_cannot_be_opened_by_composition(self):
original=Path.open original = pathlib.Path.open
def guarded(path, *args, **kwargs): def guarded(path, *args, **kwargs):
self.assertNotIn('audition',str(path));self.assertNotIn('reference',str(path));self.assertNotIn('generated_assets',str(path)) self.assertNotIn('audition', str(path))
self.assertNotIn('reference', str(path))
self.assertNotIn('generated_assets', str(path))
return original(path, *args, **kwargs) return original(path, *args, **kwargs)
with patch.object(Path,'open',guarded): with unittest.mock.patch.object(pathlib.Path, 'open', guarded):
result=production.generate_role(self.source,'acoustic_solo',self.root) result = generator.production.generate_role(self.source, 'acoustic_solo', self.root)
self.assertNotIn('reference', json.dumps(result['trace'])) self.assertNotIn('reference', json.dumps(result['trace']))
def test_titles_and_export_ids_do_not_supply_musical_identity(self): def test_titles_and_export_ids_do_not_supply_musical_identity(self):
document=song(count=1,chords=('CM7',));other=copy.deepcopy(document) document = tests.solo_fixtures.song(count=1, chords=('CM7',))
other = copy.deepcopy(document)
other['metadata'].update(title='A completely different title', composer='Different') other['metadata'].update(title='A completely different title', composer='Different')
for m in other['staves'][0]['measures']:m['id']='exporter_123';m['number']='99' for m in other['staves'][0]['measures']:
self.assertEqual(production.SourceAnalysis.read(document),production.SourceAnalysis.read(other)) m['id'] = 'exporter_123'
m['number'] = '99'
self.assertEqual(generator.production.SourceAnalysis.read(document), generator.production.SourceAnalysis.read(other))
def test_reference_presence_and_changes_do_not_change_clean_music(self): def test_reference_presence_and_changes_do_not_change_clean_music(self):
# No repository reference is a fixture or prerequisite. These deliberately # No repository reference is a fixture or prerequisite. These deliberately
# invalid optional files test the absence of a discovery/fallback branch. # invalid optional files test the absence of a discovery/fallback branch.
with tempfile.TemporaryDirectory() as directory, contextlib.chdir(directory): with tempfile.TemporaryDirectory() as directory, contextlib.chdir(directory):
reference_dir=Path(directory)/'build';reference_dir.mkdir() reference_dir = pathlib.Path(directory) / 'build'
references=[reference_dir/(song+'-solo-'+str(index)+suffix+'.xml') reference_dir.mkdir()
for song in ('all-of-me','autumn-leaves') for index in (1,2) for suffix in ('','(4)')] references = [reference_dir / (source_name + '-solo-' + str(index) + suffix + '.xml')
before=production.generate_role(self.source,'electric_solo',self.root) for source_name in ('all-of-me', 'autumn-leaves') for index in (1, 2) for suffix in ('', '(4)')]
for reference in references:reference.write_text('<not-a-production-source/>') before = generator.production.generate_role(self.source, 'electric_solo', self.root)
present=production.generate_role(self.source,'electric_solo',self.root) for reference in references:
for reference in references:reference.write_text('changed optional target, still not MusicXML') reference.write_text('<not-a-production-source/>')
changed=production.generate_role(self.source,'electric_solo',self.root) present = generator.production.generate_role(self.source, 'electric_solo', self.root)
for reference in references:reference.unlink() for reference in references:
absent=production.generate_role(self.source,'electric_solo',self.root) reference.write_text('changed optional target, still not MusicXML')
changed = generator.production.generate_role(self.source, 'electric_solo', self.root)
for reference in references:
reference.unlink()
absent = generator.production.generate_role(self.source, 'electric_solo', self.root)
self.assertTrue(all(music(before) == music(v) for v in (present, changed, absent))) self.assertTrue(all(music(before) == music(v) for v in (present, changed, absent)))
def test_original_melody_changes_the_theme_with_unchanged_harmony(self): def test_original_melody_changes_the_theme_with_unchanged_harmony(self):
source=song(count=4,chords=('CM7',)) source = tests.solo_fixtures.song(count=4, chords=('CM7',))
changed = copy.deepcopy(source) changed = copy.deepcopy(source)
for m in changed['staves'][0]['measures']: for m in changed['staves'][0]['measures']:
for v in m['voices']: for v in m['voices']:
for n in v['notes']: for n in v['notes']:
for p in n['pitches']:p['letter']={'c':'e','d':'f','e':'g','f':'a'}[p['letter']] for p in n['pitches']:
a=production.generate_role(production.SourceAnalysis.read(source),'acoustic_solo',self.root) p['letter'] = {'c': 'e', 'd': 'f', 'e': 'g', 'f': 'a'}[p['letter']]
b=production.generate_role(production.SourceAnalysis.read(changed),'acoustic_solo',self.root) a = generator.production.generate_role(generator.production.SourceAnalysis.read(source), 'acoustic_solo', self.root)
b = generator.production.generate_role(generator.production.SourceAnalysis.read(changed), 'acoustic_solo', self.root)
self.assertNotEqual(a['details']['phrases'][0]['notes'], b['details']['phrases'][0]['notes']) self.assertNotEqual(a['details']['phrases'][0]['notes'], b['details']['phrases'][0]['notes'])
def test_source_tuplet_lanes_survive_assembly(self): def test_source_tuplet_lanes_survive_assembly(self):
serial={r:production.generate_role(self.source,r,self.root) for r in production.ROLES} serial = {r: generator.production.generate_role(self.source, r, self.root) for r in generator.production.ROLES}
# Exercise a different original lane name, as in the imported Autumn head. # Exercise a different original lane name, as in the imported Autumn head.
doc=serial['electric_melody']['document'];staff=doc['staves'][0] doc = serial['electric_melody']['document']
raw=tuple(note(F(i,3),F(i+1,3),'c',event_id=str(i)) for i in range(3)) staff = doc['staves'][0]
placed=placement.place(raw,F(120))[1] raw = tuple(tests.solo_fixtures.note(fractions.Fraction(i, 3), fractions.Fraction(i + 1, 3), 'c', event_id=str(i)) for i in range(3))
placed = generator.solo.placement.place(raw, fractions.Fraction(120))[1]
index = next(i for i, m in enumerate(staff['measures']) if m.get('section_label') == 'Guitar Melody') index = next(i for i, m in enumerate(staff['measures']) if m.get('section_label') == 'Guitar Melody')
replacement=notation.measures(staff['measures'][index:index+1],placed,'source_triplet') replacement = generator.solo.notation.measures(staff['measures'][index:index + 1], placed, 'source_triplet')
notation.replace_region(doc,index,replacement) generator.solo.notation.replace_region(doc, index, replacement)
for m in staff['measures']: for m in staff['measures']:
for v in m['voices']: for v in m['voices']:
v['id'] = 'voice_source' v['id'] = 'voice_source'
for n in v['notes']:n['voice']='voice_source' for n in v['notes']:
for t in staff['tuplets']:t['voice']='voice_source' n['voice'] = 'voice_source'
model.validate_document(doc) for t in staff['tuplets']:
model.validate_document(production.assemble(self.source,serial)['electric']) t['voice'] = 'voice_source'
generator.model.validate_document(doc)
generator.model.validate_document(generator.production.assemble(self.source, serial)['electric'])
def test_sparse_parts_reuse_the_canonical_form(self): def test_sparse_parts_reuse_the_canonical_form(self):
result=production.generate_role(production.SourceAnalysis.read(song(count=4,chords=('Dm7','G7','CM7'))),'acoustic_accompaniment',self.root) result = generator.production.generate_role(generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=4, chords=('Dm7', 'G7', 'CM7'))), 'acoustic_accompaniment', self.root)
measures = result['document']['staves'][0]['measures'] measures = result['document']['staves'][0]['measures']
def signature(m): def signature(m):
return [(n['onset'],n['duration'],[(model.pitch_number(p),p['tablature']) for p in n['pitches']]) for v in m['voices'] for n in v['notes']] return [(n['onset'], n['duration'], [(generator.model.pitch_number(p), p['tablature']) for p in n['pitches']]) for v in m['voices'] for n in v['notes']]
self.assertEqual([signature(m) for m in measures[4:8]], [signature(m) for m in measures[8:12]]) self.assertEqual([signature(m) for m in measures[4:8]], [signature(m) for m in measures[8:12]])
self.assertTrue(all(len(n['pitches']) <= 3 for m in measures for v in m['voices'] for n in v['notes'])) self.assertTrue(all(len(n['pitches']) <= 3 for m in measures for v in m['voices'] for n in v['notes']))
class TypedExpressionContracts(unittest.TestCase): class TypedExpressionContracts(unittest.TestCase):
def setUp(self): def setUp(self):
self.h=harmonic.Harmony(choices(('C7','FM7'))) self.h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7', 'FM7')))
def make(self, rule='upper_lower'): def make(self, rule='upper_lower'):
target=note(2,4,'e',event_id='target') target = tests.solo_fixtures.note(2, 4, 'e', event_id='target')
upper=note(1,F(3,2),'f',event_id='upper') upper = tests.solo_fixtures.note(1, fractions.Fraction(3, 2), 'f', event_id='upper')
lower=dataclasses.replace(note(F(3,2),2,'d',event_id='lower'),pitch=types.Pitch('d',F(1),4)) lower = dataclasses.replace(tests.solo_fixtures.note(fractions.Fraction(3, 2), 2, 'd', event_id='lower'), pitch=generator.solo.types.Pitch('d', fractions.Fraction(1), 4))
c=types.ExpressionContract('gesture','enclosure',rule,(upper,lower),target,'C7') c = generator.solo.types.ExpressionContract('gesture', 'enclosure', rule, (upper, lower), target, 'C7')
return (upper, lower, target), c return (upper, lower, target), c
def test_complete_enclosure_and_orphans(self): def test_complete_enclosure_and_orphans(self):
notes, c = self.make() notes, c = self.make()
self.assertTrue(self.h.evaluate(notes, expressions=(c,)).valid) self.assertTrue(self.h.evaluate(notes, expressions=(c,)).valid)
@@ -133,61 +144,54 @@ class TypedExpressionContracts(unittest.TestCase):
self.assertFalse(self.h.evaluate(notes[:-1], expressions=(c,)).valid) self.assertFalse(self.h.evaluate(notes[:-1], expressions=(c,)).valid)
changed = (*notes[:-1], dataclasses.replace(notes[-1], pitch=dataclasses.replace(notes[-1].pitch, octave=5))) changed = (*notes[:-1], dataclasses.replace(notes[-1], pitch=dataclasses.replace(notes[-1].pitch, octave=5)))
self.assertFalse(self.h.evaluate(changed, expressions=(c,)).valid) self.assertFalse(self.h.evaluate(changed, expressions=(c,)).valid)
def test_anticipation_validates_actual_future_harmony(self): def test_anticipation_validates_actual_future_harmony(self):
n=note(F(7,2),6,'a',event_id='early') n = tests.solo_fixtures.note(fractions.Fraction(7, 2), 6, 'a', event_id='early')
c=types.ExpressionContract('anticipation','anticipation','early_chord_tone',(n,),n,'FM7',boundary=F(4)) c = generator.solo.types.ExpressionContract('anticipation', 'anticipation', 'early_chord_tone', (n,), n, 'FM7', boundary=fractions.Fraction(4))
self.assertTrue(self.h.evaluate((n,), expressions=(c,)).valid) self.assertTrue(self.h.evaluate((n,), expressions=(c,)).valid)
self.assertTrue(self.h.evaluate((n,)).valid) self.assertTrue(self.h.evaluate((n,)).valid)
self.assertFalse(self.h.evaluate((n,), infer=False).valid) self.assertFalse(self.h.evaluate((n,), infer=False).valid)
self.assertFalse(self.h.evaluate((n,),expressions=(dataclasses.replace(c,boundary=F(3)),)).valid) self.assertFalse(self.h.evaluate((n,), expressions=(dataclasses.replace(c, boundary=fractions.Fraction(3)),)).valid)
def test_optional_color_is_not_a_written_chord_requirement(self): def test_optional_color_is_not_a_written_chord_requirement(self):
n=note(0,2,'d',event_id='ninth') n = tests.solo_fixtures.note(0, 2, 'd', event_id='ninth')
c=types.ExpressionContract('color','color','dominant_ninth',(n,),n,'C7') c = generator.solo.types.ExpressionContract('color', 'color', 'dominant_ninth', (n,), n, 'C7')
self.assertTrue(self.h.evaluate((n,), expressions=(c,)).valid) self.assertTrue(self.h.evaluate((n,), expressions=(c,)).valid)
self.assertTrue(self.h.evaluate((n,)).valid) self.assertTrue(self.h.evaluate((n,)).valid)
self.assertFalse(self.h.evaluate((n,), infer=False).valid) self.assertFalse(self.h.evaluate((n,), infer=False).valid)
self.assertNotIn(n.pitch.number() % 12, self.h.spans[0].roles.basic) self.assertNotIn(n.pitch.number() % 12, self.h.spans[0].roles.basic)
self.assertNotIn(n.pitch.number() % 12, self.h.spans[0].roles.specified) self.assertNotIn(n.pitch.number() % 12, self.h.spans[0].roles.specified)
def test_labels_do_not_authorize_unresolved_notes(self): def test_labels_do_not_authorize_unresolved_notes(self):
n=dataclasses.replace(note(0,2,'f',event_id='bad'),pitch=types.Pitch('f',F(1),4),role='color') n = dataclasses.replace(tests.solo_fixtures.note(0, 2, 'f', event_id='bad'), pitch=generator.solo.types.Pitch('f', fractions.Fraction(1), 4), role='color')
self.assertFalse(self.h.evaluate((n,)).valid) self.assertFalse(self.h.evaluate((n,)).valid)
c=types.ExpressionContract('bad','color','anything',(n,),n,'C7') c = generator.solo.types.ExpressionContract('bad', 'color', 'anything', (n,), n, 'C7')
self.assertFalse(self.h.evaluate((n,), expressions=(c,)).valid) self.assertFalse(self.h.evaluate((n,), expressions=(c,)).valid)
def test_delayed_approach_allows_only_the_declared_rest(self): def test_delayed_approach_allows_only_the_declared_rest(self):
lower=dataclasses.replace(note(1,F(3,2),'d',event_id='lower'),pitch=types.Pitch('d',F(1),4)) lower = dataclasses.replace(tests.solo_fixtures.note(1, fractions.Fraction(3, 2), 'd', event_id='lower'), pitch=generator.solo.types.Pitch('d', fractions.Fraction(1), 4))
target=note(2,4,'e',event_id='target') target = tests.solo_fixtures.note(2, 4, 'e', event_id='target')
c=types.ExpressionContract('delay','approach','delayed_below',(lower,),target,'C7') c = generator.solo.types.ExpressionContract('delay', 'approach', 'delayed_below', (lower,), target, 'C7')
self.assertTrue(self.h.evaluate((lower, target), expressions=(c,)).valid) self.assertTrue(self.h.evaluate((lower, target), expressions=(c,)).valid)
wrong = dataclasses.replace(c, rule='below') wrong = dataclasses.replace(c, rule='below')
self.assertFalse(self.h.evaluate((lower, target), expressions=(wrong,)).valid) self.assertFalse(self.h.evaluate((lower, target), expressions=(wrong,)).valid)
def test_altered_dominant_color_requires_function_and_resolution(self): def test_altered_dominant_color_requires_function_and_resolution(self):
color=dataclasses.replace(note(0,1,'d',event_id='color'),pitch=types.Pitch('d',F(-1),4)) color = dataclasses.replace(tests.solo_fixtures.note(0, 1, 'd', event_id='color'), pitch=generator.solo.types.Pitch('d', fractions.Fraction(-1), 4))
target=note(1,2,'c',event_id='target') target = tests.solo_fixtures.note(1, 2, 'c', event_id='target')
c=types.ExpressionContract('flat9','color','dominant_flat_ninth',(color,),target,'C7') c = generator.solo.types.ExpressionContract('flat9', 'color', 'dominant_flat_ninth', (color,), target, 'C7')
self.assertTrue(self.h.evaluate((color, target), expressions=(c,)).valid) self.assertTrue(self.h.evaluate((color, target), expressions=(c,)).valid)
unresolved=harmonic.Harmony(choices(('C7','Dm7'))) unresolved = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7', 'Dm7')))
self.assertFalse(unresolved.evaluate((color, target), expressions=(c,)).valid) self.assertFalse(unresolved.evaluate((color, target), expressions=(c,)).valid)
self.assertFalse(self.h.evaluate((color,), expressions=(c,)).valid) self.assertFalse(self.h.evaluate((color,), expressions=(c,)).valid)
def test_mapped_semitone_needs_no_expression_exception(self): def test_mapped_semitone_needs_no_expression_exception(self):
h=harmonic.Harmony(choices(('A7','Dm7'))) h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('A7', 'Dm7')))
a=dataclasses.replace(note(0,F(1,2),'c',event_id='csharp'),pitch=types.Pitch('c',F(1),4)) a = dataclasses.replace(tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'c', event_id='csharp'), pitch=generator.solo.types.Pitch('c', fractions.Fraction(1), 4))
b=note(F(1,2),1,'d',event_id='d') b = tests.solo_fixtures.note(fractions.Fraction(1, 2), 1, 'd', event_id='d')
self.assertTrue(h.evaluate((a, b)).valid) self.assertTrue(h.evaluate((a, b)).valid)
def test_ties_and_barlines_do_not_restart_triplets(self): def test_ties_and_barlines_do_not_restart_triplets(self):
raw=tuple(note(F(7,2)+F(i,3),F(7,2)+F(i+1,3),'c',event_id=str(i)) for i in range(3)) raw = tuple(tests.solo_fixtures.note(fractions.Fraction(7, 2) + fractions.Fraction(i, 3), fractions.Fraction(7, 2) + fractions.Fraction(i + 1, 3), 'c', event_id=str(i)) for i in range(3))
placed=placement.place(raw,F(120))[1] placed = generator.solo.placement.place(raw, fractions.Fraction(120))[1]
doc=song(count=2,chords=('CM7',));measures=notation.measures(doc['staves'][0]['measures'],placed,'triplet') doc = tests.solo_fixtures.song(count=2, chords=('CM7',))
spans=notation.tuplets(measures) measures = generator.solo.notation.measures(doc['staves'][0]['measures'], placed, 'triplet')
spans = generator.solo.notation.tuplets(measures)
self.assertEqual(len(spans), 1) self.assertEqual(len(spans), 1)
self.assertTrue(any('tie_from' in p for m in measures for v in m['voices'] for n in v['notes'] for p in n['pitches'])) self.assertTrue(any('tie_from' in p for m in measures for v in m['voices'] for n in v['notes'] for p in n['pitches']))
if __name__ == '__main__':
unittest.main()
if __name__=='__main__':unittest.main()
+98 -82
View File
@@ -2,142 +2,158 @@
import copy import copy
import dataclasses import dataclasses
import json import json
from pathlib import Path import pathlib
from fractions import Fraction as F import fractions
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch import unittest.mock
from generator import production,parts,musicxml,train,training_io,vocabulary import generator.production
from generator.solo import authored,learned,composer,decoder,harmonic,analysis,export import generator.parts
from tests.solo_fixtures import song import generator.musicxml
from tests.test_musicxml import xml_score,xml_note import generator.train
import generator.training_io
import generator.vocabulary
import generator.solo.authored
import generator.solo.learned
import generator.solo.composer
import generator.solo.decoder
import generator.solo.harmonic
import generator.solo.analysis
import generator.solo.export
import tests.solo_fixtures
import tests.test_musicxml
import generator.harmonic_time
ROOT=Path(__file__).resolve().parents[1] ROOT = pathlib.Path(__file__).resolve().parents[1]
def music(events):return tuple((F(n['start']),F(n['end']),F(n['sounding_midi'])) for n in events)
def music(events): return tuple((fractions.Fraction(n['start']), fractions.Fraction(n['end']), fractions.Fraction(n['sounding_midi'])) for n in events)
class TrainingAndRepertoire(unittest.TestCase): class TrainingAndRepertoire(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.examples,cls.provenance=train.load_examples(ROOT/'data/training/manifest.json') cls.examples, cls.provenance = generator.train.load_examples(ROOT / 'data/training/manifest.json')
def test_all_four_examples_and_intentional_pickup_forms(self): def test_all_four_examples_and_intentional_pickup_forms(self):
self.assertEqual(len(self.examples), 4) self.assertEqual(len(self.examples), 4)
self.assertEqual(sorted(p['measures'] for p in self.provenance['examples']), [32, 32, 33, 33]) self.assertEqual(sorted(p['measures'] for p in self.provenance['examples']), [32, 32, 33, 33])
self.assertEqual(sorted(p['pickup_beats'] for p in self.provenance['examples']), ['0', '0', '4', '4']) self.assertEqual(sorted(p['pickup_beats'] for p in self.provenance['examples']), ['0', '0', '4', '4'])
def test_training_is_pooled_and_deterministic(self): def test_training_is_pooled_and_deterministic(self):
baseline=learned.fit(self.examples,{}) baseline = generator.solo.learned.fit(self.examples, {})
swapped = [dict(e, role='electric' if e['role'] == 'acoustic' else 'acoustic') for e in reversed(self.examples)] swapped = [dict(e, role='electric' if e['role'] == 'acoustic' else 'acoustic') for e in reversed(self.examples)]
self.assertEqual(baseline,learned.fit(swapped,{})) self.assertEqual(baseline, generator.solo.learned.fit(swapped, {}))
self.assertNotIn('acoustic', json.dumps(baseline)) self.assertNotIn('acoustic', json.dumps(baseline))
self.assertNotIn('electric', json.dumps(baseline)) self.assertNotIn('electric', json.dumps(baseline))
frozen = json.loads((ROOT / 'data/models/solo.json').read_text()) frozen = json.loads((ROOT / 'data/models/solo.json').read_text())
self.assertEqual(frozen,learned.fit(self.examples,self.provenance)) self.assertEqual(frozen, generator.solo.learned.fit(self.examples, self.provenance))
def test_repertoire_exports_exact_sounding_music_for_every_registered_pair(self): def test_repertoire_exports_exact_sounding_music_for_every_registered_pair(self):
manifest = json.loads((ROOT / 'data/training/manifest.json').read_text()) manifest = json.loads((ROOT / 'data/training/manifest.json').read_text())
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
for entry in manifest['examples']: for entry in manifest['examples']:
part = 'acoustic' if entry['slot'] == 1 else 'electric' part = 'acoustic' if entry['slot'] == 1 else 'electric'
doc=parts.prepare_document(musicxml.load_musicxml(ROOT/'data/training'/entry['source'])) doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source']))
source=production.SourceAnalysis.read(doc) source = generator.production.SourceAnalysis.read(doc)
with patch('generator.solo.composer.compose',side_effect=AssertionError('authored pair must not be predicted')): with unittest.mock.patch('generator.solo.composer.compose', side_effect=AssertionError('authored pair must not be predicted')):
result=production.generate_role(source,part+'_solo',ROOT/'data') result = generator.production.generate_role(source, part + '_solo', ROOT / 'data')
path=Path(directory)/(part+'.xml');export.write_role(result,path) path = pathlib.Path(directory) / (part + '.xml')
actual=training_io.read(path,part);expected=training_io.read(ROOT/'data/training'/entry['solo'],part,pitch_convention=entry['pitch_convention']) generator.solo.export.write_role(result, path)
actual = generator.training_io.read(path, part)
expected = generator.training_io.read(ROOT / 'data/training' / entry['solo'], part, pitch_convention=entry['pitch_convention'])
self.assertEqual(music(actual['sounding_events']), music(expected['sounding_events']), entry['id']) self.assertEqual(music(actual['sounding_events']), music(expected['sounding_events']), entry['id'])
self.assertEqual([(m['start'], m['duration']) for m in actual['measures']], [(m['start'], m['duration']) for m in expected['measures']]) self.assertEqual([(m['start'], m['duration']) for m in actual['measures']], [(m['start'], m['duration']) for m in expected['measures']])
self.assertEqual(actual['discrepancies'], []) self.assertEqual(actual['discrepancies'], [])
self.assertTrue(all(n['legal_positions_at_sounding_pitch'] for n in actual['notes'] if n['pitch'])) self.assertTrue(all(n['legal_positions_at_sounding_pitch'] for n in actual['notes'] if n['pitch']))
def test_lookup_uses_source_music_and_slot_not_title_or_path(self): def test_lookup_uses_source_music_and_slot_not_title_or_path(self):
entry = json.loads((ROOT / 'data/training/manifest.json').read_text())['examples'][0] entry = json.loads((ROOT / 'data/training/manifest.json').read_text())['examples'][0]
doc=parts.prepare_document(musicxml.load_musicxml(ROOT/'data/training'/entry['source'])) doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source']))
source=production.SourceAnalysis.read(doc) source = generator.production.SourceAnalysis.read(doc)
part = 'acoustic' if entry['slot'] == 1 else 'electric' part = 'acoustic' if entry['slot'] == 1 else 'electric'
self.assertIsNotNone(authored.lookup(source,part,ROOT/'data')) self.assertIsNotNone(generator.solo.authored.lookup(source, part, ROOT / 'data'))
doc['metadata'].update(title='Renamed standard', composer='Display only') doc['metadata'].update(title='Renamed standard', composer='Display only')
self.assertEqual(source,production.SourceAnalysis.read(doc)) self.assertEqual(source, generator.production.SourceAnalysis.read(doc))
doc['staves'][0]['measures'][0]['voices'][0]['notes'][0]['pitches'][0]['octave'] += 1 doc['staves'][0]['measures'][0]['voices'][0]['notes'][0]['pitches'][0]['octave'] += 1
self.assertIsNone(authored.lookup(production.SourceAnalysis.read(doc),part,ROOT/'data')) self.assertIsNone(generator.solo.authored.lookup(generator.production.SourceAnalysis.read(doc), part, ROOT / 'data'))
def test_adding_a_new_pair_requires_no_code_or_existing_song_names(self): def test_adding_a_new_pair_requires_no_code_or_existing_song_names(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root=Path(directory);source=root/'new-standard.xml';solo=root/'interpretation.xml' root = pathlib.Path(directory)
content=xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>'+xml_note('E',octave=5)) source = root / 'new-standard.xml'
source.write_text(content);solo.write_text(content) solo = root / 'interpretation.xml'
content = tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note('E', octave=5))
source.write_text(content)
solo.write_text(content)
manifest = root / 'data/training/manifest.json' manifest = root / 'data/training/manifest.json'
train.register_example(manifest,source,solo,1,'guitar-written') generator.train.register_example(manifest, source, solo, 1, 'guitar-written')
examples,_=train.load_examples(manifest) examples, _ = generator.train.load_examples(manifest)
self.assertEqual(len(examples), 1) self.assertEqual(len(examples), 1)
doc=parts.prepare_document(musicxml.load_musicxml(root/'data/songs'/source.name)) doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(root / 'data/songs' / source.name))
context=production.SourceAnalysis.read(doc) context = generator.production.SourceAnalysis.read(doc)
self.assertIsNotNone(authored.lookup(context,'acoustic',root/'data')) self.assertIsNotNone(generator.solo.authored.lookup(context, 'acoustic', root / 'data'))
self.assertIsNone(authored.lookup(context,'electric',root/'data')) self.assertIsNone(generator.solo.authored.lookup(context, 'electric', root / 'data'))
self.assertEqual(solo.read_bytes(), (manifest.parent / solo.name).read_bytes()) self.assertEqual(solo.read_bytes(), (manifest.parent / solo.name).read_bytes())
before = manifest.read_bytes() before = manifest.read_bytes()
with self.assertRaisesRegex(ValueError,'already registered'):train.register_example(manifest,source,solo,1,'guitar-written') with self.assertRaisesRegex(ValueError, 'already registered'):
generator.train.register_example(manifest, source, solo, 1, 'guitar-written')
self.assertEqual(manifest.read_bytes(), before) self.assertEqual(manifest.read_bytes(), before)
def test_incorrect_form_registration_rolls_back(self): def test_incorrect_form_registration_rolls_back(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root=Path(directory);source=root/'song.xml';solo=root/'solo.xml' root = pathlib.Path(directory)
source.write_text(xml_score(xml_note()));solo.write_text(xml_score(xml_note(duration=2,kind='half'))) source = root / 'song.xml'
solo = root / 'solo.xml'
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
solo.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half')))
manifest = root / 'data/training/manifest.json' manifest = root / 'data/training/manifest.json'
with self.assertRaisesRegex(ValueError,'timing differs'):train.register_example(manifest,source,solo,2,'guitar-written') with self.assertRaisesRegex(ValueError, 'timing differs'):
self.assertFalse(manifest.exists());self.assertFalse((root/'data/songs/song.xml').exists());self.assertFalse((root/'data/training/solo.xml').exists()) generator.train.register_example(manifest, source, solo, 2, 'guitar-written')
self.assertFalse(manifest.exists())
self.assertFalse((root / 'data/songs/song.xml').exists())
self.assertFalse((root / 'data/training/solo.xml').exists())
class LearnedInference(unittest.TestCase): class LearnedInference(unittest.TestCase):
def test_model_is_required_without_repertoire(self): def test_model_is_required_without_repertoire(self):
with self.assertRaisesRegex(ValueError,'run python -m generator.train'):learned.Model.load('/missing-model-file') with self.assertRaisesRegex(ValueError, 'run python -m generator.train'):
generator.solo.learned.Model.load('/missing-model-file')
def test_decoder_runs_without_training_files_and_is_deterministic(self): def test_decoder_runs_without_training_files_and_is_deterministic(self):
source=song(count=1,chords=('C7',));a=analysis.analyze(source) source = tests.solo_fixtures.song(count=1, chords=('C7',))
predictor=learned.Model.load(ROOT/'data/models/solo.json') a = generator.solo.analysis.analyze(source)
with vocabulary.using(vocabulary.load(),'acoustic_solo',{'attempts':2}): predictor = generator.solo.learned.Model.load(ROOT / 'data/models/solo.json')
original=Path.open with generator.vocabulary.using(generator.vocabulary.load(), 'acoustic_solo', {'attempts': 2}):
original = pathlib.Path.open
def guarded(path, *args, **kwargs): def guarded(path, *args, **kwargs):
self.assertNotIn('training',str(path));return original(path,*args,**kwargs) self.assertNotIn('training', str(path))
with patch.object(Path,'open',guarded): return original(path, *args, **kwargs)
first=composer.compose(a,source['staves'][0]['measures'],'acoustic',F(4),learned_model=predictor) with unittest.mock.patch.object(pathlib.Path, 'open', guarded):
second=composer.compose(a,source['staves'][0]['measures'],'acoustic',F(4),learned_model=predictor) first = generator.solo.composer.compose(a, source['staves'][0]['measures'], 'acoustic', fractions.Fraction(4), learned_model=predictor)
self.assertEqual(first.notes(),second.notes());composer.validate(first) second = generator.solo.composer.compose(a, source['staves'][0]['measures'], 'acoustic', fractions.Fraction(4), learned_model=predictor)
self.assertTrue(first.notes());self.assertEqual(first.diagnostics['construction'],'learned_joint_events') self.assertEqual(first.notes(), second.notes())
generator.solo.composer.validate(first)
self.assertTrue(first.notes())
self.assertEqual(first.diagnostics['construction'], 'learned_joint_events')
def test_event_score_does_not_depend_on_action_grouping(self): def test_event_score_does_not_depend_on_action_grouping(self):
source=song(count=1,chords=('C7',));a=analysis.analyze(source) source = tests.solo_fixtures.song(count=1, chords=('C7',))
from generator.harmonic_time import read a = generator.solo.analysis.analyze(source)
h=harmonic.Harmony(read(source['staves'][0]));predictor=learned.Model.load(ROOT/'data/models/solo.json') h = generator.solo.harmonic.Harmony(generator.harmonic_time.read(source['staves'][0]))
engine=decoder.Decoder(a,h,predictor,'acoustic',F(),F(4)) predictor = generator.solo.learned.Model.load(ROOT / 'data/models/solo.json')
notes=(engine.note(F(),F(1),60),engine.note(F(1),F(2),64)) engine = generator.solo.decoder.Decoder(a, h, predictor, 'acoustic', fractions.Fraction(), fractions.Fraction(4))
notes = (engine.note(fractions.Fraction(), fractions.Fraction(1), 60), engine.note(fractions.Fraction(1), fractions.Fraction(2), 64))
events = tuple((n.start, n.end, n.pitch.number()) for n in notes) events = tuple((n.start, n.end, n.pitch.number()) for n in notes)
combined=engine.extend(decoder.State(),(events,notes,())) combined = engine.extend(generator.solo.decoder.State(), (events, notes, ()))
split=engine.extend(engine.extend(decoder.State(),(events[:1],notes[:1],())),(events[1:],notes[1:],())) split = engine.extend(engine.extend(generator.solo.decoder.State(), (events[:1], notes[:1], ())), (events[1:], notes[1:], ()))
self.assertEqual(combined, split) self.assertEqual(combined, split)
def test_source_features_include_actual_melodic_intervals(self): def test_source_features_include_actual_melodic_intervals(self):
source=song(count=1,chords=('C7',));a=analysis.analyze(source) source = tests.solo_fixtures.song(count=1, chords=('C7',))
a = generator.solo.analysis.analyze(source)
changed = dataclasses.replace(a, melody=tuple(dataclasses.replace(n, pitch=dataclasses.replace(n.pitch, octave=n.pitch.octave + (i == 1))) for i, n in enumerate(a.melody))) changed = dataclasses.replace(a, melody=tuple(dataclasses.replace(n, pitch=dataclasses.replace(n.pitch, octave=n.pitch.octave + (i == 1))) for i, n in enumerate(a.melody)))
self.assertNotEqual(learned.source_feature(a,F(1)),learned.source_feature(changed,F(1))) self.assertNotEqual(generator.solo.learned.source_feature(a, fractions.Fraction(1)), generator.solo.learned.source_feature(changed, fractions.Fraction(1)))
def test_generated_long_nonchord_notes_still_need_support(self): def test_generated_long_nonchord_notes_still_need_support(self):
from tests.solo_fixtures import choices,note h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7',)))
h=harmonic.Harmony(choices(('C7',))) self.assertTrue(h.evaluate((tests.solo_fixtures.note(0, 2, 'e'),)).valid)
self.assertTrue(h.evaluate((note(0,2,'e'),)).valid) self.assertFalse(h.evaluate((tests.solo_fixtures.note(0, 2, 'f'),)).valid)
self.assertFalse(h.evaluate((note(0,2,'f'),)).valid) self.assertTrue(h.evaluate((tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'g'), tests.solo_fixtures.note(fractions.Fraction(1, 2), 1, 'f'), tests.solo_fixtures.note(1, 2, 'e'))).valid)
self.assertTrue(h.evaluate((note(0,F(1,2),'g'),note(F(1,2),1,'f'),note(1,2,'e'))).valid)
def test_held_chord_member_can_become_a_supported_color(self): def test_held_chord_member_can_become_a_supported_color(self):
from tests.solo_fixtures import choices source = tests.solo_fixtures.song(count=2, chords=('C', 'Dm'))
source=song(count=2,chords=('C','Dm'));a=analysis.analyze(source) a = generator.solo.analysis.analyze(source)
h=harmonic.Harmony(choices(('C','Dm'))) h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C', 'Dm')))
engine=decoder.Decoder(a,h,learned.Model.load(ROOT/'data/models/solo.json'),'acoustic',F(),F(8)) engine = generator.solo.decoder.Decoder(a, h, generator.solo.learned.Model.load(ROOT / 'data/models/solo.json'), 'acoustic', fractions.Fraction(), fractions.Fraction(8))
n=engine.note(F(3),F(6),64) # E: C's third becomes D minor's ninth. n = engine.note(fractions.Fraction(3), fractions.Fraction(6), 64) # E: C's third becomes D minor's ninth.
support = engine.permission(n) support = engine.permission(n)
self.assertIsNotNone(support) self.assertIsNotNone(support)
self.assertEqual(support[0].rule, 'minor_ninth') self.assertEqual(support[0].rule, 'minor_ninth')
+10 -10
View File
@@ -41,7 +41,7 @@ BOOST_AUTO_TEST_CASE(menu_actions_share_navigation_and_preserve_settings_focus)
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU); BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
BOOST_TEST(state.selected_settings_index == 1U); BOOST_TEST(state.selected_settings_index == 1U);
state = vamp_zero::apply_menu_action(state, action(state, 1U)); state = vamp_zero::apply_menu_action(state, action(state, 1U));
BOOST_TEST(state.audio.playback_enabled); BOOST_TEST(!state.audio.backing_enabled);
state = vamp_zero::apply_menu_action(state, action(state, 2U)); state = vamp_zero::apply_menu_action(state, action(state, 2U));
BOOST_TEST(state.audio.metronome_enabled); BOOST_TEST(state.audio.metronome_enabled);
state = vamp_zero::apply_menu_action(state, action(state, 3U)); state = vamp_zero::apply_menu_action(state, action(state, 3U));
@@ -53,7 +53,7 @@ BOOST_AUTO_TEST_CASE(menu_actions_share_navigation_and_preserve_settings_focus)
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU); BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
BOOST_TEST(state.selected_settings_index == 3U); BOOST_TEST(state.selected_settings_index == 3U);
BOOST_TEST(!state.audio.tempo_editing); BOOST_TEST(!state.audio.tempo_editing);
BOOST_TEST(state.audio.playback_enabled); BOOST_TEST(!state.audio.backing_enabled);
BOOST_TEST(state.audio.metronome_enabled); BOOST_TEST(state.audio.metronome_enabled);
BOOST_REQUIRE(vamp_zero::validate_app_state(state)); BOOST_REQUIRE(vamp_zero::validate_app_state(state));
} }
@@ -68,7 +68,7 @@ BOOST_AUTO_TEST_CASE(menu_touch_activates_once_on_release_and_stepper_has_priori
BOOST_REQUIRE(tapped); BOOST_REQUIRE(tapped);
state = vamp_zero::apply_menu_action(state, *tapped); state = vamp_zero::apply_menu_action(state, *tapped);
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU); BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
BOOST_TEST(!state.audio.playback_enabled); BOOST_TEST(!state.audio.backing_enabled);
BOOST_CHECK(!touch.handle(point_event(luma::touch_phase::UP, point, now), menu_bounds, state)); BOOST_CHECK(!touch.handle(point_event(luma::touch_phase::UP, point, now), menu_bounds, state));
point = row_center(state, 3U, vamp_zero::menu_action_kind::TEMPO_UP); point = row_center(state, 3U, vamp_zero::menu_action_kind::TEMPO_UP);
touch.handle(point_event(luma::touch_phase::DOWN, point, now), menu_bounds, state); touch.handle(point_event(luma::touch_phase::DOWN, point, now), menu_bounds, state);
@@ -76,16 +76,16 @@ BOOST_AUTO_TEST_CASE(menu_touch_activates_once_on_release_and_stepper_has_priori
BOOST_REQUIRE(tapped); BOOST_REQUIRE(tapped);
BOOST_CHECK(tapped->kind == vamp_zero::menu_action_kind::TEMPO_UP); BOOST_CHECK(tapped->kind == vamp_zero::menu_action_kind::TEMPO_UP);
state = vamp_zero::apply_menu_action(state, *tapped); state = vamp_zero::apply_menu_action(state, *tapped);
BOOST_TEST(state.audio.metronome_bpm == 125U); BOOST_TEST(state.audio.tempo_bpm == 120U);
BOOST_TEST(!state.audio.tempo_editing); BOOST_TEST(!state.audio.tempo_editing);
for (auto i = 0U; i < 100U; ++i) { for (auto i = 0U; i < 100U; ++i) {
state = vamp_zero::apply_menu_action(state, action(state, 3U, vamp_zero::menu_action_kind::TEMPO_DOWN)); state = vamp_zero::apply_menu_action(state, action(state, 3U, vamp_zero::menu_action_kind::TEMPO_DOWN));
} }
BOOST_TEST(state.audio.metronome_bpm == state.audio.metronome_min_bpm); BOOST_TEST(state.audio.tempo_bpm == 60U);
for (auto i = 0U; i < 100U; ++i) { for (auto i = 0U; i < 100U; ++i) {
state = vamp_zero::apply_menu_action(state, action(state, 3U, vamp_zero::menu_action_kind::TEMPO_UP)); state = vamp_zero::apply_menu_action(state, action(state, 3U, vamp_zero::menu_action_kind::TEMPO_UP));
} }
BOOST_TEST(state.audio.metronome_bpm == state.audio.metronome_max_bpm); BOOST_TEST(state.audio.tempo_bpm == 120U);
} }
BOOST_AUTO_TEST_CASE(menu_touch_cancels_drag_hold_multitouch_and_stale_press) { BOOST_AUTO_TEST_CASE(menu_touch_cancels_drag_hold_multitouch_and_stale_press) {
@@ -121,14 +121,14 @@ BOOST_AUTO_TEST_CASE(control_rejects_delayed_actions_after_remote_navigation_or_
control.apply(tapped); control.apply(tapped);
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::SETTINGS_MENU); BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::SETTINGS_MENU);
control.apply(tapped); control.apply(tapped);
BOOST_TEST(!control.snapshot().audio.playback_enabled); BOOST_TEST(!control.snapshot().audio.backing_enabled);
auto settings = control.snapshot(); auto settings = control.snapshot();
tapped = action(settings, 1U); tapped = action(settings, 1U);
control.apply(vamp_protocol::page_command::NEXT); control.apply(vamp_protocol::page_command::NEXT);
auto before = control.snapshot(); auto before = control.snapshot();
control.apply(tapped); control.apply(tapped);
BOOST_TEST(control.snapshot().input_revision == before.input_revision); BOOST_TEST(control.snapshot().input_revision == before.input_revision);
BOOST_TEST(!control.snapshot().audio.playback_enabled); BOOST_TEST(!control.snapshot().audio.backing_enabled);
tapped = action(before, 1U); tapped = action(before, 1U);
tapped.catalog = std::make_shared<vamp_zero::catalog const>(); tapped.catalog = std::make_shared<vamp_zero::catalog const>();
control.apply(tapped); control.apply(tapped);
@@ -165,9 +165,9 @@ BOOST_AUTO_TEST_CASE(menu_scroll_reaches_every_song_and_remote_shows_menu_titles
BOOST_AUTO_TEST_CASE(pico_settings_parser_rejects_invalid_values_without_mutating_state) { BOOST_AUTO_TEST_CASE(pico_settings_parser_rejects_invalid_values_without_mutating_state) {
auto state = vamp_pico::remote_display_state{}; auto state = vamp_pico::remote_display_state{};
BOOST_REQUIRE(vamp_pico::parse_remote_display_body("mode=settings_menu\nfocus=Playback\nplayback=1\nbpm=135\n", state)); BOOST_REQUIRE(vamp_pico::parse_remote_display_body("mode=settings_menu\nfocus=Backing\nbacking=1\nbpm=135\n", state));
auto before = state; auto before = state;
for (auto text : {"bpm=0\n", "bpm=401\n", "bpm=-1\n", "editing=yes\n", "playback=2\n", "metronome=true\n", "focus=abcdefghijklmnopqrstuvwxyz0123456789\n"}) { for (auto text : {"bpm=0\n", "bpm=401\n", "bpm=-1\n", "editing=yes\n", "backing=2\n", "metronome=true\n", "focus=abcdefghijklmnopqrstuvwxyz0123456789\n"}) {
BOOST_CHECK(!vamp_pico::parse_remote_display_body(text, state)); BOOST_CHECK(!vamp_pico::parse_remote_display_body(text, state));
BOOST_CHECK(state == before); BOOST_CHECK(state == before);
} }
+1 -1
View File
@@ -84,7 +84,7 @@ class MusicModelTests(unittest.TestCase):
result = generator.arrange.generate.arrange_document(source, part) result = generator.arrange.generate.arrange_document(source, part)
generator.model.validate_document(result) generator.model.validate_document(result)
measures = result['staves'][0]['measures'] measures = result['staves'][0]['measures']
self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody', 'Ending']) self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody'])
self.assertFalse(measures[-1]['harmonies']) self.assertFalse(measures[-1]['harmonies'])
self.assertTrue(any(h for m in measures[:-1] for h in m['harmonies'])) self.assertTrue(any(h for m in measures[:-1] for h in m['harmonies']))
self.assertFalse(any('chord_optional' in h for m in measures for h in m['harmonies'])) self.assertFalse(any('chord_optional' in h for m in measures for h in m['harmonies']))
+5 -5
View File
@@ -16,7 +16,7 @@ import generator.model
import generator.musicxml import generator.musicxml
import generator.parts import generator.parts
import generator.pitch import generator.pitch
import generator.playback import tests.score_events
import tests.arrangement_fixtures import tests.arrangement_fixtures
import tests.solo_fixtures import tests.solo_fixtures
import tests.test_musicxml import tests.test_musicxml
@@ -51,7 +51,7 @@ class PartGenerationTests(unittest.TestCase):
if onset < fractions.Fraction(3, 2): if onset < fractions.Fraction(3, 2):
self.assertLessEqual(onset + generator.model.fraction(note['duration']), fractions.Fraction(3, 2)) self.assertLessEqual(onset + generator.model.fraction(note['duration']), fractions.Fraction(3, 2))
self.assertLessEqual({generator.model.pitch_number(p) % 12 for p in note['pitches']}, {0, 4, 7} if onset < fractions.Fraction(3, 2) else {0, 5, 9}) self.assertLessEqual({generator.model.pitch_number(p) % 12 for p in note['pitches']}, {0, 4, 7} if onset < fractions.Fraction(3, 2) else {0, 5, 9})
self.assertTrue(generator.playback.compile_timeline(result)['events']) self.assertTrue(tests.score_events.timeline(result)['events'])
def test_harmony_carry_and_explicit_silence(self): def test_harmony_carry_and_explicit_silence(self):
content = xml_harmony(offset=1) + xml_harmony(kind='none', offset=2) + xml_harmony('F', offset=3) + tests.test_musicxml.xml_note() content = xml_harmony(offset=1) + xml_harmony(kind='none', offset=2) + xml_harmony('F', offset=3) + tests.test_musicxml.xml_note()
source = tests.test_musicxml.xml_score(content).replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note() + '</measure><measure number="3">' + xml_harmony(kind='none') + tests.test_musicxml.xml_note() + '</measure><measure number="4">' + tests.test_musicxml.xml_note() + '</measure></part>') source = tests.test_musicxml.xml_score(content).replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note() + '</measure><measure number="3">' + xml_harmony(kind='none') + tests.test_musicxml.xml_note() + '</measure><measure number="4">' + tests.test_musicxml.xml_note() + '</measure></part>')
@@ -77,7 +77,7 @@ class PartGenerationTests(unittest.TestCase):
result = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(xml), 'acoustic') result = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(xml), 'acoustic')
self.assertEqual(len(result['staves'][0]['measures']), 12) self.assertEqual(len(result['staves'][0]['measures']), 12)
generator.model.validate_document(result) generator.model.validate_document(result)
self.assertTrue(generator.playback.compile_timeline(result)['events']) self.assertTrue(tests.score_events.timeline(result)['events'])
self.assertTrue(any(n['kind'] == 'rest' for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m))) self.assertTrue(any(n['kind'] == 'rest' for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m)))
def test_melody_written_pitch_rhythm_rests_and_cross_bar_ties_are_preserved(self): def test_melody_written_pitch_rhythm_rests_and_cross_bar_ties_are_preserved(self):
content = '' content = ''
@@ -115,7 +115,7 @@ class PartGenerationTests(unittest.TestCase):
self.assertEqual(notes[0]['pitches'][0]['tie_to'], notes[1]['pitches'][0]['id']) self.assertEqual(notes[0]['pitches'][0]['tie_to'], notes[1]['pitches'][0]['id'])
self.assertEqual(notes[1]['pitches'][0]['tie_to'], notes[2]['pitches'][0]['id']) self.assertEqual(notes[1]['pitches'][0]['tie_to'], notes[2]['pitches'][0]['id'])
if builder is tests.arrangement_fixtures.section_document: if builder is tests.arrangement_fixtures.section_document:
events = generator.playback.compile_timeline(result)['events'] events = tests.score_events.timeline(result)['events']
self.assertEqual([(generator.model.fraction(e['onset']), generator.model.fraction(e['duration']), generator.model.fraction(e['pitch'])) for e in events], [(0, 6, 61 if part == 'electric' else 49), (fractions.Fraction(13, 2), fractions.Fraction(3, 2), 63 if part == 'electric' else 51)]) self.assertEqual([(generator.model.fraction(e['onset']), generator.model.fraction(e['duration']), generator.model.fraction(e['pitch'])) for e in events], [(0, 6, 61 if part == 'electric' else 49), (fractions.Fraction(13, 2), fractions.Fraction(3, 2), 63 if part == 'electric' else 51)])
self.assertEqual(source, before) self.assertEqual(source, before)
def test_song_slug_collisions_fail_before_writes(self): def test_song_slug_collisions_fail_before_writes(self):
@@ -146,7 +146,7 @@ class PartGenerationTests(unittest.TestCase):
unittest.mock.patch('generator.engraving.pipeline.engrave_tree'), unittest.mock.patch('generator.engraving.pipeline.engrave_tree'),
unittest.mock.patch('generator.pico_artwork.generate_titles'), unittest.mock.patch('generator.pico_artwork.generate_titles'),
unittest.mock.patch('generator.catalog.build_catalog'), unittest.mock.patch('generator.catalog.build_catalog'),
unittest.mock.patch('generator.playback.compile_tree'), unittest.mock.patch('generator.performance.copy_audio', return_value=[]),
): ):
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
# Role catalog loading is replaced by this infrastructure fixture. The # Role catalog loading is replaced by this infrastructure fixture. The
+193
View File
@@ -0,0 +1,193 @@
#include <boost/test/unit_test.hpp>
#include <boost/json.hpp>
#include <array>
#include <fstream>
#include <iterator>
#include <thread>
#include <vamp_zero/audio.hpp>
#include <vamp_zero/control.hpp>
#include <vamp_zero/notation.hpp>
#include <vamp_zero/page_control.hpp>
#include <vamp_zero/performance.hpp>
namespace {
auto read(std::string const &name) -> std::string {
auto input = std::ifstream{std::string{VAMP_TEST_PERFORMANCE_ASSETS_DIR} + "/" + name};
return {std::istreambuf_iterator<char>{input}, {}};
}
auto score() -> vamp_zero::score_document {
auto parsed = vamp_zero::parse_notation_document(read("score.json"), "score.json");
BOOST_REQUIRE_MESSAGE(parsed, (parsed ? "" : parsed.error().describe()));
return *parsed;
}
auto state(bool metronome = false, bool backing = false) -> vamp_zero::app_state {
auto catalog = std::make_shared<vamp_zero::catalog>();
auto audio = vamp_zero::parse_audio_assets(read("manifest.audio"));
BOOST_REQUIRE(audio);
catalog->audio_assets = *audio;
catalog->parts.push_back({.id = "score", .title = "Fixture", .part = "Vocal", .total_pages = 2, .song_id = "fixture"});
catalog->groups.push_back({.title = "Fixture", .parts = {0}});
auto result = vamp_zero::app_state{.catalog = catalog, .mode = vamp_zero::app_mode::PERFORMANCE_SCORE};
result.selected_song_index = 1;
result.selected_part_index = 1;
result.active_part_id = "score";
result.score_session = 1;
result.audio.metronome_enabled = metronome;
result.audio.backing_enabled = backing;
return result;
}
}
BOOST_AUTO_TEST_CASE(recordings_and_clicks_share_clock_and_tail_is_unclicked) {
for (auto metronome : {false, true}) {
for (auto backing : {false, true}) {
auto app = state(metronome, backing);
auto document = score();
auto prepared = vamp_zero::prepare_performance(document, app, VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F);
if (!metronome && !backing) {
BOOST_CHECK(!prepared);
continue;
}
BOOST_REQUIRE_MESSAGE(prepared, (prepared ? "" : prepared.error().describe()));
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .master_volume = 1.F, .no_device = true});
BOOST_REQUIRE(engine);
auto session = engine->play_transport(std::move(*prepared));
BOOST_REQUIRE(session);
auto frames = backing ? 576000U : 288000U;
auto buffer = std::array<float, 960>{};
for (unsigned cursor = 0; cursor < frames; cursor += 480) {
BOOST_REQUIRE(engine->render(buffer.data(), 480));
BOOST_TEST(session->frames() == cursor + 480);
if (cursor % 24000 == 0) {
auto expected = backing ? 0.03125F : 0.F;
if (metronome && cursor < 288000 && (!backing || cursor >= 96000)) {
expected += cursor % 96000 == 0 ? .25F : .125F;
}
BOOST_TEST(buffer.front() == expected, boost::test_tools::tolerance(0.0001F));
}
// Let the background stream worker refill between accelerated renders.
if (backing && cursor % 48000 == 0) {
std::this_thread::sleep_for(std::chrono::milliseconds{2});
}
}
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
BOOST_TEST(vamp_zero::performance_at(document, 95999, 120).phase == "Count-in");
BOOST_TEST(vamp_zero::performance_at(document, 96000, 120).page == 0U);
BOOST_TEST(vamp_zero::performance_at(document, 191999, 120).page == 0U);
BOOST_TEST(vamp_zero::performance_at(document, 192000, 120).page == 1U);
BOOST_TEST(vamp_zero::performance_at(document, 288000, 120).phase == "Sustain");
}
}
}
BOOST_AUTO_TEST_CASE(manual_navigation_requires_both_toggles_off) {
for (auto metro : {false, true}) {
for (auto backing : {false, true}) {
auto app = state(metro, backing);
auto next = vamp_zero::apply_app_command(app, vamp_protocol::page_command::NEXT);
BOOST_TEST(next.page_index == (metro || backing ? 0U : 1U));
next = vamp_zero::apply_app_command(next, vamp_protocol::page_command::SELECT);
BOOST_CHECK(next.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
}
}
}
BOOST_AUTO_TEST_CASE(tempo_steps_use_global_recording_intersection_and_nearest_slower_tie) {
auto app = state();
BOOST_TEST(vamp_zero::available_tempos(app).size() == 7U);
app.mode = vamp_zero::app_mode::SETTINGS_MENU;
app.selected_settings_index = 1;
app.audio.tempo_bpm = 80;
app = vamp_zero::apply_app_command(app, vamp_protocol::page_command::SELECT);
BOOST_TEST(app.audio.backing_enabled);
BOOST_TEST(app.audio.tempo_bpm == 120U);
auto catalog = std::make_shared<vamp_zero::catalog>(*app.catalog);
auto entry = catalog->audio_assets.back();
entry.bpm = 100;
entry.path = "backing/fixture_100.wav";
catalog->audio_assets.push_back(entry);
app.catalog = catalog;
app.audio.backing_enabled = false;
app.audio.tempo_bpm = 110;
app = vamp_zero::apply_app_command(app, vamp_protocol::page_command::SELECT);
BOOST_TEST(app.audio.tempo_bpm == 100U);
catalog->parts.push_back({.song_id = "other"});
catalog->groups.push_back({.parts = {1}});
BOOST_TEST(vamp_zero::available_tempos(app).empty());
}
BOOST_AUTO_TEST_CASE(stale_performance_updates_do_not_navigate_or_restart) {
auto app = state(true, false);
auto control = vamp_zero::control_state{{}, app};
control.update_performance(99, 1, "Playing", app.catalog);
BOOST_TEST(control.snapshot().page_index == 0U);
control.update_performance(1, 1, "Playing", app.catalog);
BOOST_TEST(control.snapshot().page_index == 1U);
control.finish_playback(99, {}, app.catalog);
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
control.finish_playback(1, "Recording failed", app.catalog);
control.update_performance(1, 0, "Playing", app.catalog);
BOOST_TEST(control.snapshot().page_index == 1U);
BOOST_TEST(control.snapshot().audio.phase == "Error");
control.apply(vamp_protocol::page_command::SELECT);
control.finish_playback(1, {}, app.catalog);
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
}
BOOST_AUTO_TEST_CASE(recording_transport_cancellation_does_not_advance_clock) {
auto prepared = vamp_zero::prepare_performance(score(), state(true), VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F);
BOOST_REQUIRE(prepared);
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .no_device = true});
BOOST_REQUIRE(engine);
auto session = engine->play_transport(std::move(*prepared));
BOOST_REQUIRE(session);
session->stop();
std::array<float, 960> buffer{};
BOOST_REQUIRE(engine->render(buffer.data(), 480));
BOOST_TEST(session->frames() == 0U);
BOOST_TEST(buffer.front() == 0.F);
}
BOOST_AUTO_TEST_CASE(page_timing_and_recording_manifests_reject_corruption) {
auto document = boost::json::parse(read("score.json"));
document.at("presentation").at("pages").as_array()[1].at("first_measure") = 1;
BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(document), "score.json"));
auto app = state(true);
auto catalog = std::make_shared<vamp_zero::catalog>(*app.catalog);
catalog->audio_assets.front().sha256 = std::string(64, '0');
app.catalog = catalog;
BOOST_CHECK(!vamp_zero::prepare_performance(score(), app, VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F));
}
BOOST_AUTO_TEST_CASE(preparation_waits_for_pages_and_cancellation_discards_stale_work) {
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .no_device = true});
BOOST_REQUIRE(engine);
auto controller = vamp_zero::audio_controller{{}, std::move(*engine)};
auto app = state(true);
auto document = std::make_shared<vamp_zero::score_document const>(score());
auto loaded = vamp_zero::score_load_state{.document = document};
BOOST_CHECK(!controller.tick(app, loaded, VAMP_TEST_PERFORMANCE_ASSETS_DIR));
BOOST_CHECK(!controller.position(*document, 120));
loaded.pages_ready = true;
BOOST_CHECK(!controller.tick(app, loaded, VAMP_TEST_PERFORMANCE_ASSETS_DIR));
auto exited = vamp_zero::apply_app_command(app, vamp_protocol::page_command::SELECT);
controller.sync(exited);
for (unsigned i = 0; i < 20; ++i) {
BOOST_CHECK(!controller.tick(exited, {}, VAMP_TEST_PERFORMANCE_ASSETS_DIR));
std::this_thread::sleep_for(std::chrono::milliseconds{1});
}
BOOST_CHECK(!controller.position(*document, 120));
++app.score_session;
for (unsigned i = 0; i < 200 && !controller.position(*document, 120); ++i) {
BOOST_CHECK(!controller.tick(app, loaded, VAMP_TEST_PERFORMANCE_ASSETS_DIR));
std::this_thread::sleep_for(std::chrono::milliseconds{1});
}
BOOST_REQUIRE(controller.position(*document, 120));
controller.sync(exited);
BOOST_CHECK(!controller.position(*document, 120));
}
+86
View File
@@ -0,0 +1,86 @@
import argparse
import copy
import hashlib
import json
import pathlib
import shutil
import tempfile
import unittest
import wave
import generator.performance
import generator.model
import tests.test_musicxml
def fixture(root):
root.mkdir(parents=True, exist_ok=True)
music = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
first = music['staves'][0]['measures'][0]
music['staves'][0]['measures'].append(copy.deepcopy(first))
music['metadata']['song_id'] = 'fixture'
pages = [dict(path=f'{i + 1}.png', sha256='0' * 64, first_measure=i + 1, measure_count=1,
start_beat=generator.model.rational(i * 4), end_beat=generator.model.rational((i + 1) * 4)) for i in range(2)]
bundle = dict(music=music, presentation=dict(width=976, height=484, pages=pages), performance=generator.performance.timing(music))
(root / 'score.json').write_text(json.dumps(bundle))
entries = []
# Constant PCM makes accents, recorded count-in and the silent click tail observable.
for name, frames, value, song_id, bpm in [('sounds/beat1.wav', 2400, 8192, '', 0), ('sounds/beat2.wav', 2400, 4096, '', 0), ('backing/fixture_120.wav', 576000, 1024, 'fixture', 120)]:
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), 'wb') as output:
output.setparams((2, 2, 48000, 0, 'NONE', ''))
output.writeframes(value.to_bytes(2, 'little', signed=True) * 2 * frames)
entries.append(generator.performance.describe_wav(path, root, song_id=song_id, bpm=bpm))
(root / 'manifest.audio').write_text(json.dumps(entries))
class PerformanceTests(unittest.TestCase):
def test_supplied_audio_is_copied_without_transformation(self):
source = pathlib.Path('package/assets')
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
entries = generator.performance.copy_audio(source, root, {'all_of_me', 'autumn_leaves'})
self.assertEqual(len(entries), 4)
for entry in entries:
self.assertEqual((source / entry['path']).read_bytes(), (root / entry['path']).read_bytes())
self.assertEqual(entry['sha256'], hashlib.sha256((root / entry['path']).read_bytes()).hexdigest())
def test_recording_names_and_truncation_are_rejected(self):
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
fixture(root)
path = root / 'backing/fixture_120.wav'
path.write_bytes(path.read_bytes()[:-2])
with self.assertRaisesRegex(ValueError, 'truncated'):
generator.performance.describe_wav(path, root)
path.rename(root / 'backing/fixture_115.wav')
with self.assertRaisesRegex(ValueError, 'unsupported'):
generator.performance.copy_audio(root, root / 'out', {'fixture'})
def test_short_backing_cannot_publish(self):
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
fixture(root)
(root / 'songs').mkdir()
bundle = json.loads((root / 'score.json').read_text())
(root / 'songs/fixture.json').write_text(json.dumps(bundle['music']))
entries = json.loads((root / 'manifest.audio').read_text())
generator.performance.validate_recording_lengths(root, entries)
entries[-1]['frames'] = 1
with self.assertRaisesRegex(ValueError, 'shorter'):
generator.performance.validate_recording_lengths(root, entries)
def test_page_coverage_and_measure_accents(self):
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
fixture(root)
bundle = json.loads((root / 'score.json').read_text())
generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages'])
self.assertEqual([c['downbeat'] for c in bundle['performance']['clicks']], [True, False, False, False] * 2)
self.assertEqual(bundle['performance']['duration'], generator.model.rational(8))
bundle['presentation']['pages'][1]['start_beat'] = generator.model.rational(3)
with self.assertRaisesRegex(ValueError, 'timing'):
generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages'])
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--fixture', type=pathlib.Path)
args = parser.parse_args()
if args.fixture:
fixture(args.fixture)
-133
View File
@@ -1,133 +0,0 @@
#include <boost/test/unit_test.hpp>
#include <boost/json.hpp>
#include <array>
#include <fstream>
#include <iterator>
#include <thread>
#include <vamp_zero/audio.hpp>
#include <vamp_zero/config.hpp>
#include <vamp_zero/playback.hpp>
#include <vamp_zero/control.hpp>
namespace {
auto playback_score() -> vamp_zero::score_document {
auto input = std::ifstream{std::string{VAMP_TEST_PLAYBACK_ASSETS_DIR} + "/score.json"};
auto score = vamp_zero::score_document{};
score.source_json = std::string{std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
return score;
}
auto performance_state() -> vamp_zero::app_state {
auto catalog = std::make_shared<vamp_zero::catalog>();
catalog->parts.push_back({.id = "part", .title = "Song", .part = "Vocal", .total_pages = 2U});
catalog->groups.push_back({.title = "Song", .parts = {0U}});
auto state = vamp_zero::app_state{};
state.catalog = std::move(catalog);
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
state.selected_song_index = 1U;
state.selected_part_index = 1U;
state.active_part_id = "part";
state.score_session = 42U;
state.audio.playback_enabled = true;
return state;
}
}
BOOST_AUTO_TEST_CASE(generated_audio_plays_at_menu_bpm) {
auto score = playback_score();
auto normal = vamp_zero::prepare_playback(score, VAMP_TEST_PLAYBACK_ASSETS_DIR, 120U);
auto slow = vamp_zero::prepare_playback(score, VAMP_TEST_PLAYBACK_ASSETS_DIR, 60U);
BOOST_REQUIRE_MESSAGE(normal, (normal ? "" : normal.error().describe()));
BOOST_REQUIRE(slow);
BOOST_TEST(normal->duration_frames == 96000U);
BOOST_TEST(slow->duration_frames == normal->duration_frames * 2U);
BOOST_TEST(normal->samples[0].frames == slow->samples[0].frames, boost::test_tools::per_element());
auto engine = sonus::engine::create({.sample_rate = 48000U, .channels = 1U, .no_device = true});
BOOST_REQUIRE(engine);
auto session = engine->play_sequence(std::move(*normal));
BOOST_REQUIRE(session);
auto output = std::array<std::float32_t, 480>{};
auto energy = std::float32_t{};
for (auto block = std::uint32_t{}; block < 200U; ++block) {
BOOST_REQUIRE(engine->render(output.data(), output.size()));
for (auto value : output) {
energy += value * value;
}
if (block == 150U) {
BOOST_CHECK(session->status() == sonus::playback_status::PLAYING);
}
}
BOOST_TEST(energy > 0.1F);
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
BOOST_REQUIRE(vamp_zero::validate_playback_assets(score, VAMP_TEST_PLAYBACK_ASSETS_DIR));
}
BOOST_AUTO_TEST_CASE(playback_completion_is_session_guarded) {
auto state = vamp_zero::control_state{{}, performance_state()};
state.finish_playback(41U);
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
state.finish_playback(42U, {}, std::make_shared<vamp_zero::catalog const>());
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
state.finish_playback(42U, "sample missing");
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
BOOST_TEST(state.snapshot().audio.playback_error == "sample missing");
state.finish_playback(42U);
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
BOOST_TEST(state.snapshot().selected_part_index == 1U);
BOOST_TEST(state.snapshot().score_session == 42U);
state.apply(vamp_protocol::page_command::SELECT);
BOOST_TEST(state.snapshot().score_session == 43U);
state.finish_playback(42U);
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
auto disabled = performance_state();
disabled.audio.playback_enabled = false;
auto quiet = vamp_zero::control_state{{}, disabled};
quiet.finish_playback(42U);
BOOST_CHECK(quiet.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
}
BOOST_AUTO_TEST_CASE(playback_worker_discards_canceled_preparation_and_reports_errors_once) {
auto engine = sonus::engine::create({.no_device = true});
BOOST_REQUIRE(engine);
auto audio = vamp_zero::audio_controller{{}, std::move(*engine)};
auto state = performance_state();
auto invalid = std::make_shared<vamp_zero::score_document>();
invalid->id = "part";
invalid->source_json = "{}";
auto loaded = vamp_zero::score_load_state{.document = invalid};
BOOST_CHECK(!audio.tick(state, loaded, VAMP_TEST_PLAYBACK_ASSETS_DIR));
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
for (auto attempt = std::uint32_t{}; attempt < 100U; ++attempt) {
BOOST_CHECK(!audio.tick(state, {}, VAMP_TEST_PLAYBACK_ASSETS_DIR));
std::this_thread::sleep_for(std::chrono::milliseconds{1});
}
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
++state.score_session;
auto reported = std::bool_t{};
for (auto attempt = std::uint32_t{}; attempt < 1000U && !reported; ++attempt) {
if (auto result = audio.tick(state, loaded, VAMP_TEST_PLAYBACK_ASSETS_DIR)) {
BOOST_CHECK(!*result);
reported = true;
}
std::this_thread::sleep_for(std::chrono::milliseconds{1});
}
BOOST_TEST(reported);
BOOST_CHECK(!audio.tick(state, loaded, VAMP_TEST_PLAYBACK_ASSETS_DIR));
}
BOOST_AUTO_TEST_CASE(playback_rejects_legacy_config_and_invalid_assets) {
auto legacy = vamp_zero::parse_config(boost::json::object{{"audio", {{"track_enabled", true}}}});
BOOST_REQUIRE(!legacy);
BOOST_TEST(std::string{legacy.error().message()}.find("use audio.playback_enabled") != std::string::npos);
BOOST_CHECK(!vamp_zero::parse_config(boost::json::object{{"audio", {{"track_enabled", true}, {"playback_enabled", false}}}}));
auto score = playback_score();
BOOST_CHECK(!vamp_zero::prepare_playback(score, "/missing-vamp-assets", 120U));
BOOST_CHECK(!vamp_zero::prepare_playback(score, VAMP_TEST_PLAYBACK_ASSETS_DIR, 0U));
score.source_json = "{}";
BOOST_CHECK(!vamp_zero::prepare_playback(score, VAMP_TEST_PLAYBACK_ASSETS_DIR, 120U));
}
-112
View File
@@ -1,112 +0,0 @@
import fractions
import json
import pathlib
import tempfile
import unittest
import wave
import generator.model
import generator.playback
import generator.score
import tests.test_musicxml
class PlaybackTests(unittest.TestCase):
def test_grace_steals_accumulate_at_both_ends_of_a_donor(self):
grace = '<note><grace {}/><pitch><step>{}</step><octave>4</octave></pitch><type>eighth</type></note>'
content = grace.format('steal-time-following="25"', 'D') + tests.test_musicxml.xml_note(duration=2, kind='half') + grace.format('steal-time-previous="25"', 'E') + tests.test_musicxml.xml_note('F', 2, 'half')
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)))
self.assertEqual([(generator.model.fraction(e['pitch']), generator.model.fraction(e['onset']), generator.model.fraction(e['duration'])) for e in result['events']], [(62, 0, fractions.Fraction(1, 2)), (60, fractions.Fraction(1, 2), 1), (64, fractions.Fraction(3, 2), fractions.Fraction(1, 2)), (65, 2, 2)])
for percent in (50, 60):
with self.subTest(percent=percent), self.assertRaisesRegex(generator.playback.PlaybackUnavailable, 'grace timing consumes'):
generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content.replace('"25"', f'"{percent}"'))))
chord = content.replace(tests.test_musicxml.xml_note(duration=2, kind='half'), tests.test_musicxml.xml_note(duration=2, kind='half') + tests.test_musicxml.xml_note('G', 1, 'quarter', prefix='<chord/>'))
with self.assertRaisesRegex(generator.playback.PlaybackUnavailable, 'chord tone'):
generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord)))
def test_grace_previous_and_make_time_preserve_their_timing(self):
for mode, expected, total in [('steal-time-previous="25"', [(0, fractions.Fraction(3, 2)), (fractions.Fraction(3, 2), fractions.Fraction(1, 2)), (2, 2)], 4), ('make-time="1"', [(0, 2), (2, 1), (3, 2)], 5)]:
grace = f'<note><grace {mode}/><pitch><step>D</step><octave>4</octave></pitch><type>eighth</type></note>'
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + grace + tests.test_musicxml.xml_note('E', 2, 'half'))))
self.assertEqual([(generator.model.fraction(e['onset']), generator.model.fraction(e['duration'])) for e in result['events']], expected)
self.assertEqual(generator.model.fraction(result['duration']), total)
def test_chords_and_trailing_rest(self):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + tests.test_musicxml.xml_note('E', 1, 'quarter', prefix='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>'))
result = generator.playback.compile_timeline(document)
self.assertEqual(generator.model.fraction(result['duration']), 4)
self.assertEqual([generator.model.fraction(e['duration']) for e in result['events']], [2, 1])
def test_tie_is_one_attack(self):
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note(duration=2, kind='half', extra='<tie type="stop"/>'))))
self.assertEqual(len(result['events']), 1)
self.assertEqual(generator.model.fraction(result['events'][0]['duration']), 4)
def test_octave_and_microtones(self):
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter='0.5'), '<transpose><diatonic>0</diatonic><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>')))
self.assertEqual(generator.model.fraction(result['events'][0]['pitch']), fractions.Fraction(97, 2))
def test_grace_default_and_cues(self):
grace = '<note><grace/><pitch><step>D</step><octave>4</octave></pitch><type>eighth</type></note>'
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(grace + tests.test_musicxml.xml_note())))
self.assertEqual([generator.model.fraction(e['duration']) for e in result['events']], [1, 3])
self.assertEqual([generator.model.fraction(e['onset']) for e in result['events']], [0, 1])
self.assertEqual(generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(prefix='<cue/>'))))['events'], [])
def test_tremolo(self):
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<notations><ornaments><tremolo>3</tremolo></ornaments></notations>'))))
self.assertEqual(len(result['events']), 32)
extra = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification><notations><ornaments><tremolo>1</tremolo></ornaments></notations>'
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='eighth', extra=extra), divisions=3, implicit=True)))
self.assertEqual([generator.model.fraction(e['duration']) for e in result['events']], [fractions.Fraction(1, 6)] * 2)
def test_hidden_pitches_sound_and_unpitched_is_unavailable(self):
hidden = tests.test_musicxml.xml_note().replace('<note>', '<note print-object="no">')
self.assertEqual(len(generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(hidden)))['events']), 1)
unpitched = '<note><unpitched><display-step>C</display-step><display-octave>4</display-octave></unpitched><duration>4</duration><type>whole</type></note>'
with self.assertRaises(generator.playback.PlaybackUnavailable):
generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(unpitched)))
def test_sounding_pitch_depends_on_instrument_transposition_not_part_or_path(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
for part in ('acoustic', 'electric', 'vocal'):
source['staves'][0]['arrangement_part_type'] = part
source['staves'][0]['measures'][0]['transpose']['octave_change'] = -1 if part != 'vocal' else 0
generator.score.write_json(root / f'{part}.json', source)
generator.playback.compile_tree(root)
for part, pitch in [('acoustic', 48), ('electric', 48), ('vocal', 60)]:
playback = json.loads((root / f'{part}.json').read_text())['playback']
self.assertEqual(playback['samples'][0]['id'], generator.playback.generate_tone(root, fractions.Fraction(pitch))['id'])
def test_audio_assets_validate_and_reject_corruption(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
generate_fixture(root)
manifest = json.loads((root / 'score.json').read_text())['playback']
generator.playback.validate_assets(manifest, root)
sample = manifest['samples'][0]
path = root / sample['path']
path.write_bytes(path.read_bytes()[:-1])
with self.assertRaises(ValueError):
generator.playback.validate_assets(manifest, root)
generate_fixture(root)
manifest['release']['sha256'] = generator.playback.write_pcm(root / 'audio/release.wav', [0.5] * 2400)
with self.assertRaisesRegex(ValueError, 'envelope'):
generator.playback.validate_assets(manifest, root)
sample['path'] = '../outside.wav'
with self.assertRaises(ValueError):
generator.playback.validate_assets(manifest, root)
def test_generated_tone_is_deterministic_and_loopable(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
a = generator.playback.generate_tone(root, fractions.Fraction(60))
b = generator.playback.generate_tone(root, fractions.Fraction(60))
self.assertEqual(a, b)
with wave.open(str(root / a['path'])) as audio:
self.assertEqual(audio.getframerate(), 48000)
self.assertEqual(audio.getnframes(), a['loop_end'])
self.assertEqual(audio.getnchannels(), 1)
self.assertGreater(a['loop_end'], a['loop_start'])
def generate_fixture(directory):
root = pathlib.Path(directory)
generator.score.write_json(root / 'score.json', tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + tests.test_musicxml.xml_note('E', 2, 'half', prefix='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>')))
generator.playback.compile_tree(root)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--fixture', required=True)
generate_fixture(parser.parse_args().fixture)
+32 -36
View File
@@ -1,73 +1,69 @@
import dataclasses, unittest import dataclasses, unittest
from fractions import Fraction as F import fractions
from generator.solo import types, harmonic, groups import generator.solo.types
from tests.solo_fixtures import note,choices import generator.solo.harmonic
import generator.solo.groups
import tests.solo_fixtures
import tests.test_independent_generation
import tempfile
import pathlib
import generator.input_audit
def melody(rows): return tuple(tests.solo_fixtures.note(a, b, p, event_id=str(i)) for i, (a, b, p) in enumerate(rows))
def melody(rows):return tuple(note(a,b,p,event_id=str(i)) for i,(a,b,p) in enumerate(rows))
class RhythmGroups(unittest.TestCase): class RhythmGroups(unittest.TestCase):
def test_complete_triplet_can_be_displaced_without_offsetting_binary_tail(self): def test_complete_triplet_can_be_displaced_without_offsetting_binary_tail(self):
pattern = ((F(1, 2), F(5, 6)), (F(5, 6), F(7, 6)), (F(7, 6), F(3, 2)), (F(3, 2), F(5, 2))) pattern = ((fractions.Fraction(1, 2), fractions.Fraction(5, 6)), (fractions.Fraction(5, 6), fractions.Fraction(7, 6)), (fractions.Fraction(7, 6), fractions.Fraction(3, 2)), (fractions.Fraction(3, 2), fractions.Fraction(5, 2)))
result = groups.describe(pattern) result = generator.solo.groups.describe(pattern)
self.assertEqual(result[0].family, 'triplet') self.assertEqual(result[0].family, 'triplet')
self.assertEqual(result[-1].family, 'binary') self.assertEqual(result[-1].family, 'binary')
self.assertEqual(result[0].displacement, F(1, 2)) self.assertEqual(result[0].displacement, fractions.Fraction(1, 2))
def test_partial_triplet_does_not_drift_into_binary(self): def test_partial_triplet_does_not_drift_into_binary(self):
self.assertFalse(groups.valid(((F(), F(1, 3)), (F(1, 3), F(5, 6))))) self.assertFalse(generator.solo.groups.valid(((fractions.Fraction(), fractions.Fraction(1, 3)), (fractions.Fraction(1, 3), fractions.Fraction(5, 6)))))
self.assertFalse(groups.valid(((F(1, 3), F(4, 3)),))) self.assertFalse(generator.solo.groups.valid(((fractions.Fraction(1, 3), fractions.Fraction(4, 3)),)))
self.assertEqual(groups.describe(((F(1, 3), F(4, 3)),), imported=True)[0].family, 'source') self.assertEqual(generator.solo.groups.describe(((fractions.Fraction(1, 3), fractions.Fraction(4, 3)),), imported=True)[0].family, 'source')
def test_relative_rhythm_identity_does_not_count_absolute_onsets(self): def test_relative_rhythm_identity_does_not_count_absolute_onsets(self):
original = melody(((0, 1, 'c'), (1, 2, 'e'), (2, 3, 'd'))) original = melody(((0, 1, 'c'), (1, 2, 'e'), (2, 3, 'd')))
self.assertEqual(len(set(groups.signature(original))), 1) self.assertEqual(len(set(generator.solo.groups.signature(original))), 1)
moved = tuple((dataclasses.replace(n, start=n.start + 13, end=n.end + 13) for n in original)) moved = tuple((dataclasses.replace(n, start=n.start + 13, end=n.end + 13) for n in original))
self.assertEqual(groups.signature(original), groups.signature(moved)) self.assertEqual(generator.solo.groups.signature(original), generator.solo.groups.signature(moved))
def test_broadening_never_cuts_inside_tuplet(self): def test_broadening_never_cuts_inside_tuplet(self):
pattern = ((F(), F(1, 3)), (F(1, 3), F(2, 3)), (F(2, 3), F(1))) pattern = ((fractions.Fraction(), fractions.Fraction(1, 3)), (fractions.Fraction(1, 3), fractions.Fraction(2, 3)), (fractions.Fraction(2, 3), fractions.Fraction(1)))
self.assertEqual(groups.broaden(pattern, F(1, 2), F(4)), ()) self.assertEqual(generator.solo.groups.broaden(pattern, fractions.Fraction(1, 2), fractions.Fraction(4)), ())
class PendingTreatments(unittest.TestCase): class PendingTreatments(unittest.TestCase):
def test_complete_route_can_cross_a_search_frontier(self): def test_complete_route_can_cross_a_search_frontier(self):
from tests.test_independent_generation import TypedExpressionContracts fixture = tests.test_independent_generation.TypedExpressionContracts()
fixture = TypedExpressionContracts()
fixture.setUp() fixture.setUp()
notes, contract = fixture.make() notes, contract = fixture.make()
partial = fixture.h.evaluate(notes[:2], until=F(2), complete=False, expressions=(contract,)) partial = fixture.h.evaluate(notes[:2], until=fractions.Fraction(2), complete=False, expressions=(contract,))
self.assertTrue(partial.valid, partial.error) self.assertTrue(partial.valid, partial.error)
self.assertEqual(partial.pending, (contract,)) self.assertEqual(partial.pending, (contract,))
self.assertFalse(fixture.h.evaluate(notes[:2], until=F(3), expressions=(contract,)).valid) self.assertFalse(fixture.h.evaluate(notes[:2], until=fractions.Fraction(3), expressions=(contract,)).valid)
complete = fixture.h.evaluate(notes, until=F(4), complete=True, expressions=(contract,)) complete = fixture.h.evaluate(notes, until=fractions.Fraction(4), complete=True, expressions=(contract,))
self.assertTrue(complete.valid) self.assertTrue(complete.valid)
self.assertFalse(complete.pending) self.assertFalse(complete.pending)
class InputFirewall(unittest.TestCase): class InputFirewall(unittest.TestCase):
def test_read_write_handles_cannot_bypass_boundary(self): def test_read_write_handles_cannot_bypass_boundary(self):
import tempfile
from pathlib import Path
from generator.input_audit import composition
with tempfile.TemporaryDirectory() as folder: with tempfile.TemporaryDirectory() as folder:
forbidden = Path(folder) / 'target.xml' forbidden = pathlib.Path(folder) / 'target.xml'
forbidden.write_text('not musical input') forbidden.write_text('not musical input')
for mode in ('r', 'r+'): for mode in ('r', 'r+'):
with self.assertRaisesRegex(ValueError, 'Forbidden composition input'): with self.assertRaisesRegex(ValueError, 'Forbidden composition input'):
with composition(Path('data'), 'electric_solo'): with generator.input_audit.composition(pathlib.Path('data'), 'electric_solo'):
with forbidden.open(mode) as stream: with forbidden.open(mode) as stream:
stream.read() stream.read()
class PreparedSuspensions(unittest.TestCase): class PreparedSuspensions(unittest.TestCase):
def test_preparation_and_delayed_resolution_are_required(self): def test_preparation_and_delayed_resolution_are_required(self):
h = harmonic.Harmony(choices(('Gm7', 'C7'))) h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('Gm7', 'C7')))
held = note(F(5, 2), F(9, 2), 'f', event_id='held') held = tests.solo_fixtures.note(fractions.Fraction(5, 2), fractions.Fraction(9, 2), 'f', event_id='held')
target = note(F(9, 2), 6, 'e', event_id='resolution') target = tests.solo_fixtures.note(fractions.Fraction(9, 2), 6, 'e', event_id='resolution')
contract = types.ExpressionContract('prepared', 'suspension', 'prepared_step_down', (held,), target, 'C7', boundary=F(4)) contract = generator.solo.types.ExpressionContract('prepared', 'suspension', 'prepared_step_down', (held,), target, 'C7', boundary=fractions.Fraction(4))
self.assertTrue(h.evaluate((held, target), expressions=(contract,)).valid) self.assertTrue(h.evaluate((held, target), expressions=(contract,)).valid)
self.assertTrue(h.evaluate((held, target)).valid) self.assertTrue(h.evaluate((held, target)).valid)
self.assertFalse(h.evaluate((held, target), infer=False).valid) self.assertFalse(h.evaluate((held, target), infer=False).valid)
changed = dataclasses.replace(held, start=F(15, 4)) changed = dataclasses.replace(held, start=fractions.Fraction(15, 4))
self.assertFalse(h.evaluate((changed, target), expressions=(dataclasses.replace(contract, events=(changed,)),)).valid) self.assertFalse(h.evaluate((changed, target), expressions=(dataclasses.replace(contract, events=(changed,)),)).valid)
self.assertFalse(h.evaluate((held,), expressions=(contract,)).valid) self.assertFalse(h.evaluate((held,), expressions=(contract,)).valid)
+6 -2
View File
@@ -26,8 +26,9 @@ import generator.model
import generator.musicxml import generator.musicxml
import generator.musicxml_writer import generator.musicxml_writer
import generator.pico_artwork import generator.pico_artwork
import generator.performance
import generator.pitch import generator.pitch
import generator.playback import tests.score_events
import tests.arrangement_fixtures import tests.arrangement_fixtures
import generator.score import generator.score
import generator.theme import generator.theme
@@ -71,7 +72,7 @@ class ScoreBundleTests(unittest.TestCase):
for start, end in ((0, 4), (4, 8)): for start, end in ((0, 4), (4, 8)):
system = generator.engraving.layout.make_system(index, start, end, fonts, generator.engraving.types.Style()) system = generator.engraving.layout.make_system(index, start, end, fonts, generator.engraving.types.Style())
self.assertTrue(any(p.role == 'tie' and len(p.sources) == 2 for p in system.primitives)) self.assertTrue(any(p.role == 'tie' and len(p.sources) == 2 for p in system.primitives))
self.assertEqual([generator.model.fraction(e['duration']) for e in generator.playback.compile_timeline(source)['events']], [8]) self.assertEqual([generator.model.fraction(e['duration']) for e in tests.score_events.timeline(source)['events']], [8])
restored = self.round_trip(source) restored = self.round_trip(source)
self.assertEqual(tests.test_musicxml.notes(restored)[0]['pitches'], tests.test_musicxml.notes(source)[0]['pitches']) self.assertEqual(tests.test_musicxml.notes(restored)[0]['pitches'], tests.test_musicxml.notes(source)[0]['pitches'])
self.assertEqual(restored['staves'][0]['measures'][1]['barlines'], {'right': 'light-heavy'}) self.assertEqual(restored['staves'][0]['measures'][1]['barlines'], {'right': 'light-heavy'})
@@ -212,6 +213,9 @@ class ScoreBundleTests(unittest.TestCase):
page.write_bytes(data) page.write_bytes(data)
document = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml')) document = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml'))
bundle = {'music': document, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}} bundle = {'music': document, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}}
times = generator.performance.boundaries(document)
bundle['presentation']['pages'][0].update(first_measure=1, measure_count=len(times) - 1, start_beat=generator.model.rational(0), end_beat=generator.model.rational(times[-1]))
bundle['performance'] = generator.performance.timing(document)
path = assets / 'score.json' path = assets / 'score.json'
path.write_text(json.dumps(bundle)) path.write_text(json.dumps(bundle))
self.assertEqual(generator.score.load_json(path), document) self.assertEqual(generator.score.load_json(path), document)

Some files were not shown because too many files have changed in this diff Show More