Compare commits

...

119 Commits

Author SHA1 Message Date
khodowany 150394d598 Toggle Solos
ci / checks (push) Failing after 24m18s
2026-10-08 22:11:34 -05:00
khodowany 1bacfd0f33 Fix
ci / checks (push) Successful in 53m31s
2026-10-08 20:25:59 -05:00
khodowany 3f63b255ac Fix Playability
ci / checks (push) Failing after 23m26s
2026-10-08 19:04:06 -05:00
khodowany bc7baea7c4 Cleanup
ci / checks (push) Failing after 23m22s
2026-10-08 02:13:45 -05:00
khodowany 300097a37e PDF Render
ci / checks (push) Successful in 52m51s
2026-10-08 00:07:07 -05:00
khodowany 841a3f974e Construction 2026-10-07 23:45:17 -05:00
khodowany 3a01b4b6bd Rhythm Comping Pairs
ci / checks (push) Successful in 56m36s
2026-10-07 20:57:23 -05:00
khodowany a0e13434e4 Rhythm Patterns
ci / checks (push) Successful in 51m14s
2026-10-07 00:22:19 -05:00
khodowany 23b606e47f Rhythm Patterns 2026-10-06 23:55:58 -05:00
khodowany d2f493c17b Playback Mode Pages
ci / checks (push) Successful in 50m29s
2026-10-06 23:11:25 -05:00
khodowany 125aaa1637 Comping Gen 2026-10-06 22:13:50 -05:00
khodowany 2f21ec1f16 Acoustic Comping 2026-10-06 21:50:59 -05:00
khodowany c7f6a30a13 Comping 2026-10-06 21:28:42 -05:00
khodowany e15dc31568 Remove XML Solo Gen
ci / checks (push) Successful in 49m36s
2026-10-06 04:27:29 -05:00
khodowany bb3bba6bbb Fix
ci / checks (push) Successful in 50m4s
2026-10-06 04:05:50 -05:00
khodowany 8423f3d788 Chord Families 2026-10-06 03:18:02 -05:00
khodowany e71385dad1 Chord Families 2026-10-06 02:57:37 -05:00
khodowany df83cae38c Accompaniment
ci / checks (push) Successful in 49m53s
2026-10-06 01:59:55 -05:00
khodowany 04bb44b9d0 Update
ci / checks (push) Failing after 4m54s
2026-10-06 00:05:20 -05:00
khodowany 82349cf897 Playback
ci / checks (push) Failing after 23m37s
2026-10-05 23:41:11 -05:00
khodowany ffdbe48258 Validation
ci / checks (push) Failing after 43m34s
2026-10-05 20:33:14 -05:00
khodowany e7180e557b Training Model 2026-10-05 19:44:33 -05:00
khodowany db7966a430 Simplification 2026-10-05 17:24:16 -05:00
khodowany bcaf2f1403 Simplification 2026-10-05 16:40:22 -05:00
khodowany 654299decd Cleanup 2026-10-05 08:51:03 -05:00
khodowany ef62af892b Cleanup 2026-10-04 22:29:06 -05:00
khodowany 0e68f5364b Cleanup 2026-10-04 18:48:02 -05:00
khodowany 68df7292ee Cleanup 2026-10-04 17:14:13 -05:00
khodowany f234dd6d51 Save 2026-10-04 15:51:18 -05:00
khodowany 405066eb43 Revamp 2026-10-04 12:27:43 -05:00
khodowany 8fdd736e13 Sepearate Generation 2026-10-04 09:05:25 -05:00
khodowany e8ac7b5edd Pin 2026-10-04 04:29:10 -05:00
khodowany 04c6ea2e00 Soloing 2026-10-03 12:44:53 -05:00
khodowany 59afd78205 Soloing 2026-10-03 10:12:12 -05:00
khodowany 5fff5a165e Solo
ci / checks (push) Failing after 23m43s
2026-10-02 06:33:01 -05:00
khodowany a0bd19b3ba Solo 2026-10-02 00:35:31 -05:00
khodowany f2f371b951 Comp 2026-10-01 20:20:24 -05:00
khodowany f5861fcdf6 Comp 2026-10-01 16:41:18 -05:00
khodowany 28fc67fba1 Soloing & Measures
ci / checks (push) Successful in 1h16m41s
2026-10-01 14:05:51 -05:00
khodowany a7b64c5361 Fret Limit 2026-10-01 12:06:02 -05:00
khodowany 5414caf30a Soloing
ci / checks (push) Successful in 1h53m40s
2026-10-01 11:23:47 -05:00
khodowany 75655786e9 Soloing
ci / checks (push) Successful in 1h9m55s
2026-10-01 08:35:12 -05:00
khodowany eae432aa83 Soloing
ci / checks (push) Successful in 50m39s
2026-10-01 06:31:14 -05:00
khodowany ce695a626a Span
ci / checks (push) Successful in 48m23s
2026-10-01 00:15:35 -05:00
khodowany 7b60182a3d Generation
ci / checks (push) Successful in 48m7s
2026-09-30 20:30:29 -05:00
khodowany 09656a9d60 Package
ci / checks (push) Successful in 48m34s
2026-09-30 19:46:37 -05:00
khodowany 2a1aa2c67a UI 2026-09-30 18:23:25 -05:00
khodowany 896de203b3 Sequences
ci / checks (push) Successful in 48m4s
2026-09-30 16:16:49 -05:00
khodowany 8d6dc70110 Sequences 2026-09-30 16:01:26 -05:00
khodowany 823b402a9e Sequences 2026-09-30 15:48:43 -05:00
khodowany fb92ed78ee Voice Leading
ci / checks (push) Successful in 48m12s
2026-09-30 15:13:00 -05:00
khodowany 10d295abfe Fixed acoustic
ci / checks (push) Successful in 48m31s
2026-09-30 12:14:49 -05:00
khodowany 7683793052 Update Section Names
ci / checks (push) Successful in 47m26s
2026-09-30 08:05:28 -05:00
khodowany f5f0ab5eec Scale Patterns
ci / checks (push) Successful in 48m15s
2026-09-30 03:17:06 -05:00
khodowany 7cad6db980 Sizing
ci / checks (push) Successful in 47m22s
2026-09-30 02:49:54 -05:00
khodowany 923b7f14e2 Sustained Rhythm
ci / checks (push) Successful in 47m43s
2026-09-30 02:18:31 -05:00
khodowany 8a23fa0fc2 Generation
ci / checks (push) Successful in 47m20s
2026-09-30 01:54:59 -05:00
khodowany deaa0468a7 Generation 2026-09-30 01:38:16 -05:00
khodowany b1a7b44d7a Harmonic Minor 2026-09-29 23:55:08 -05:00
khodowany 2496b2f36f Ignore Scales for Now
ci / checks (push) Successful in 46m55s
2026-09-29 22:30:41 -05:00
khodowany f65637abd4 Scales
ci / checks (push) Failing after 25m24s
2026-09-29 08:03:57 -05:00
khodowany 142d843fac Improve Catalog
ci / checks (push) Successful in 46m42s
2026-09-27 11:03:43 -05:00
khodowany a416a80dd9 Fix Generation
ci / checks (push) Successful in 1h10m16s
2026-09-26 23:28:13 -05:00
khodowany 3beeb87549 Fix Generation 2026-09-26 22:59:14 -05:00
khodowany 83154b1a01 Generation 2026-09-26 18:00:51 -05:00
khodowany c8581ea51a Generation
ci / checks (push) Successful in 1h10m52s
2026-09-26 17:26:22 -05:00
khodowany 11222a9f8c Generation
ci / checks (push) Successful in 1h11m24s
2026-09-26 15:51:20 -05:00
khodowany 1d5f3a9372 Generation 2026-09-26 10:12:51 -05:00
khodowany 9fd5abd075 Rhythm Selection & Cleanup
ci / checks (push) Successful in 1h16m10s
2026-09-26 02:50:22 -05:00
khodowany 4d6348d354 Rhythm Time Signatures 2026-09-26 01:44:04 -05:00
khodowany 4485eb7413 Excercises 2026-09-25 22:39:38 -05:00
khodowany 6043870ed5 Excercises 2026-09-25 22:21:21 -05:00
khodowany 7a1a95395e Excercises 2026-09-25 22:19:49 -05:00
khodowany 8da8e4af2f Excercises 2026-09-25 22:12:24 -05:00
khodowany 69bf4540f1 Excercises 2026-09-25 22:04:52 -05:00
khodowany c472244e5f Excercises 2026-09-25 21:03:31 -05:00
khodowany c64d83af3e Excercises 2026-09-25 17:44:27 -05:00
khodowany 0c7b10fedb Excercises 2026-09-25 17:31:10 -05:00
khodowany 9920474b09 Excercises 2026-09-25 17:28:37 -05:00
khodowany 6989e81d77 Excercises 2026-09-25 16:44:12 -05:00
khodowany 41dff88bda Refresh build dependencies
ci / checks (push) Successful in 1h15m18s
2026-09-25 11:39:03 -05:00
khodowany 82b9541a03 Changes 2026-09-25 11:37:00 -05:00
khodowany 17326dbf56 Build Fix
ci / checks (push) Failing after 1h18m29s
2026-09-25 09:35:09 -05:00
khodowany 07559ad74c Rhythm 2026-09-25 08:41:53 -05:00
khodowany 3f47c0c5f3 Cleanup
ci / checks (push) Failing after 1h6m47s
2026-09-24 19:44:05 -05:00
khodowany 1be98e14aa Update
ci / checks (push) Successful in 1h15m13s
2026-09-24 06:20:37 -05:00
khodowany da5640291a Cleanupf
ci / checks (push) Successful in 1h15m45s
2026-09-23 23:54:19 -05:00
khodowany a0faa9b565 Practice Excercises
ci / checks (push) Successful in 1h19m55s
2026-09-22 23:39:32 -05:00
khodowany 478ac96dde Excercises
ci / checks (push) Successful in 1h19m38s
2026-09-21 00:41:11 -05:00
khodowany cf7c0c010c Tab Generation
ci / checks (push) Successful in 1h19m14s
2026-09-20 16:20:43 -05:00
khodowany 68ded4d8c8 Cached Generation
ci / checks (push) Successful in 2h36m35s
2026-09-20 10:39:12 -05:00
khodowany 7244cebe68 Update Data 2026-09-20 10:20:09 -05:00
khodowany 7bb1ed2b13 Data Labels
ci / checks (push) Failing after 3h12m18s
2026-09-20 02:07:26 -05:00
khodowany 5fc2103810 Update Data 2026-09-20 00:49:44 -05:00
khodowany c07586e06a Fret Range & Cleanup
ci / checks (push) Failing after 3h14m51s
2026-09-19 22:29:54 -05:00
khodowany 85cab9d6f6 Fix
ci / checks (push) Successful in 2h10m42s
2026-09-19 08:18:45 -05:00
khodowany cee9f45b0c Fix
ci / checks (push) Has been cancelled
2026-09-19 08:17:25 -05:00
khodowany e1410b0fdc Fix
ci / checks (push) Has been cancelled
2026-09-19 07:57:19 -05:00
khodowany 7a74ebf04d Build Fix
ci / checks (push) Failing after 1h6m51s
2026-09-18 21:05:38 -05:00
khodowany 4955405199 Build Fix
ci / checks (push) Failing after 24m8s
2026-09-18 18:04:07 -05:00
khodowany 80873a0896 Tone Generation
ci / checks (push) Has been cancelled
2026-09-18 01:17:36 -05:00
khodowany 64eac2f11e Excercises 2026-09-17 23:29:51 -05:00
khodowany fa385205e2 Shared Catalog
ci / checks (push) Failing after 1h5m14s
2026-09-17 01:25:26 -05:00
khodowany 5941a4cd49 Shared Catalog 2026-09-17 00:44:19 -05:00
khodowany aa1273b07d Data File Mappings
ci / checks (push) Successful in 2h3m59s
2026-09-16 23:55:09 -05:00
khodowany 079c024ca9 Data Files 2026-09-16 23:20:10 -05:00
khodowany a0eb8ba957 Data Files 2026-09-16 22:54:03 -05:00
khodowany 01c6d4e786 Transpose 2026-09-16 21:33:34 -05:00
khodowany c6adff8b3c Score
ci / checks (push) Successful in 45m44s
2026-09-16 19:22:23 -05:00
khodowany 7a83b5435f Engraver
ci / checks (push) Successful in 45m44s
2026-09-16 18:17:42 -05:00
khodowany 1d1bd3e871 Music Notation
ci / checks (push) Successful in 43m19s
2026-09-15 01:30:42 -05:00
khodowany fe42d811fa Formatting
ci / checks (push) Successful in 42m54s
2026-09-15 00:06:02 -05:00
khodowany 44e100985a Integral Types 2026-09-12 10:11:50 -07:00
khodowany 693e232957 Clangd
ci / checks (push) Successful in 42m38s
2026-09-11 00:02:36 -05:00
khodowany f84717990b Music XML
ci / checks (push) Successful in 42m24s
2026-09-10 22:01:18 -05:00
khodowany ea4e0bbba1 Editor
ci / checks (push) Has been cancelled
2026-09-10 18:54:24 -05:00
khodowany 1a13d6b0e1 Cleanup
ci / checks (push) Successful in 41m34s
2026-09-10 09:22:26 -05:00
khodowany fa7485d404 Styles
ci / checks (push) Successful in 39m31s
2026-09-08 22:07:31 -05:00
khodowany 6e7db6fd97 Add Excercises 2026-09-08 21:17:40 -05:00
292 changed files with 94781 additions and 28851 deletions
-1
View File
@@ -1 +0,0 @@
DisableFormat: true
-15
View File
@@ -1,15 +0,0 @@
CompileFlags:
CompilationDatabase: build/build_x86_debug
Add:
- -Wall
- -Wextra
Diagnostics:
ClangTidy:
Remove: ['*']
---
If:
PathMatch: (include/vamp_pico/.*|src/vamp_pico/.*)
CompileFlags:
CompilationDatabase: build/build_pico_debug
+6 -1
View File
@@ -1,5 +1,10 @@
.git .git
build build/*
!build/build_tools/
build/build_tools/*
!build/build_tools/docker/
build/build_tools/docker/*
!build/build_tools/docker/format_requirements.txt
dist dist
node_modules node_modules
*.log *.log
+30 -5
View File
@@ -74,6 +74,7 @@ jobs:
libdrm-dev:arm64 \ libdrm-dev:arm64 \
libegl-dev \ libegl-dev \
libegl-dev:arm64 \ libegl-dev:arm64 \
libfribidi0 \
libgbm-dev \ libgbm-dev \
libgbm-dev:arm64 \ libgbm-dev:arm64 \
libgles-dev \ libgles-dev \
@@ -93,14 +94,38 @@ jobs:
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: build/build_common/conan_cache path: build/build_common/conan_cache
key: product-vamp-conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json', 'conan/profiles/*') }} key: product_vamp_conan_${{ runner.os }}_${{ hashFiles('conanfile.py', 'modulus.json', 'conan/profiles/*') }}
restore-keys: product-vamp-conan-${{ runner.os }}- restore-keys: product_vamp_conan_${{ runner.os }}_
- name: Configure native release - name: Configure native release
run: python3 modulus.py configure --target x86_64_gcc12_release run: python3 modulus.py configure --target x86_64_gcc12_release --update
- name: Check package tooling
run: python3 modulus.py python -m unittest discover -s tests -p 'test_publish_package.py'
- name: Check code style
run: |
python3 -m pip install --no-cache-dir -r build/build_tools/docker/format_requirements.txt
python3 modulus.py format --check
- name: Identify engraving renderer
id: renderer
run: echo "fingerprint=$(python3 modulus.py python -m generator --renderer-fingerprint)" >> "$GITHUB_OUTPUT"
- name: Restore engraving cache
id: engraving_cache
uses: actions/cache/restore@v4
with:
path: build/build_common/engraving_cache/${{ steps.renderer.outputs.fingerprint }}
key: product_vamp_pages_${{ steps.renderer.outputs.fingerprint }}_${{ hashFiles('generator/**/*.py', 'generator/*.py', 'generator/palette.json', 'data/songs/*.xml', 'data/composition/*.json', 'data/models/*.json', 'data/training/*.json', 'data/training/*.xml') }}
restore-keys: product_vamp_pages_${{ steps.renderer.outputs.fingerprint }}_
- name: Build native release - name: Build native release
run: python3 modulus.py build --target x86_64_gcc12_release run: python3 modulus.py build --target x86_64_gcc12_release
- name: Save engraving cache
if: steps.engraving_cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: build/build_common/engraving_cache/${{ steps.renderer.outputs.fingerprint }}
key: ${{ steps.engraving_cache.outputs.cache-primary-key }}
- name: Test native release - name: Test native release
run: python3 modulus.py test --target x86_64_gcc12_release run: python3 modulus.py test --target x86_64_gcc12_release
- name: Test asset generation
run: python3 modulus.py python -m unittest discover -s tests -p 'test_*.py'
- name: Fetch Pico SDK - name: Fetch Pico SDK
if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }} if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }}
env: env:
@@ -119,10 +144,10 @@ jobs:
run: PICO_SDK_PATH="$PWD/build/build_common/pico-sdk" python3 modulus.py lsp --no-build-targets --database pico run: PICO_SDK_PATH="$PWD/build/build_common/pico-sdk" python3 modulus.py lsp --no-build-targets --database pico
- name: Create package - name: Create package
if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }} if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }}
run: PICO_SDK_PATH="$PWD/build/build_common/pico-sdk" python3 modulus.py package --version latest --update run: PICO_SDK_PATH="$PWD/build/build_common/pico-sdk" python3 modulus.py package --update
- name: Upload latest package - name: Upload latest package
if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }} if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }}
env: env:
PACKAGE_REGISTRY_USER: ${{ secrets.CONAN_USER }} PACKAGE_REGISTRY_USER: ${{ secrets.CONAN_USER }}
PACKAGE_REGISTRY_TOKEN: ${{ secrets.CONAN_TOKEN }} PACKAGE_REGISTRY_TOKEN: ${{ secrets.CONAN_TOKEN }}
run: python3 package/scripts/publish_latest_generic_package.py run: python3 modulus.py publish
+13 -9
View File
@@ -1,4 +1,8 @@
build/ build/
.clangd
.nvim.lua
.clang-format
pyrightconfig.json
**/__pycache__/ **/__pycache__/
*.pyc *.pyc
@@ -6,12 +10,12 @@ build/
*.pyd *.pyd
*.log *.log
package/CMakeCache.txt CMakeFiles/
package/CMakeFiles/ CMakeCache.txt
package/metadata/ CMakeUserPresets.json
package/.ninja_* cmake_install.cmake
package/*.json CTestTestfile.cmake
package/*.cmake Testing/
package/*.ninja build.ninja
package/*.sh .ninja_*
package/*-smoke compile_commands.json
+1 -3
View File
@@ -1,7 +1,5 @@
cmake_minimum_required(VERSION 3.25) cmake_minimum_required(VERSION 3.25)
set(VAMP_PROJECT_VERSION 0.1.0)
include(cmake/vamp_options.cmake) include(cmake/vamp_options.cmake)
if(VAMP_PICO_FIRMWARE_PROJECT) if(VAMP_PICO_FIRMWARE_PROJECT)
@@ -9,6 +7,6 @@ if(VAMP_PICO_FIRMWARE_PROJECT)
return() return()
endif() endif()
project(product-vamp VERSION ${VAMP_PROJECT_VERSION} LANGUAGES CXX) project(product_vamp LANGUAGES CXX)
include(cmake/zero.cmake) include(cmake/zero.cmake)
+119 -36
View File
@@ -5,13 +5,21 @@ 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.
The Zero imports generated score assets into SQLite through Noto at startup. Runtime navigation and rendering use Ligo for the HTTP API, Luma for touch UI rendering, and Modulus for shared build/runtime utilities. 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.
Pico controls: 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.
Device controls:
On the Zero, tap menu rows and switches directly; Tempo has minus/plus buttons. The main menu offers **Performance** and **Settings**. **Settings → Solos** switches both guitar parts between written solos (On) and beat slashes for improvisation (Off), including any solo pickup. Chord symbols and the rest of the arrangement stay in place. Solos starts On and remembers your choice until the app restarts; it applies when you next open a part on either device.
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 highlighted mode/song/part/practice row/key, or return from score view to the previous menu. - Tap and hold: select the focused item, enter/leave tempo editing, or return from a score to its part menu.
The HTTP service is device-only: Pico controls, title artwork, and health checks. There is no browser remote.
## Build ## Build
@@ -27,9 +35,17 @@ 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'
``` ```
Configure/build can read public packages anonymously from the khodo Conan registry. `python3 modulus.py run` uses `package/config/vamp.development.json` for a headless readiness check against native build assets. `vamp_zero --once` checks the packaged database and referenced page/audio assets, checks runtime writes and the server bind, then exits without opening audio or display hardware. Missing or incompatible catalogs require rebuilding or reinstalling; they are never repaired at runtime. `--check-config` only validates configuration; `--health-check` probes the running service's `/api/health` endpoint without modifying files.
Configure/build read public packages anonymously from the khodo Conan registry, even when the project cache contains an expired login. `configure --update` refreshes both the exported Modulus tooling and Conan dependencies. `--no-remote` uses only locally cached packages. Publishing is the only operation that needs credentials. CMake checks the required Sonus and Luma APIs during configuration and reports stale dependencies early.
ARM builds use the versioned GCC 12 cross compilers. The selected compiler executables also identify internal Conan binaries, excluding old packages built with an unversioned compiler that could resolve to GCC 13. The first corrected build rebuilds missing internal packages; third-party caches remain reusable.
`modulus.py` is an unchanged copy of Modulus's canonical bootstrap. The project-specific build image lives in `package/Dockerfile.build`; `tooling.docker` declares its additional inputs and permits Docker socket access only for build/package commands that assemble the Zero image.
Refresh clangd compile databases, including the Pico firmware database, with: Refresh clangd compile databases, including the Pico firmware database, with:
@@ -39,71 +55,138 @@ python3 modulus.py lsp
When running directly outside the generated Modulus Docker image, set `PICO_SDK_PATH` before refreshing the Pico database. When running directly outside the generated Modulus Docker image, set `PICO_SDK_PATH` before refreshing the Pico database.
Editor settings live in `modulus.json` under `tooling.editor`. Modulus generates the ignored `.clangd`, `.clang-format`, and `pyrightconfig.json` files; edit the JSON settings rather than these outputs. The clangd configuration routes Zero/shared code to the native compile database and Pico code to its firmware database.
Modulus also generates `.nvim.lua` for Neovim 0.11+ with `exrc` enabled. Review and trust it when Neovim asks; it preserves the existing LSP capabilities/keybindings and runs clangd 19 in the build container for this repo. Run `python3 modulus.py lsp` once to prepare both databases and the shared read-only header cache. Other editors can use `python3 modulus.py clangd` as their language-server command. Startup never downloads or builds anything, and never falls back to host clangd.
`tooling.lsp.clangd` configures the server executable, arguments, trusted compiler paths, and additional header roots. Product Vamp adds the bare-metal ARM compiler and `/opt/pico-sdk`; standard-library and SDK definitions open from an image-keyed cache under `$XDG_CACHE_HOME/modulus/clangd` (default `~/.cache/modulus/clangd`). Set `tooling.editor.nvim.enabled` to `false` for a hand-managed editor setup. Existing unowned `.nvim.lua` files are preserved.
## 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 --version latest --update PICO_SDK_PATH=/path/to/pico-sdk python3 modulus.py package --update
``` ```
The package command creates: The package command creates:
```text ```text
build/packages/product-vamp-latest-arm_64_gcc12_release.tar.gz build/packages/product_vamp_arm_64_gcc12_release.tar.gz
build/packages/product-vamp-latest-arm_64_gcc12_release.tar.gz.sha256 build/packages/product_vamp_arm_64_gcc12_release.tar.gz.sha256
``` ```
The tarball contains the Zero Docker archive, Pi install scripts, generated score assets, and Pico UF2 firmware. The tarball contains the Zero Docker archive, Pi install scripts, generated catalog database, pages, audio, fonts, Pico title artwork, and Pico UF2 firmware. Complete score JSON is stored inside `catalog.db`; individual JSON files remain build intermediates and are not deployed. Builds replace the same target-specific archive without a release label. The manifest and `vamp_zero --build-info` identify the source commit.
Run `sudo zero/install_zero.sh` from an extracted bundle on the Zero. Every installation resets all Product Vamp data and settings, including the control Wi-Fi profile, to the new bundle defaults. No previous database rows or configuration are migrated. Unrelated OS data and networks are untouched. Installing through the control Wi-Fi can disconnect your session; use a local console or an independent connection.
The installer probes the incoming image before removing existing application data. A failed replacement leaves Product Vamp stopped and reports an error; rerun the installer after fixing the cause. Normal service restarts do not perform installation cleanup. No automatic backup of the previous installation is retained.
Publishing the rolling generic package requires credentials only for upload: Publishing the rolling generic package requires credentials only for upload:
```sh ```sh
PACKAGE_REGISTRY_USER=<user> \ PACKAGE_REGISTRY_USER=<user> \
PACKAGE_REGISTRY_TOKEN=<token> \ PACKAGE_REGISTRY_TOKEN=<token> \
python3 package/scripts/publish_latest_generic_package.py python3 modulus.py publish
``` ```
Artifacts are published to the khodo generic package registry. Artifacts are published to the khodo generic package registry's required `latest` slot. Publish the updated Modulus tooling before running Product Vamp CI so packaging and publishing agree on the stable filenames. CI checks this contract immediately after configure using a tiny fixture archive, without compiling or contacting the registry. A failure here means the exported Modulus packager needs updating; rerun the full workflow after publishing Modulus, not just the upload step.
## Score Assets ## Score Assets
Source score JSON lives in `data/`. Generated score JSON is not committed. To inspect generated assets without building the full package, run: 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. `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. 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.
Electric accompaniment and electric melody share compact chord families on strings 1–4. Melody harmonization adapts these to keep the original melody on the lowest-numbered feasible string, preferring it above the added chord tones. The separate Electric Solo stays monophonic. Acoustic accompaniment uses one bass note on string 5 or 6 and three upper notes on strings 2, 3 and 4. Transposable degree families cover triads, sevenths/sixths, suspensions, diminished/augmented and extended/altered chords, with root/fifth bass alternatives on both bass strings. Plain major/minor chords also allow third basses; explicit slash basses and inversions take priority. Unplayable variants are discarded. Each instrument selects a connected voicing path across the source form to reduce hand movement, then reuses it through the arrangement.
Accompaniment uses independently authored two-bar jazz pattern families, chosen against each song's original melody and harmonic rhythm. Acoustic repeats two patterns (A–B); electric repeats four (A–B–C–D). Occasional source-phrase-ending fills recur at the same positions in every iteration; both instruments allow at most one per eight full measures, including the repeat join. Acoustic normally has 1–3 attack positions per full bar with quarter/eighth bass and upper-chord notes played together or in sequential pairs. Electric has distinct patterns with normally 0–2 attacks per bar, stabs, sustains and space. Cross-bar holds use ties; repeated attacks remain attacks. Pattern phase follows source-form position, including partial entries and odd-length forms. Meter changes use compatible cycles, with explicit diagnostics if fewer distinct patterns are available. Tempo changes do not reshuffle the accompaniment.
Each instrument has 24 distinct two-bar patterns in 4/4, 24 in 3/4, and 24 for each of the 3+2 and 2+3 groupings in 5/4. Written additive grouping takes precedence. Otherwise, the original chord changes and melody choose one consistent 5/4 grouping per continuous meter section, shared as source analysis by the independently generated parts. Explicit meter matching prevents treating 6/8 as 3/4. The learned solo model and playback count-in are unchanged by this accompaniment expansion.
Both accompaniment parts balance movement with sounding register and a soft cost for mean fret positions above nine; comfortable shifts can win over staying high. Acoustic uses a register center of MIDI 53 and electric uses 60. Compact spans and preferred chord families remain higher priorities, including for the final sustained chord. Electric melody's supporting chords use the same balance after the existing melody-string and foreground priorities; the original melody pitches and rhythm remain fixed. Voicing preferences do not reseed accompaniment rhythms.
Acoustic attacks normally play bass and upper chord together. Consecutive attacks exactly an eighth note apart split into bass–upper pairs within the same harmony; an odd leftover attack plays both together. Pairing continues across barlines and two-bar pattern boundaries, but stops at chord changes, chorus boundaries and source-defined entries/handoffs. The first attack for each new chord therefore includes bass, including a qualified anticipated entrance. Delayed entrances remain delayed. Every new acoustic attack releases the previous sound: a bass eighth followed by an upper-chord quarter has no bass underneath the quarter. Complete-cycle selection, fills and final realization use this same release rule. Bass and upper notes share one engraved part; uninterrupted holds may still tie across barlines.
Each song has six canonical roles: electric melody, electric accompaniment, electric solo, acoustic accompaniment, acoustic solo, and vocal melody. Matching source-form positions reuse the same musical events and TAB whenever a role returns. Final-turnaround omission changes only affected harmony; source-defined pickups/handoffs and the three-bar instrumental closing sustain remain explicit exceptions.
**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.
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.
`./modulus.py python -m generator.compose --song ... --role acoustic_solo --data-dir data --output ...` builds one independent role as JSON. To exercise prediction for a registered song, pass `--settings` with a JSON file containing `{"predict": true}`. Each song produces five score bundles: the normal acoustic, electric and vocal parts, plus acoustic and electric `_improv` variants. The variants reuse the same accompaniment and melody, replacing the full solo allocation (including pickup rests) with meter-aware slashes in notation and TAB. Their subtitles identify them as Improvisation. Normal scores retain written solos; separate solo MusicXML files are not generated. Authored XMLs remain under `data/training`, and regression tests compare their sounding events directly with generated score data.
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 temporary comparisons and one-off diagnostics under `/tmp/`; `build/` retains product outputs, tools and dependencies.
To inspect generated assets without building the full package, run:
```sh ```sh
python3 -m generator --data-dir data --output-dir /tmp/product-vamp-generated --static-assets-dir package/assets --clean --check ./modulus.py python -m generator --data-dir data --output-dir /tmp/product_vamp_generated --static-assets-dir package/assets --check
``` ```
The `--check` path compares generation invariants against `data/expected/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.
Authored score files use compact JSON. Metadata stays as normal JSON fields, while each measure is a readable notation string: 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.
```json 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.
{
"title": "Autumn Leaves",
"key": "F",
"time": "4/4",
"clef": "treble",
"part": "Vocal",
"role": "vocal",
"measures": [
"[G-7] Bb4:1~ \"leaves\"",
"[C7] ~Bb4:4 C4:4 \"drift\" D4:4 \"by\" E4:4 \"my\"",
"{5:2 C4:4 D4:4 E4:4 F4:4 G4:4}",
"[G7] (G4 B4 F5):4 r:4"
]
}
```
Notation tokens are `PITCH:DURATION`, `r:DURATION`, and `(PITCH PITCH ...):DURATION` for written chord tones. Durations use `1`, `2`, `4`, `8`, `16`, `32`, plus `.` for dotted values. `[Chord]` applies to the next token, `[(Chord)]` marks an optional turnaround chord that is used between generated performance iterations but omitted from the final ending, quoted text applies as a lyric to the previous note, `~` starts or ends ties, and `{N:M ...}` writes tuplets such as five quarter notes in the space of two quarter notes. Add a source and its authored solo, then retrain the pooled model, without changing generator code:
Bundled third-party font assets live in `package/assets/fonts/petaluma`. Petaluma is from Steinberg and is distributed under the SIL Open Font License; the package keeps `OFL.txt` next to the font files. If the Pico bitmap font needs to be refreshed after changing the bundled UI font, run:
```sh ```sh
python3 tools/generate_pico_font.py ./modulus.py python -m generator.train --add-source /path/to/song.xml --add-solo /path/to/solo1.xml --slot 1
python3 tools/generate_pico_font.py --check ./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`; 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 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 main performance sections plus the held Ending 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.
All generated monophonic guitar material uses frets 1–12 without open strings. Chords use two span tiers: **one to three consecutive fret positions share the primary tier; exactly four positions are a fallback** (e.g. 7–9 is primary, 7–10 is fallback). Five or more positions are forbidden. Selection minimizes fallback use before family, melody-string, movement or register preferences. A chord can extend to fret 20 while retaining at least one sounding anchor on frets 1–12 throughout its duration. Acoustic bass remains within frets 1–12 and upper chords need their own anchor when played separately. Some chord/key combinations still have no legal inversion; generation reports them instead of removing required tones. Electric source-melody harmonization retains its 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.
Normal browsing uses two music systems per page, with a single system only when the score ends on an odd system. Playback uses overlapping pairs (1–2, 2–3, 3–4): each new active line moves to the top, revealing the next below. The final line moves to the top with an empty lower half. Measure numbers sit above their starting barlines in a clear lane; playback outlines the active measure number in the accent color. 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.
Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The performance arrangements, including the held Ending, 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.
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.
A performance is an eight-bar intro followed by five complete passes: Vocal Melody, Guitar Melody, Acoustic Solo, Electric Solo, and Vocal Melody. A final Ending section adds one guitar chord attack sustained across three full measures, with vocal rests. The current 32-bar songs contain 171 numbered bars. The ending holds the final performed harmony (GM6 for All of Me, Dm for Autumn Leaves). 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 arrangement including its three-bar held ending, and decay. The existing WAVs are copied unchanged. The recorded count-in plays alone. Metronome clicks stop at the start of the written Ending section; those three measures and the decay remain unclicked. Metronome-only mode supplies its own count-in, waits silently through the ending, and then stops. Playback line turns and measure highlighting follow the rendered audio clock through all 171 measures, including rests and ties; the final line stays visible through the recording decay. Preparation and count-in have no highlighted measure.
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.
Use American English in project-owned identifiers, filenames, messages, and documentation, such as `catalog`, `color`, and `canceled`. Preserve authored music and external API names.
See [Score Generation](TECHNICAL_GUIDE.md#score-generation) for supported MusicXML notation, Vamp properties, schema details, and diagnostics. There is no compact-JSON source format or legacy runtime adapter. Unsupported notation fails generation before existing assets are replaced.
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.
Every normal build also writes printable PDFs to `build/<target>/generated_assets/print/`: written-solo and improvisation versions of each acoustic and electric part, plus one vocal part per song, including every arranged iteration and the ending. Print at actual size on portrait US Letter paper. Pages have white backgrounds, black notation, at least half-inch printer margins, and multiple systems per sheet; guitar parts include TAB. These are fresh 300 DPI renders, without the duplicated lines used by playback transition pages. PDFs use the existing Modulus-managed Pillow dependency, are cached separately, and remain build-only artifacts rather than being installed on the device.
`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.
Pico artwork generation requires Pillow's Raqm layout support to preserve kerning. On Debian/Ubuntu, install `libfribidi0` alongside the pinned Python requirements. The build image and CI install both. See [Pillow's installation requirements](https://pillow.readthedocs.io/en/stable/installation/basic-installation.html).
Pico firmware embeds a small set of fixed labels, part names, digits, and the page separator, generated as four-bit coverage sprites under `generated/pico/static.p4`. Score generation prerenders titles into content-addressed `pico/titles/*.mask` assets referenced by the catalog and served by the Zero when the selection changes. Pico blends those pixels and composes page counts from digit sprites; it does not load fonts, shape text, or rasterize glyphs. All Pico text shaping and artwork generation run on the build host. The build image and CI install the pinned dependencies automatically. For a direct build, first run:
```sh
./modulus.py configure
```
To inspect the fixed artwork independently, run `./modulus.py python -m generator.pico_artwork --static-output /tmp/product_vamp_pico/static.p4`. Changing the source OTF or artwork generator rebuilds the firmware sprites and generated title assets. These generated files are not authored music and are not checked in.
## License ## License
Product Vamp is released under the MIT License. Bundled third-party fonts keep their own licenses next to the font files. Product Vamp is released under the MIT License. Bundled third-party fonts keep their own licenses next to the font files.
+19 -9
View File
@@ -13,10 +13,12 @@ python3 modulus.py package
Copy the tarball to the Zero, verify its `.sha256`, unpack it anywhere, and run: Copy the tarball to the Zero, verify its `.sha256`, unpack it anywhere, and run:
```sh ```sh
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.
On reinstalls, the installer skips apt entirely when the required OS packages are already present. If packages are missing, the Zero needs upstream internet before the installer can finish provisioning Docker, NetworkManager, and SQLite. On reinstalls, the installer skips apt entirely when the required OS packages are already present. If packages are missing, the Zero needs upstream internet before the installer can finish provisioning Docker, NetworkManager, and SQLite.
@@ -29,26 +31,34 @@ 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/`. The build/package step runs the Python generator and packages the generated songs and practice variants into the Zero image automatically; generated JSON under the build directory does not need to be copied by hand. ```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
Hold BOOTSEL while plugging the Pico into USB, then copy: Hold BOOTSEL while plugging the Pico into USB, then copy:
```text ```text
pico/product-vamp-pico2w.uf2 pico/product_vamp_pico2w.uf2
``` ```
to the mounted `RPI-RP2` drive. 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 performance or practice score is active, then it shows the title, selected part or active practice key, 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 stop at the written three-bar Ending section; page timing continues through the hold. 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 three-bar held ending plus decay (171 written bars for the current songs). Rebuild/package after adding recordings. Backing tempo choices include only tempos supplied for every song; the current package has 120 BPM for both songs.
+352 -24
View File
@@ -8,51 +8,371 @@ Product Vamp follows the Modulus product layout used by the khodo repos:
- `include/vamp_pico` and `src/vamp_pico`: Pi Pico 2 W C++17 firmware. - `include/vamp_pico` and `src/vamp_pico`: Pi Pico 2 W C++17 firmware.
- `include/vamp_protocol` and `src/vamp_protocol`: shared route and page-command contracts used by both devices. - `include/vamp_protocol` and `src/vamp_protocol`: shared route and page-command contracts used by both devices.
- `cmake/pico.cmake` and `cmake/zero.cmake`: device-specific build definitions selected by the root `CMakeLists.txt`. - `cmake/pico.cmake` and `cmake/zero.cmake`: device-specific build definitions selected by the root `CMakeLists.txt`.
- `include/vamp_style.hpp`, `include/vamp_style/font.hpp`, and `src/vamp_style`: shared color palette, Petaluma font paths, and Pico bitmap font data. - `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`: authored source score JSON for performance songs and practice exercises. - `data`: original songs, paired training solos, the frozen solo model, and harmonic/accompaniment policies. Expected-output fixtures live under `tests/`.
- `generator`: build-time Python preprocessing for performance arrangements and practice variants. - `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, migrations, 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
The Zero process can run headless for local/API quick checks or with Luma for the Waveshare Zero-DISP-7A display. The API is plain HTTP on the private AP: The Zero process can run headless for local/API quick checks or with Luma for the Waveshare Zero-DISP-7A display.
- `GET /api/status` `runtime.headless` controls display startup and `audio.enabled` controls audio startup. The unused `runtime.hardware` setting has been removed; older copies of that field are ignored like other unknown configuration fields.
The API is plain HTTP on the private AP:
- `GET /api/health`
- `GET /api/remote/display` - `GET /api/remote/display`
- `POST /api/remote/title` (registered title image ID)
- `POST /api/remote/heartbeat` - `POST /api/remote/heartbeat`
- `POST /api/remote/next` - `POST /api/remote/next`
- `POST /api/remote/previous` - `POST /api/remote/previous`
- `POST /api/remote/select` - `POST /api/remote/select`
Legacy `/api/page/next` and `/api/page/previous` routes are still accepted for simple manual checks. There is no browser remote, HTTP font route, or full-catalog status dump. `control` owns serialized device actions; direct menu actions carry the displayed input revision and catalog identity so intervening remote navigation invalidates them. Pico display responses include only focused-item/settings values and the selected title artwork ID.
Page commands use `/api/remote/next` and `/api/remote/previous`; the old `/api/page/*` routes are removed.
The AP setup pins NetworkManager to 2.4 GHz channel 6 with WPA2/RSN and CCMP ciphers so the Pico W can join using `CYW43_AUTH_WPA2_AES_PSK`. The AP setup pins NetworkManager to 2.4 GHz channel 6 with WPA2/RSN and CCMP ciphers so the Pico W can join using `CYW43_AUTH_WPA2_AES_PSK`.
On Raspberry Pi OS Lite, the installed display service owns `tty1`, conflicts with `getty@tty1.service`, and starts the Zero container through `product-vamp-start-zero`. The wrapper switches to the display VT when `chvt` is available and runs `vamp-zero` inside the container through `setsid --wait --ctty` with `/dev/tty1` as stdin, giving raylib's DRM backend a real virtual terminal while keeping Docker attached to the long-running display process. SSH remains the normal admin path and `tty2` is enabled as the fallback local console. On Raspberry Pi OS Lite, the installed display service owns `tty1`, conflicts with `getty@tty1.service`, and starts the Zero container through `product_vamp_start_zero`. The wrapper switches to the display VT when `chvt` is available and runs `vamp_zero` inside the container through `setsid --wait --ctty` with `/dev/tty1` as stdin, giving raylib's DRM backend a real virtual terminal while keeping Docker attached to the long-running display process. SSH remains the normal admin path and `tty2` is enabled as the fallback local console.
At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-vamp/practice/<type>/<lesson_group>/<exercise>/<key>.json`, parses generated score JSON, imports both catalogs into SQLite with Noto, then reloads runtime menu state from the database. Runtime navigation and rendering use that loaded Noto-backed state instead of rescanning asset folders. The UI supports a top-level mode menu, Performance song/part/score menus, and Practice type/lesson/exercise/key/score menus. Melody practice offers key selection by choosing a pre-generated score variant; chord practice skips key selection and renders the authored score key directly. The score renderer draws title/composer/part metadata, systems, measures, clef/key/time preambles, enlarged chord symbols and section labels, Petaluma SMuFL noteheads/rests/flags/dots/accidentals, simple vector stems/ties, and lyrics. Slash notation is represented explicitly with `notehead: "slash"` in generated JSON. 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, Solos, 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.
Petaluma music symbols are placed from SMuFL bounding boxes and stem/flag anchors instead of rough font-size offsets, which keeps clefs, noteheads, rests, dots, accidentals, and eighth-note flags aligned to staff-space coordinates. Printed accidentals are derived from the active key signature and per-measure accidental state, so natural signs are shown only when they are musically required.
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 includes a required integer `solos_enabled` flag (0 or 1), matching the boolean in music metadata. Catalog generation and startup validate paired variants for arranged guitar parts and a single shared Vocal. This schema requires rebuilt assets and updated Zero/Pico software; runtime migration is not supported. 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.
Pico networking progresses without blocking touch sampling. Navigation commands use a bounded 16-entry FIFO ahead of background status polls; a full queue reports busy. Commands are not automatically retried because a timed-out request may already have executed. Background polls wait one second after completion before trying again.
The Zero menus use direct taps: release on a row to select it, tap switches to toggle them, and use the tempo minus/plus buttons to adjust BPM. Drags, canceled touches, multiple fingers, and stale menu actions do not activate items. Back clears tempo editing; returning to Settings restores the last setting's focus. Long song lists have previous/next focus buttons.
In score view, Zero and Pico share one gesture state machine: a second release within 220 ms of the first release produces previous, a hold of at least 700 ms produces select once, and other taps produce next. A late second release preserves both single taps; holding consumes a pending first tap. Zero schedules pending-tap deadlines without polling and discards canceled or obsolete timer callbacks.
Score loading uses a dedicated single-worker executor for SQLite reads and JSON parsing. Completion returns to the control executor. The loader retains one in-flight request, coalesces pending selections, and discards stale results, keeping database work off HTTP and playback polling.
## Score Generation ## Score Generation
The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Performance source songs become acoustic, electric, and vocal arrangements. Melody practice sources are emitted in all 12 keys at build time. Chord practice sources are emitted once in their authored key. Generated JSON is sanitized so packaged scores do not contain tab positions or fingering fields; guitar playability logic is used only inside the arrangement generator. The build runs `./modulus.py python -m generator` against `data/` and writes build-only assets under the CMake binary tree. Every song produces acoustic, electric, and vocal parts in its authored musical key. Source counts are discovered, not fixed in code. Generator-only optional chord markers are consumed before publishing.
Authored source files use compact JSON and are normalized by the generator before arrangement. A root-level `measures` array means compact source; legacy staff/voice JSON with `staves` is still accepted. Measure notation supports `PITCH:DURATION`, `r:DURATION`, parenthesized chord tones like `(G4 B4 F5):4`, `[Chord]` markers, optional turnaround markers as `[(Chord)]`, quoted lyrics, `~` tie starts/stops, and tuplets as `{N:M ...}`. Tuplet normalization keeps the written duration in `display_ticks` and distributes rounded timeline ticks across the tuplet group so the measure total remains stable on the existing 16,384 ticks-per-whole-note grid. 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. Each song generates five bundles: the usual three parts and acoustic/electric `_improv` alternatives. The two current songs generate ten bundles. Solos defaults to On at each app start; Off selects the improvisation guitar variants while Vocal is shared. Part menus retain three rows, using the selected variant’s normal and playback page counts. Pico status includes `solos=0/1`.
Generator checks compare the generated performance summary against `data/expected/performance_summary.json`. The summary stores compatibility invariants such as section layout, chord-label placement, slash-section placement, and vocal timing without committing the generated score files themselves. Refresh that file only when an intentional arrangement-logic change should become the new baseline. 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
Generator dependencies run from pure musical values and source analysis toward composition, assembly, and publication. `model` contains primitive values; `validation` checks score documents; `score` contains pure score operations; and `score_io` owns bundle/file validation. `source` owns canonical source identity and immutable source events. `arrange.form.FormPlan` supplies source-derived sections, ownership windows, and pickup stops to both accompaniment and role assembly.
`composition` generates one role from an explicit immutable `vocabulary.CompositionContext`; `production` schedules typed worker requests and publishes the assembled parts. Lower musical functions receive that context explicitly. The scoped vocabulary API remains available for standalone callers and tests. Cached vocabulary loads return owned copies so one caller cannot alter another caller's resources.
Role cache identities include the transitive generator imports of the composition entrypoint. Engraving cache identities similarly include renderer dependencies. Cache identities never seed musical choices. `cache` provides shared content identity and staged-file publication mechanics, while role, PNG, and PDF adapters retain their own validation. `engraving.worker` owns rendering and its bounded image cache; `engraving.pipeline` groups and schedules work. `performance` contains pure timing contracts, and `audio_assets` validates and copies supplied recordings.
The reviewed output contract is checked by `tests.test_output_contract` for complete synthetic role scores, including learned solos, and by `python -m tests.asset_contract <generated-assets-directory>` for every non-diagnostic asset. CTest runs the asset check against the normal build output. Musical output, JSON/catalog serialization, PNGs, and PDFs must remain unchanged during structural refactors; diagnostic text and cache identities may change.
`data/songs/` contains authoritative source scores. `data/training/manifest.json` pairs each authored solo with a source and a slot (1 or 2), and declares its pitch convention. Both slots train **one pooled model**. Slot 1 maps to acoustic playback and slot 2 to electric playback; slot and instrument names are not model features. The four XML files retain their notes, harmony, timing, ties, rests, Autumn Leaves pickup and All of Me's full-bar opening. Explanatory labels such as `Lead in`, `b9` and `6` were removed; they were not model features. Written chord extensions remain intact.
`data/models/solo.json` is an explicitly trained, versioned statistical model. Its short-context counts, rhythm and register statistics are learned from the paired examples. Provenance records input hashes and the training version. This is supervised use of the supplied solos, not independence from them and not a neural network pretrained on an external corpus.
`data/composition/` contains shared guitar/difficulty policy, sparse accompaniment figures and explicit harmonic-treatment validation rules. The exhaustive rhythm/interval catalogs, 21-pattern scale catalog, fixed maps, old solo grammar and handwritten phrase scorer have been removed. The renderer can still display existing scale annotations as part of its compatible score schema; new solos do not create them.
### MusicXML Contract
| Area | Accepted notation |
| --- | --- |
| Pitch | Exact rational letter/alteration/octave spelling, including microtones, double/triple accidentals, and courtesy accidentals. B-sharp and C-flat retain the correct octave. |
| Rhythm | All fourteen MusicXML types, maxima through 1024th; up to sixteen augmentation dots; exact positive rational metric durations and separately stored written values. Time modifications must reconcile the two. |
| Chords and rests | Independent voice lanes, simultaneous chord tones, shorter additional chord tones, rests, full-measure rests, hidden events, forward spacers, and rhythm slashes. |
| Ties and tuplets | Per-pitch ties, including partial chord ties; nested and cross-bar tuplet spans. Nested tuplets require explicit `tuplet-actual` and `tuplet-normal` numbers. |
| Small notes | Grace notes with zero metric duration, cue notes, and measured single/paired tremolos. |
| Staff state | G/F/C, percussion, and no-clef staves; octave clefs and octave-shift spans; major/minor/modal keys or custom seven-letter alterations; timed key, clef, and meter changes. Authored TAB and technical fingering instructions are rejected; TAB is generated from pitched guitar parts. |
| Meter | Simple, compound, additive, mixed-denominator, and unmetered timelines. A partial measure must be marked `implicit="yes"`; an initial implicit measure is the arrangement pickup, including a full measure padded with leading rests. |
| Harmony | Structured roots, kinds, inversions, slash basses, and degree additions/alterations/subtractions. Harmony events have their own offsets, including inside held notes. Electric melody generation intentionally reattacks eligible long notes at changed harmonies; import preserves the authored notes. |
| Text | Title, composer, part name, multiple lyric verses with syllabification, elisions, and extenders, rehearsal labels at measure starts, and Vamp directives. |
| Tempo | Metronome directions and positive quarter-note BPM from `<sound tempo="...">`, either within a direction or directly in a measure. Playback/navigation attributes on `sound` are rejected. |
| Reading order | All measures are written out. Repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. Ordinary barlines and contiguous ties remain supported. |
Multiple staves/parts, D.C./D.S./coda/fine navigation, slurs, dynamics, articulations, and expressive ornaments are outside the product contract and rejected. Unknown musical elements also fail. Serialized times and alterations use reduced signed-64-bit numerators and positive denominators; pitch octaves are -4 through 16, clef/octave shifts span at most three octaves, and meter counts are 1-1024 with denominator units 1-4096. These resource bounds produce diagnostics, not clamping or truncation.
Layout/exporter information such as page defaults, credits, print layout, font/color/position attributes, and source pagination does not determine the small-screen layout. Engraving is regenerated. Beam groups and written note values are retained; unrelated engraving metadata does not authorize ignoring musical descendants. `defusedxml==0.7.1` forbids XML entities and external access. Ordinary MusicXML DOCTYPE declarations are allowed without fetching their DTDs.
Chord labels are generated from structured harmony using `M`, `m`, `aug`, `dim`, and `sus`: for example `CM`, `Cm7`, `CM9`, `Caug`, `Cdim7`, `Csus`, `Csus2`, and `Cm7b5`. `CmM7` is a minor triad with a major seventh, corresponding to MusicXML's `major-minor` kind. Ninths, elevenths, and thirteenths include their underlying seventh; `Cmadd9` does not. Slash basses, degree alterations, added tones, and omissions remain semantic, not display-only. Older recognized spellings such as `-7`, `maj7`, and `+` are accepted but rendered canonically.
Standard MusicXML kinds and explicit degrees remain authoritative. For Soundslice compatibility, a text-only `<kind text="mM7">other</kind>` (also `m7b5`, `7b9`, and other recognized chord spellings) is normalized into a standard kind and degrees. Complete symbols such as `(Fm7)` or `(Bb7)` supply their own root; an exporter placeholder C is accepted only for text-only `other` harmonies. A conflicting non-placeholder root, conflicting bass, contradictory structured chord, or unknown spelling is an error with source/measure context. Raw exporter text is not retained as an override that could become stale during transposition. This compatibility path does not require editing the XML or weakening structured-harmony validation.
### Vamp Directives
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 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
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), followed by three held ending measures, 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 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 171 arrangement bars: the eight-bar intro, five 32-bar passes, and three written held-ending measures.
Improvisation assembly reuses the independent accompaniment and melody roles and fills each source-defined solo window with regular beat slashes. The complete pickup allocation also becomes slashes, including rests within that allocation; music before a partial pickup and subsequent melody handoffs are preserved. Slash events carry no pitches or fret positions, and render in both the staff and TAB. This does not rerun solo composition or change backing recordings, measure numbering, harmony, or performance duration. Both variants receive normal/playback pages and build-only printable PDFs.
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
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.
- `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.
- `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.
Measure durations, event onsets/durations, harmony offsets, span times, and pitch alterations use `{ "numerator": N, "denominator": D }`. Times are in quarter-note units and alterations in semitones. There is no tick grid, display-duration surrogate, or runtime flattening into one voice. Written note types, dots, and time modifications remain distinct from metric duration. Every event owns a voice; every chord tone has its own ID, written duration, duration, and reciprocal tie links. Tuplets reference events and belong to one voice.
### Shared Part Generation
`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 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.
Electric accompaniment shares the electric melody's `guitar.chord_positions(..., profile='electric')` families: strings 1–3 or 1–4, with a preference for four-note grips. `chord_families.py` classifies actual shapes as drop-two, close-position, compact-open, compact-doubled or three-note shells, ranked by versioned policy. Electric melody chords must also use strings 1–3 or 1–4, always including string 1. The exact source pitch is retained even when a legal supporting chord tone must sound above it. Within the selected span tier, the melody's lowest feasible string number and foreground position precede chord size. A primary grip with the melody on string 2 beats a four-fret grip with it on string 1. Short monophonic melody notes and solos use the shared hand-window placement objective. A harmonization with no legal chord retains the existing reported single-note fallback. These electric roles use the same written chord requirements without taking responsibility for the slash bass. Acoustic accompaniment uses `acoustic_voicing.candidates`: exactly one bass on string 5/6 and one upper note each on strings 4/3/2. Without an explicit slash bass or inversion, its bass is a root or written fifth (including an altered fifth); plain major/minor triads also permit the third. Plain triads retain all three chord members. Explicit bass/inversion requirements override this choice.
Acoustic families are transposable degree orders for triads, tetrads and extended chords, with root/fifth alternatives on both bass strings. Slots resolve to the written third/suspension, sixth/seventh, fifth and extension, including alterations and omissions. They are not per-song grip tables. Only harmonically and physically valid realizations enter selection; unavailable bass/inversion variants are discarded. Coverage checks exercise 35 common qualities in all 12 keys. Four-fret fallbacks support previously unavailable acoustic Bb9, BbM9, Bb7#9 and Bb6add9; for example, Bb9 uses fifth-bass frets 8/10/7/9 on strings 5/4/3/2. Acoustic Bbm9 and Bm9 still have no grip meeting all constraints. Unsupported chords fail with source measure, beat and chord rather than silently omitting defining tones.
The active voicing selector compares complete paths with dynamic programming over concrete grips. The leading cost minimizes four-fret grip selections: every span of one, two or three positions has zero cost in this tier. Existing criteria then apply within the tier. Electric accompaniment prioritizes chord size and shared family rank; acoustic prefers declared degree-order families on either bass string. Both then minimize hand-window travel, the passage maximum hand position, secondary finger/grip effort, missing optional tones and sounding-register distance, in that order. Stable role-specific keys resolve equal costs. Since accompaniment transitions have travel costs but no hard travel cutoff, four-fret candidates are safely removed from a span when any primary grip exists; this exact dominance reduction keeps the repeat search small. Electric placement retains distinct alternatives where future held/tied notes can constrain feasibility, and does not charge tied continuations as fresh grip choices. The remaining acoustic placement helper uses the same leading fallback tier.
Silent spans retain the preceding hand position. A continuous accompaniment repeat includes the last-to-first transition. Source-harmony variants, such as relocated pickup harmony, are solved between fixed canonical neighbors without mutating a shared previous-grip state; request order cannot change them. Per-span composition reports include candidate counts, selected family, bass/string family availability, selected positions, bass degree and movement, plus `fret_span`, `span_group` and `span_candidates` (primary/fallback counts before dominance reduction). These are diagnostic sidecar fields; the score/runtime format is unchanged.
`comping_rhythm.py` selects recurring source-only figures from version 6 of `composition/accompaniment.json`. The normal acoustic cycle has two two-bar slots; electric has four. Each template and anticipation mode is evaluated at every occurrence of its slot before retrieval limits apply. Up to eight diverse candidates per slot enter bounded complete-cycle comparison. Distinct slots differ in attack/duration relationships, not just IDs, bass/upper assignments or anticipation flags. Repetition is not penalized. The source melody and harmonic rhythm affect selection; title, iteration, generated solo and tempo do not. `groove_cycles` reports slot identities, anticipation modes, aggregate cost, compared cycles and any reduction caused by vocabulary or form length.
Meter-specific full patterns declare `meter` and, for 5/4, `grouping: [3, 2]` or `[2, 3]`; `meter: partial` marks the bounded one-bar pickup vocabulary. The loader checks nominal lengths, grouping, quarter/eighth arithmetic, electric lane overlap and distinct acoustic/electric onset patterns. Acoustic lane overlap is checked after automatic assignment. Each role has 24 distinct attack/duration figures per selectable pool: 4/4 (11 families), 3/4 (12 families), and each 5/4 grouping (12 families). The 4/4 pool removes one duplicate acoustic figure, develops another previously duplicated answer, and adds three electric variants. Patterns are independently authored from general principles, with research URLs and provenance in the resource, rather than transcriptions. The counts describe available choices, not required musical activity.
`grouping_for` is a pure source-only decision. It honors written 3+2 or 2+3, otherwise compares actual chord changes at the secondary boundary, then melody entrances following at least an eighth-note rest, then other melody attacks; equal evidence defaults to 3+2. An unchanged chord marking is not a change. The selection stays fixed through a continuous meter section, and each role derives it independently from the same source. Fills cannot borrow another grouping. No accents or playback swing changes are inferred. `groove_cycles` additionally reports grouping, its evidence and the eligible pattern count. Equal-duration compound meters (such as 6/8) do not borrow the 3/4 vocabulary; unsupported comping meters fail explicitly.
Accompaniment dependency keys and choice streams include only full-pattern meters and partial lengths present in that source, plus the applicable instrument policy. Adding unused-meter patterns therefore does not reseed an existing song; unrelated role policies remain excluded. Research prose is excluded from musical seeds. This schema migration changes the accompaniment choice-key domain once; it does not promise identical previous 4/4 tie-break winners. Source melodies, registered solos, notation format and playback count-in are unchanged.
The cycle begins at the first full source-body measure. A partial-form entry first fits the incoming harmony, then slices the sounded figure instead of restarting it; this prevents a previously clipped chord hold from reappearing as a new attack. An odd final bar uses the appropriate half-pattern. Meter changes restart a compatible cycle. Unsupported durations fail explicitly, and limited vocabularies report a shorter cycle. Optional final-harmony changes rebind the same selected pattern. Canonical grips are fixed wherever the chord and interval start remain unchanged, so a new turnaround cannot move neighboring unaffected voicings.
Fills replace only a two-bar figure's ending bar, using a same-family variation that improves the existing source-fit objective. Eligible boundaries are source section endings, form endings, or a melody breath of at least one quarter-note beat at the block end. Carried events from the opening bar are protected. The largest improvements are retained with an eight-measure minimum separation around the whole cyclic form, equally for both roles; no fill quota applies. Fills do not change cycle phase and recur at identical source positions. Reports record the base slot, selected fill, source index and reason.
Normal acoustic patterns use 1–3 attack positions per full bar. Their event layer is `acoustic`; the instrument's versioned `attack_assignment` policy is `combined_except_eighth_pairs`. Authored bass/upper decisions are replaced by chronological assignment across the complete canonical source form: ordinary attacks are combined, while adjacent half-beat-separated attacks in the same effective harmony become bass then upper. Pairs do not overlap; an odd leftover attack is combined. Bar and two-bar pattern boundaries do not reset pairing. Source-defined entries, handoffs and chorus boundaries do; the same boundary set applies at repeated form positions. Chord changes break pairs, but repeated identical chord labels do not. An anticipation uses its upcoming harmonic region for this decision. First attacks of new harmonic spans still include bass (`first_attack: bass_per_harmony`).
Quarter/eighth articulations and explicitly authored sustains release at the earliest of their intended end, the next acoustic attack in either lane, or a required harmonic/handoff boundary. Bass cannot ring beneath a later upper attack, nor can an upper chord ring beneath a later bass attack. Combined attacks have one shared release. Shortening that would strand an anticipation first rebinds it to current harmony and recomputes assignment. `acoustic_context` and `realize_acoustic_form` implement the shared source-form contract used by cycle comparison, fill selection and final source-harmony variants, before grip placement or notation splitting. Raw candidates retain authored releases so a changed neighboring attack can be evaluated afresh. Fills must improve the resolved whole-form cost without changing events outside their editable second bar. Concrete block caches include their assigned event signature: a neighboring chord change can break a pair even when this block's own harmony is unchanged. The renderer verifies actual sounding lane events against the selected assignment. The completed validator follows sounding tie chains and rejects any overlap between distinct acoustic attacks, even across different lanes. Placement validates all simultaneously sounding strings, fret spans and anchors. Bass and upper events merge into one notated part. Uninterrupted cross-bar holds and the combined three-bar closing sustain remain intact.
Electric normally uses 0–2 attack positions per bar, with stabs, sustains and space. Direct events release at changed harmony. An eligible acoustic/electric template can anticipate a chord by half a beat when it continues into that chord; the grip comes from the upcoming harmony and releases by its following change. Acoustic anticipations also include bass on the new chord's first attack. Missing harmony remains silent. Protected participation limits retain the chosen figure, shortening only its affected releases and recording exceptions. Cross-bar sounding notes become ties, never automatic merging of repeated attacks.
All six production roles receive reuse audits. Musical comparisons include rests, pitch durations, ties and TAB while ignoring regenerated event IDs and global measure numbers. Source-defined pickup/participation changes and effective final-turnaround harmony are reported separately. The final three-bar hold remains instrumental.
Named activity, melody-support, harmonic-support and articulation contributions evaluate complete figures. Cycle selection aggregates them over all occurrences, retaining family diversity before the final bounded comparison. `groove_cycles.retrieval_cost` is the isolated-figure estimate used for shortlisting. `cost` is the actual selected base cycle's cost before optional fills; acoustic cycles are assigned and released together before this comparison. Acoustic `final_cost` reports the complete source-form result after fills and any meter-section joins. The selected score is never overwritten by a post-selection report. Reports identify the base slot, realized pattern, projected events, continuations, anticipation target times and source-context exceptions. Tempo affects metadata/cache identity but never musical choice. This release correction leaves musical data and role choice seeds unchanged; code fingerprints invalidate stale concrete caches.
Acoustic events use temporary bass/upper notation lanes for projection; then `acoustic.combine` converts their sounding intervals to one rhythmic voice before assembly. It splits notes at attack/release boundaries and ties only continuing pitches; repeated attacks stay separate. Staff and TAB share the result, with no competing rests or opposing layer stems. This preserves exact sounding durations instead of truncating overlaps. The older truncating `prepare` helper serves only the noncanonical legacy/test path. The product build audits canonical accompaniment reuse across arrangement iterations, allowing only source-derived changes in harmony, pickups and role handoffs. The `.roles` sidecars report pattern IDs, exact projected layer events, continuations, anticipation targets and exceptions. Their `selection_cost` records the complete figure’s local contributions, repeated on each projected bar for context; the cycle selector aggregates these by the number of measures in each occurrence, without an anti-repetition transition cost. Accompaniment dependency keys contain only that instrument's policy; electric melody consumes the shared electric voicing families. Solos retain their existing learned source-melody/harmony conditioning and registered exact performances.
Acoustic sequential-release verification on October 7, 2026: `python3 modulus.py python -m unittest discover -s tests -p 'test_*.py'` passed 451 tests in 162.318 seconds. Coverage includes release in both lane directions, cross-bar pairing in 3/4, 4/4 and both 5/4 groupings, expired and surviving anticipations, complete-cycle ranking versus isolated retrieval, fills protecting neighboring assignments, malformed overlap before/after notation merging, canonical reuse, cold/warm caches and parallel roles. `python3 modulus.py format --check` passed. `python3 modulus.py build --target x86_64_gcc12_debug --target arm_64_gcc12_release` passed both generator/asset checks; 394 score JSON/PNG artifacts match across targets. Acoustic composition took 2.1 seconds for All of Me and 2.2 seconds for Autumn Leaves; native assets finished in 31 seconds and the cached ARM build in 11 seconds. Compared with the saved pre-change output, all attack pitches, onsets and TAB positions are identical: only 67 Autumn Leaves bass releases are shorter. Its arrangement measure 3 now contains bass [0, 1/2), upper chord [1/2, 3/2), and combined chord [2, 3), without ties. All of Me and the ten other-role documents are unchanged. Canonical reuse audits pass, and both three-bar instrumental endings retain one twelve-beat attack. Both acoustic opening pages were visually inspected; no audio audition was performed. The comparison baseline remains outside the repository under `/tmp/vamp-sequential-acoustic-yt_irkfd`.
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, a sounding anchor on frets 1–12, primary spans of up to three consecutive positions and fallback spans of exactly four. The span is inclusive: 7–9 is three positions, 7–10 is four. Five positions are always invalid. The anchor must remain sounding, including after shorter notes release. Acoustic accompaniment additionally keeps its bass within frets 1–12 and requires an independent anchor in its upper three-note cluster, preserving the option of separate bass/upper attacks. These costs approximate physical feasibility; they do not assign fingers or guarantee comfortable playing.
Guitar placement shares the `hand-window-2` model in `generator/hand_position.py`. **Both chords and single notes** may occupy any legal four-consecutive-fret window. Position 7 covers frets 7–10; a chord occupying only frets 7–8 can use positions 5, 6 or 7, according to its neighbors. Position is the bottom of the selected window, not necessarily an occupied fret. This is an approximate positioning model, not an anatomical finger assignment. Sounded-note limits remain binding: a solo note cannot exceed fret 12 merely because its hand window extends farther.
After the existing compact-span, chord-family and electric-melody foreground priorities, selection minimizes **total absolute hand-position travel**, then the **maximum hand position across the complete path**. Finger changes and string crossings within the same window contribute zero travel. Only after those two criteria tie do relative finger adjustment, string crossings, grip complexity and time pressure decide between paths. Acoustic bass is one voice even across strings 5 and 6; upper voices follow their strings. Added/removed chord voices contribute one secondary adjustment each. Solo secondary effort uses elapsed time from the preceding attack, not a tied notation fragment. A genuine continuation retains pitch, TAB and hand position without another movement charge.
A low fret is not rewarded independently at every event. All three voicing policies retain only `register_weight` (default 1), weighting distance from their sounding-register center after travel, peak position, secondary effort and optional-tone coverage. Centers remain 53 for acoustic accompaniment and 60 for electric accompaniment/melody. Exact melody and solo pitches are never transposed to meet these preferences. Canonical accompaniment selection includes source-form repeat joins and solves source-derived variations between actual selected neighboring grips **and hand positions**. Unchanged intervals retain both. The instrumental ending receives the last accompaniment grip and selected window; it does not infer the hand from the lowest occupied fret.
The dynamic programs retain concrete grips/held strings, selected hand position and distinct earlier peaks. A later high position can equalize two earlier peaks and expose a subordinate tie-break, so retaining only one lowest-peak history is unsafe. Strictly worse primary travel at an identical endpoint/hand can still be discarded. Equal fret averages do not imply equal placement states. Stable ordering resolves equal complete costs.
Role reports expose `total_travel`, `shift_count`, `largest_shift`, `peak_position` and actual selected hand positions. Accompaniment costs separate travel from secondary `effort`; individual spans separate hand shift, finger adjustment, string changes, grip effort and register tie-breaking. Repeat travel is assigned to the first canonical span. Solo reports call the time-weighted subordinate value `secondary_effort`. Cache fingerprints include placement code while accompaniment rhythm seeds remain unchanged. Every role remains independently composed; the solo model, pitches and rhythms are unchanged.
The pre-window baseline is preserved outside production inputs under `/tmp/vamp-hand-window-baseline-dwh4anzh`. Verification compares sounded content and rhythms separately from TAB, and checks all electric melody chords for strings 1–3 or 1–4. Independent exhaustive test oracles cover complete grip/window paths, fixed neighbors, cyclic repeats and single-note paths; tests also cover a greedy-position trap, future peak equalization, zero-cost within-window string changes and tied-event invariance.
Hand-window verification on October 8, 2026: `python3 modulus.py python -m unittest discover -s tests -p 'test_*.py'` passed 472 tests in 100.928 seconds. The subsequent asset build exposed a print-footer overlap: Letter pagination had reserved a nominal font height instead of the actual page-number outline. The new tight-page regression reproduced that failure, and `python3 modulus.py python -m unittest tests.test_printing` passed all eight tests after measured footer bounds replaced the estimate. Content and collision audits remain enabled.
`python3 modulus.py build --target x86_64_gcc12_debug --target arm_64_gcc12_release` then passed both asset checks and produced six complete PDFs (42 pages). Native regeneration took 33 seconds with cached roles and a cold engraving namespace; ARM reused the assets in six seconds. Before the footer repair, uncached composition completed in five seconds for all twelve roles, with solos taking about 0.5 seconds each. `python3 modulus.py test --target x86_64_gcc12_debug` passed in 13.27 seconds. All 400 score JSON, normal/playback PNG, report and PDF files match between native and ARM.
Against the saved pre-window baseline, all four solos retain exactly the same pitches, timing and ties; vocal documents and accompaniment rhythm reports remain identical. Electric melody retains its exact source notes and timing; supporting chord tones may change to enforce string 1. All 46 All of Me and 33 Autumn Leaves electric melody chord fragments now use strings 1–3 or 1–4; previously 13 and three fragments respectively omitted string 1. Short monophonic melody remains permissible.
| Solo | Total hand travel, before → after | Number of shifts, before → after | Peak hand position, before → after |
|---|---:|---:|---:|
| All of Me acoustic | 131 → 12 | 87 → 10 | 9 → 7 |
| All of Me electric | 128 → 9 | 87 → 7 | 9 → 7 |
| Autumn Leaves acoustic | 141 → 2 | 85 → 2 | 9 → 3 |
| Autumn Leaves electric | 106 → 6 | 77 → 6 | 9 → 5 |
Travel is the sum of absolute window-position changes, measured in frets. An independent dynamic program over every legal string/fret/window confirmed that each new solo achieves the minimum raw travel for its unchanged sounding notes. This verifies the placement objective, not anatomical comfort or musical preference. The canonical accompaniment cycles now travel 10/14 frets for All of Me/Autumn Leaves acoustic, and 0/6 for electric; they retain source-based repeat consistency. No listening assessment was performed.
The sibling Modulus Conan helper was repaired to call `modulus.build_context.build_context`, replacing the removed `modulus.cli.build_context` API. Its Python suite passed 132 tests with four existing skips. A locally exported and built Modulus package passed `python3 modulus.py configure --target x86_64_gcc12_release`, followed by the release build and runtime test above. The offline attempt first identified missing cached Ligo sources; allowing ordinary Conan source retrieval completed configuration. An unwritable generated engraving cache was also restored to the build user's ownership. The Modulus source fix must be published through that repository's normal release process before remote CI using `modulus/latest` receives it; no remote package was published during this repair.
Recurring-groove verification on October 6, 2026: the full Python regression command passed 423 tests in 149.85 seconds. After the final partial-entry grip fix and one additional real-evaluator fill test, the focused accompaniment/arrangement command passed 59 tests in 21.64 seconds. The native test target passed in 30.58 seconds. The initial native rebuild took 42 seconds, including 7 seconds for independently generated roles and 33.62 seconds of engraving; accompaniment roles took 0.8–1.6 seconds each. The following ARM build reused all role/score caches and took 11 seconds. All four solo documents, both electric melodies and both vocals exactly matched the prechange baseline; native/ARM score assets matched byte for byte. The chosen base cycles differed by song and retained all two/four requested slots. Autumn Leaves selected the authored overlapping-quarter acoustic pattern. No optional fill improved these production winners, so no fills were forced; independently authored tests verify real source-fit selection of a fill. An acoustic score/TAB page was visually checked for overlap ties in a single part. Recordings were unchanged and no listening assessment was performed. Baseline snapshots stayed under `/tmp/`.
Shared-preference and bass-first verification on October 6, 2026: `python3 modulus.py python -m unittest discover -s tests -p 'test_*.py'` passed 408 tests in 61.35 seconds, and the native test target passed all 124 cases. Both product builds passed asset checks; 139 native/ARM score and page files matched byte for byte. All 273 acoustic measures containing new attacks started with bass, including 69 delayed entrances. Tied carries did not trigger new attacks. Both solos, vocals, electric accompaniment rhythms, and electric foreground melody pitches/timing/TAB matched the saved baseline exactly. Some All of Me melody support chords changed; Autumn Leaves' complete electric melody document did not. All of Me's canonical electric accompaniment mean fret dropped from 7.26 to 4.13 and movement cost from 57 to 48; Autumn Leaves stayed at 2.95 and 46. Recordings were unchanged. These are score checks and page inspections, not a listening assessment.
With no concurrent build, isolated cold role generation measured 0.56–0.57 seconds for acoustic accompaniment, 0.35–0.37 for electric accompaniment, and 0.26–0.29 for electric melody; cache hits took 0.02–0.04 seconds and matched clean regeneration for both songs. The normal two-worker native asset stage took about 14 seconds (4 composing, 9 engraving, 1 other); ARM reused the caches in about 4 seconds. Application compilation is separate from these asset-stage timings.
Lower-position verification on October 6, 2026: all 400 Python tests and 124 native test cases passed. Native and ARM builds passed asset checks; all 139 score/page files matched byte for byte. Against the saved production output, acoustic rhythms and all other five role documents were identical for both songs. Mean fret across canonical active acoustic grips changed from 8.90 to 4.50 in Autumn Leaves and 5.19 to 4.29 in All of Me; movement costs rose from 144 to 190 and 128 to 140 respectively. In the requested six-chord passage, five suggested grips won; Em7b5 instead chose its valid fifth-bass shape on strings 6/4/3/2 at frets 6/5/7/5. Tests also verify that the complete suggested sequence beats the previous higher sequence with fixed neighbors, and that sufficiently costly travel can still retain a higher grip. These are score/selection facts, not an ear-based audition.
Isolated uncached acoustic role generation took 0.61–0.62 seconds per song, versus 0.56–0.58 seconds before this change; cache hits took about 0.048 seconds and matched clean output. With two composition and engraving workers, the native asset rebuild took approximately 11 seconds (4 composing, 6 engraving, 1 other); ARM reused caches and took approximately 6 seconds. These are individual local measurements, not a statistical benchmark. Supplied recordings remain byte-identical.
### Recording Playback and Page Timing
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.
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.
The existing recordings contain one bar of count-in, 168 main-form bars, three held bars and decay. `arrange/ending.py` now writes that existing tail as measures 169–171, without extending or transforming the recordings. Both accompaniment roles choose a valid closing voicing using their own outgoing position and hold it with ties; melody/solo roles are silent there and the vocal has three full rests. The chord is the final effective harmony after omitting optional turnarounds, including carried harmony. Missing/N.C. final harmony fails accompaniment generation explicitly. The original final bar is untouched.
Score timing includes `sustain_start`, an exact measure boundary, as well as the total written duration. Ending measures carry `ending_sustain`; Python and C++ validate a contiguous tail and consistent timing. Older scores without the new timing field retain their previous full-duration click behavior. Both toggles play the recorded count-in alone, then backing plus sample clicks until `sustain_start`. No clicks sound over the ending. Metronome-only supplies its own count-in and completes after the three silent held bars. Backing-only plays the complete file and decay. Both off enables manual navigation; hold exits and natural completion returns to the part menu. The final page remains visible through decay.
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 system and measure boundaries and Count-in/Playing/Sustain status; wall-clock timers never determine musical progress.
Page metadata comes from actual system pagination and is preserved and revalidated in engraving caches. `presentation.pages` partitions the music into normal two-system pages. `presentation.playback_pages` holds one view per system: that system and its successor, or a final single top system. Both reuse the same system breaks, horizontal geometry, and part-wide scale, fitted against every adjacent pair. Each descriptor carries system measure ranges, exact beat boundaries, and page-coordinate rectangles for its measure labels. Normal systems partition the score; overlapping playback views must match that canonical partition. Bundles missing playback metadata require regeneration.
Measure numbers are centered over their starting barlines (the staff’s left edge at system start), in a reserved lane above notation and below harmony/section labels. The display outlines the current label using the page texture’s letterbox transform; count-in, preparation, exit, and playback error clear it. Measure updates publish even when the line stays unchanged. Normal catalog page counts remain unchanged; the loader reads the separate playback count from bundle metadata. During automatic playback, navigation bounds, the header’s Line counter, and Pico’s page/pages fields refer to overlapping views. Manual browsing uses the normal sequence. 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 both normal and playback score pages without opening audio/display hardware.
### Authored Repertoire and Learned Solos
The production decision is explicit:
1. Look up the canonical source music and solo slot in the training manifest. A registered pair uses its authored solo **exactly**, with deterministic guitar placement and normal notation/export. This is library reuse, not a claim that a model reproduced it. An incompatible arrangement window, invalid tie, unsupported harmonic relationship or unplayable note fails rather than clipping, transposing or replacing it. Authored chromatic/color notes pass the same contextual harmonic validation as predictions; there is no authored exemption.
2. For an unregistered pair, load the frozen pooled model and call `solo.composer.compose → decoder.Decoder → placement → validate → notation/export`. This path does not read authored notes or train during an ordinary build. `generate_role(..., settings={'predict': True})` explicitly exercises prediction even for a registered song.
The small conditional model backs off from up to four recent joint pitch/rhythm events through source-melody features, current/future chord quality, metrical phase and global evidence. Pitch relationships are relative; there are no song-name conditions or per-song prediction tables. All examples contribute to the same distribution. Source intervals, sounding state and chord-relative source pitches influence prediction. Register comes from pooled training evidence, not an acoustic/electric octave formula.
Prediction uses constrained autoregressive sampling with a stable seed derived from canonical musical source content, slot/role and relevant configuration. The two slots draw from the same learned distribution; the seed permits different interpretations without teaching separate instrument styles. It scores valid joint continuations by their learned likelihood, then draws deterministically from the normalized probabilities. This avoids the mostly-silent maximum-likelihood mode observed during testing. At most four deterministic attempts are allowed if a sample cannot finish or contains no sounding notes; this is not a beam search or a claim of global phrase optimization.
Binary durations and complete triplet groups remain exact. Contiguous silence is one event, matching training tokenization; the decoder cannot repeatedly concatenate rest tokens to invent a long silent passage. Selected holds survive barline notation splits. Generic chromatic approaches/enclosures include their exact target as complete validated actions. Their event score is identical to emitting the constituent events separately, and equivalent actions are deduplicated before sampling. The report's negative log likelihood is diagnostic, not a ranking of complete solos or a model of listener preference.
Fixed scale maps no longer constrain prediction. Brief ordinary movement uses the source key and written chord tones; chord quality, alterations, omissions and slash-bass semantics remain authoritative. Long nonchord events require a supported color or resolution relationship. Approaches resolve to their specified pitch and octave; anticipation validates the upcoming harmony. Missing harmony does not authorize arbitrary pitches. Search failure is explicit and never invokes the old composer.
`solo.recognition` identifies these relationships automatically in a complete sounded line, using the versioned policies and `solo.expressions` validator. It checks actual preparation, pitch/register, duration, future harmony and resolution; annotations, filenames, authored status and source IDs grant no permission. A bounded deterministic search finds mutually consistent coverage of exposed events, without changing notes. Malformed explicit contracts remain errors. Tied notation fragments are merged before harmonic classification. Unsupported notes, missing harmony, expired resolutions and exhausted recognition budgets fail explicitly. Users do not need to supply explanations or tags.
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 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.
Train explicitly:
```sh
./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:
```sh
./modulus.py python -m generator.train --add-source /path/to/new-song.xml --add-solo /path/to/new-song-solo1.xml --slot 1
./modulus.py python -m generator.train --add-source data/songs/new-song.xml --add-solo /path/to/new-song-solo2.xml --slot 2
```
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 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`.
Solos are published inside the arranged score JSON and engraved pages, without separate solo MusicXML exports or comparison manifests. Exact-repertoire tests compare generated sounding pitches, onsets, releases, repeated attacks, measure timing and silence directly with the authored XMLs. Tied notation fragments are merged into sounding events for this check, and generated TAB is checked against the guitar constraints. Training and authored reuse remain the explicitly permitted dependencies on the supplied music.
Migration inventory:
| Kept or added | Consumer and reason |
| --- | --- |
| `data/songs/` (two songs) | Normal source importer and immutable arrangement analysis. |
| `data/training/` (four XMLs + manifest) | Explicit trainer and registered authored playback. All slots are pooled for learning. Annotation-only cleanup preserves the music and learned counts. |
| `data/models/solo.json` | Frozen prediction model; reproducible by `generator.train`. |
| `data/composition/{roles,harmony,lead_ins}.json` | Physical/difficulty settings and generic harmonic validity. Fixed-map permissions removed. |
| `data/composition/accompaniment.json` | Version 3: independently authored per-instrument duration families, layer ranges, bounded retrieval and generic chord-degree/geometry families. Consumers: `comping_rhythm.py`, `comping.py`, `chord_families.py` and `guitar.py`. Migrates the v2 shared-duration schema; no parallel legacy data or reference-derived material. |
| `generator/harmonic_time.py` | Exact chord/key spans retained from the removed scale subsystem. |
| Solo event, expression, placement, notation and export helpers | Retained physical/timing safeguards shared by authored and predicted music. |
Removed: `data/{scales,sequences,rhythms}`, old `composition/solo.json` and mixed `rhythms.json`, catalog import/cache code, old solo construction/phrase/selection/scoring/sequence modules, unused accompaniment catalog planner, three evaluation-song excerpts, and tests specific to those retired algorithms. Shared importer, timing, guitar, playback, engraving, arrangement and independence tests remain, with new pooled-learning, registration and exact-repertoire tests. No `docs/` or `audition/` directory is required. Temporary comparisons and diagnostics belong under `/tmp/`; old comparison pages, diagnostic reports, formatter-debug scripts and the copied verification bundle have been removed from `build/`. Product builds, caches and toolchains remain. Desktop and ARM builds resolve Sonus through Conan; separate local Sonus installations and temporary CMake dependency overrides are unnecessary. Refresh both with `python3 modulus.py configure --update --target x86_64_gcc12_debug --target arm_64_gcc12_release` when dependency packages change. The obsolete playback-test assets are superseded by the current performance-test fixtures.
Verification on October 5, 2026: `unittest discover -s tests -p 'test_*.py'` passed 361 tests in 78.62 seconds. These include all four approved solos without annotations or authored-role privileges, source-form pickup context, invalid resolutions, passing endpoints, anticipated colors, tied-event partition invariance and active prediction construction. The normal six-part asset build passed `--check`, including playback, engraving, TAB and catalog validation. With two composition workers, newly generated solo results, cached accompaniment/melody and a warm engraving cache, it took 5.10 seconds; performance-part generation occupied 2.37 seconds. Each registered solo took 0.36–0.40 seconds. The subsequent `cmake --build build/build_x86_debug --target vamp_generated_assets` also passed. These measurements describe registered playback, not novel prediction; the disposable diagnostic reports have since been removed.
Voicing-only verification on October 6, 2026, before the rhythm update: the suite passed 363 tests in 120.66 seconds, including 11 new voicing tests. Exhaustive small cases verify that the movement selector beats greedy selection and includes repeat/neighbor boundaries. That change retained the prior accompaniment onset/duration/rest sequences; source melody, vocal and both solo role documents were unchanged. Its six-score production rebuild passed `--check` in 11.54 seconds with two composition and two engraving workers: 2.95 seconds generating parts and 8.19 seconds engraving, with two unchanged score cache hits. This checks timing, harmonic/physical constraints and rendering, not a claim of ergonomic comfort from listening.
Rhythm verification on October 6, 2026: `unittest discover -s tests -p 'test_*.py'` passed all 375 tests in 83.57 seconds. Coverage includes attack ranges, bass downbeats, mixed durations, harmonic-change budgets, overlapping voices, tied joins, 3/4 and 5/4, melody-conditioned rhythm costs, tempo invariance, canonical reuse, and serial/parallel role identity. A synthetic learned-model test also confirms that changing original melody rhythm or upcoming harmony changes solo duration probabilities with a fixed seed. Both songs' solo, electric melody and vocal role documents remain exactly equal to the saved pre-rhythm baseline; no model retraining was performed.
The ordinary six-score rebuild with `--check --composition-jobs 2 --jobs 2` passed in 13.39 seconds: 4.44 seconds generating parts and 8.48 seconds engraving. All role composition results were newly generated; the warm engraving namespace reused two unchanged vocal scores. The subsequent `cmake --build build/build_x86_debug --target vamp_generated_assets` passed with all role/score caches reused. Actual selected acoustic upper rhythms use one or two attacks in these songs; three-attack alternatives are available and tested but are not forced. All of Me electric has three zero-attack source/harmony contexts; Autumn Leaves electric uses one or two attacks. Neither song required a density exception. Reuse audits checked 103/62 acoustic/electric repeated bars for Autumn Leaves and 102/62 for All of Me, with only source-defined pickups and final-harmony exceptions. Sample acoustic notation/TAB pages were visually inspected. Supplied audio recordings remain unchanged; no ear-based audition of the new accompaniment was performed. Disposable logs, profiles and comparisons are under `/tmp/`, outside product assets.
Chord-family and unified-acoustic verification on October 6, 2026: `unittest discover -s tests -p 'test_*.py'` passed 386 tests in 81.67 seconds. New cases cover 35 chord qualities in three keys for both instruments, discarded bass variants and explicit unsupported combinations, the six suggested acoustic grips, highest feasible electric melody strings, scoped policy dependencies, and one-voice tie preservation including repeated attacks and cross-bar ties. Quarter/eighth acoustic attack durations and available consecutive-eighth patterns are tested. The acoustic sounding events in both complete arrangements contain only quarter/eighth durations, even where notation splits them into ties.
The full checked rebuild with two composition and two engraving workers took 13.76 seconds: 4.68 seconds for all parts and 8.63 seconds engraving. All role caches missed after the code change; two unchanged vocal engravings were reused. Serial composition-only generation into a disposable directory also passed in 5.58 seconds. The subsequent CMake asset build passed and reproduced every role document from warm caches. Both registered solos and vocal documents are exactly unchanged from the saved prechange baseline; the electric melody's original pitches/onsets/durations also match. Reuse audits retain the same 103/62 and 102/62 repeated-bar counts. Acoustic upper rhythms now select one to three attacks in Autumn Leaves and one or two in All of Me. Acoustic and electric melody pages were visually inspected; this is notation/constraint verification, not an ear-based audition. Only product artifacts and normal caches were written under `build/`; diagnostic logs and baseline comparisons remain under `/tmp/`.
Four-fret fallback verification on October 6, 2026: `unittest discover -s tests -p 'test_*.py'` passed 392 tests in 85.65 seconds. This includes primary-tier precedence over family, arbitrary movement savings and melody-string height; a complete held-note path that needs an earlier four-fret option; tied-fragment cost invariance; and anchor release checks. The first full run exposed one obsolete C5 rejection expectation, now replaced by a test verifying its legal four-fret voicings contain only C/G. Coverage tests check 35 qualities in 12 keys for each instrument: 418/420 acoustic contexts and 420/420 electric contexts have a legal grip. Acoustic Bbm9/Bm9 remain explicitly unsupported; this is measured coverage, not a guarantee for every written extension or slash chord.
The checked six-score rebuild with `--composition-jobs 2 --jobs 2` passed in 9.21 seconds (4.58 seconds composing all 12 roles, 3.90 seconds processing six cached engravings). All composition caches missed after the code change. Every resulting role document and accompaniment rhythm report exactly matches the saved baseline: current production chords already have primary grips, so none needed fallback substitutions. New sidecar span diagnostics identify the tiers and available candidates. The subsequent `cmake --build build/build_x86_debug --target vamp_generated_assets` passed with all role caches reused and identical per-role musical hashes. Acoustic and electric score/TAB pages were visually checked; supplied recordings are unchanged. Formatter and diff checks passed. Temporary verification files remain under `/tmp/`.
Earlier exact-repertoire verification confirmed all four authored solos: 137/141 attacks for All of Me and 121/115 for Autumn Leaves, with no pitch, onset, release or rest differences. All four also passed contextual harmony validation. Removing 69 explanatory annotations left the complete event streams and learned musical count tables identical to the pre-edit baseline; only model provenance hashes changed. This verifies authored reuse rather than learned reproduction. These checks now compare generated score data directly with the training inputs instead of producing standalone XML exports. No ear-based audition or claim of transfer quality was made.
Forced prediction, without reading the training XMLs, completed all four source/role combinations with valid harmony. These were composition-only runs on the training-source songs, not held-out transfer tests. Their notes intentionally differ from registered playback and were not published as replacements. The expanded treatment candidates initially took 99.03–120.33 seconds per role with one worker and no concrete-result cache. Removing repeated validation of identical resolution endpoints reduced the isolated All of Me acoustic check from 104.30 to 60.13 seconds, with an identical complete output hash. Six additional contexts retained all 32,566 ordered action candidates exactly. These expanded prediction choices remain slower than the earlier 21.81–27.16-second decoder; registered playback is unaffected. Training the pooled 2,860-context model took 0.57 seconds. Prediction remains the slower path, chiefly candidate construction and event-likelihood evaluation; further profiling is needed before expanding its choices again.
### Engraving Boundary
`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. 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.
`notation.py` interprets staff state, exact pitch positions and shared accidental state; `notes.py` and `attributes.py` construct symbols; `spans.py` handles beams, ties, tuplets, octave shifts and lyric continuations; `layout.py` handles spacing and pagination. `fonts.py` reads bundled OTF outlines and SMuFL metadata, caching metrics and outlines for one generation run. `raster.py` uses FontTools, aggdraw and Pillow only as low-level font and drawing libraries. Modulus owns the pinned optional `assets` dependency group. Configure prepares its build-only environment; CMake and `./modulus.py python` use the exported runner to import shared build utilities without shadowing the Modulus package. Standard glyph names and their upstream notice are vendored alongside the fonts; no system music fonts are consulted.
Voices retain separate stems and collision offsets; chord tones retain their own written durations and per-tone ties. Pitched and percussion material use their own staff positions. Unknown exact microtonal alterations use a rational semitone annotation rather than a rounded accidental; an unavailable explicit SMuFL glyph is an error. The broader model/engraver supports this notation, while automatic guitar generation enforces the source contracts above.
Generated guitar tones carry `tablature: {string, fret}`; their staff is marked `tablature: true`. `engraving/tablature.py` draws a six-line TAB view below standard notation, sharing onset columns, barlines, and page breaks. Both views belong to one system and never duplicate the semantic timeline or playback events. Standard notation carries rhythm, rests, and tuplets; TAB carries positions, solo slashes, and tied continuations without rest glyphs. Strings and frets are validated against sounding pitch, and the layout audit checks each visible TAB numeral's position and alignment.
Normal pages use two music systems; only an odd final system occupies a row alone. Playback pages use overlapping adjacent pairs and always finish with the last system alone on top. A compact title/composer/part header reserves the same height on every page, wrapping credits if necessary. Pagination fits the two systems using their individual heights and distributes extra whitespace around them while retaining minimum system and header/footer clearances. One uniform scale applies throughout the entire part, reducing the ordinary notation size only when required by the tallest adjacent pair, including transition pages. Notation, TAB, text, and anchors scale together without changing musical content. Guitar layout starts with 10-pixel notation spacing, 10.5-pixel TAB spacing, 12-pixel fret labels, and a 14-pixel minimum gap between systems. The unscaled TAB spacing accommodates Petaluma numerals up to 9.75 pixels tall. Tempo markings consume neither ink nor spacing; their semantic data is preserved. Existing `.pages/*.png` preview files are the exact packaged page textures.
Line breaks occur only at measure boundaries. Each line fits as many complete measures as readable spacing allows; it never fills remaining space with a partial measure. A complete measure exceeding the available width or height fails with source and measure context instead of being split or squeezed to fit. Authored ties, beams, tuplets, and lyric spans can continue across whole-measure line breaks. Column measurement and fit trials reuse a score index and bounded column cache. Page placement then re-spaces columns for the final height-fitting scale, keeping every notation/TAB staff at the same physical left and right margins without stretching glyphs. Non-final lines are justified; a short final line retains natural note spacing while staff lines extend to the same right margin. Layout audits enforce these margins as well as musical coverage.
`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, 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 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.
### Printable Parts
`generator.printing.generate_tree` runs after screen engraving inside the normal asset staging transaction. It reads each completed song/part's musical document, not its screen PNGs or overlapping playback views, and writes `print/<score-stem>.pdf`. Every arranged measure and the instrumental ending appear once in order. Acoustic/electric parts retain standard notation and TAB; vocal parts retain lyrics and rests. No music, runtime bundle fields or catalog rows are changed. Both CMake installation and runtime image copying exclude the `print` directory.
Print layout uses portrait US Letter (612 × 792 PDF points), a minimum half-inch clear margin, and fixed five-point guitar/six-point vocal staff spacing. It reserves the actual overhang of centered measure numbers inside the margin. Complete systems fill the available page height with consistent notation size; the final page keeps natural vertical spacing. Headers identify the tune, composer and instrument; printed page numbers are independent of device pagination. The existing musical/fragment/TAB audits remain active, with variable systems per page allowed only by the print caller. Screen audits still require two systems except the odd final system.
The shared primitive renderer accepts explicit colors and output scale while retaining unchanged screen defaults. Print draws black on white directly at 2550 × 3300 pixels (300 DPI), then writes grayscale image pages through Pillow's PDF writer at quality 95. This is a raster PDF, not searchable/vector notation. One page is rendered and appended at a time to bound image memory. The pinned Pillow parser checks page count, Letter media boxes and image resolution/color; tests decode the embedded JPEGs and verify clear margins. No new Python package is required: Modulus's existing `assets` group owns Pillow, fonttools and aggdraw. Timestamps and output-path metadata are omitted for byte-reproducible PDFs.
With `--engraving-cache`, print results use a separate `print/<print-renderer>/<score-key>/` namespace. Keys cover score content, print settings, fonts, renderer source and dependency versions. Cached PDFs have checked hashes and page metadata; missing or corrupt entries regenerate. Native and ARM builds share the cache locally. This print namespace is separate from the screen-only CI cache snapshot. Output PDFs are validated before the build publishes; failure preserves the previous complete asset directory. Stale PDFs disappear when source parts disappear. Temporary print files live only in build/cache staging.
Print rollout verification: `python3 modulus.py python -m unittest discover -s tests -p 'test_*.py'` passed 458 tests; the seven `tests.test_printing` tests were rerun after the final footer-margin adjustment. `python3 modulus.py build --target x86_64_gcc12_debug --target arm_64_gcc12_release` passed with byte-identical PDFs on both targets. All of Me has 10/8/5 acoustic/electric/vocal pages and Autumn Leaves has 9/7/4, totaling 43. All pages were checked for Letter size, 300 DPI grayscale images and pure-white half-inch margins; representative opening, solo, lyric and ending pages were visually inspected. All 394 preexisting score JSON/screen PNG files stayed byte-identical. PDF generation took 9.7 seconds uncached and 0.1 seconds cached on this development machine; this is the print stage only, not a clean whole-product build benchmark. Physical printing was not tested.
### 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, 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 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.
Diagnostics identify the file, part, measure, and unsupported element or invalid relationship. Examples include `part P1, measure 4: unsupported musical element <slur>`, `tie stop has no contiguous matching start`, and `optional-chord marker must identify exactly one harmony`.
The six compact source files were replaced, not retained as a second authoring format. Installation replaces the entire catalog, rather than retaining or migrating rows from older releases. Legacy data must be authored as supported MusicXML and regenerated; the runtime never upgrades old databases.
References: [MusicXML durations](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/duration/), [tuplets](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/tuplet/), [degree alterations](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/degree-alter/), and [SMuFL accidentals](https://smufl.formats.music/latest/tables/standard-accidentals-12-edo.html).
Generator checks compare the generated performance summary against `tests/fixtures/performance_summary.json`. The summary stores compatibility invariants such as section layout, chord-label placement, absence of uncomposed solo slashes, and vocal timing without committing the generated score files themselves. Refresh that file only when an intentional arrangement-logic change should become the new baseline.
## Pico Runtime ## Pico Runtime
The Pico firmware uses the Pico SDK C++ stack and the `pico2_w` board definition. It initializes the ST7789T3 display over SPI, reads the CST816D touch controller over I2C, joins the Zero AP, polls `/api/remote/display`, and sends HTTP POST commands for gestures. The 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 stays blank until a performance or practice score is active, then it shows the active title, selected part or active practice key, and page count. Touch controls are tap for next, double tap for previous, and tap-and-hold for select. The 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.
The Pico text renderer uses an antialiased bitmap atlas generated from `package/assets/fonts/petaluma/PetalumaScript.otf`; regenerate it with `python3 tools/generate_pico_font.py` after font or atlas-size changes. The generated atlas includes ASCII kerning pairs so display text such as `VAMP` uses the same spacing as the source font.
`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.
Score generation renders titles as 296x64 four-bit coverage images under `pico/titles/<sha256>.mask`. Each file is 9,480 bytes: an eight-byte `VPTI` header with little-endian width/height, followed by packed coverage. Identical image bytes share an asset without merging score identities. The generated `presentation.title_image` identifier is also stored in catalog metadata; no separate lookup manifest is required. These assets are generated and validated before staged output is published. Remote status includes `title_image=<sha256>`; `POST /api/remote/title` with `{"id":"<sha256>"}` serves validated bytes only for catalog-registered titles. Pico fetches a changed title, validates its fixed format and bounds, rejects stale responses, and retains the selected image. Part labels and page counts use fixed sprites without reshaping or refetching title text. Commands take priority over image requests, and failed title requests display `UNAVAILABLE` until a retry succeeds.
Pico performs coverage blending and sprite placement only: no OTF parsing, font shaping, glyph rasterization, or general text renderer runs in firmware. Missing glyphs and invalid source text are build errors. Font or generator changes rebuild both static firmware sprites and title artwork. Direct generation uses `./modulus.py python -m generator.pico_artwork --static-output <path>` for firmware sprites and `./modulus.py python -m generator.pico_artwork --assets <generated_assets>` for title assets. Python, Pillow/Raqm, and FontTools remain build-only dependencies.
The root `CMakeLists.txt` is also the Pico firmware entrypoint when configured with `-DVAMP_PICO_FIRMWARE_PROJECT=ON`. That mode returns before the Zero/Linux targets are declared, so the Pico build can use the embedded ARM toolchain cleanly. The root `CMakeLists.txt` is also the Pico firmware entrypoint when configured with `-DVAMP_PICO_FIRMWARE_PROJECT=ON`. That mode returns before the Zero/Linux targets are declared, so the Pico build can use the embedded ARM toolchain cleanly.
@@ -69,25 +389,33 @@ Modulus packages the CMake install stage. Product Vamp uses `install_prefix` `.`
The ARM64 package target builds: The ARM64 package target builds:
- `zero/product-vamp-zero-linux-arm64.docker.tar` - `zero/product_vamp_zero_linux_arm64.docker.tar`
- `pico/product-vamp-pico2w.uf2` - `pico/product_vamp_pico2w.uf2`
- `manifest.json` - `manifest.json`
Building the full package requires Docker/buildx, the ARM embedded GCC toolchain, and the Pico SDK. CI passes `PICO_SDK_PATH` directly. The generated Modulus Docker image also carries the pinned Pico SDK at `/opt/pico-sdk` for local package builds. The Zero Docker runtime targets Debian trixie to match current Raspberry Pi OS and the ARM64 dependency binaries, and includes `util-linux` for the `setsid --wait --ctty` launch path. Building the full package requires Docker/buildx, the ARM embedded GCC toolchain, and the Pico SDK. CI passes `PICO_SDK_PATH` directly. The generated Modulus Docker image also carries the pinned Pico SDK at `/opt/pico-sdk` for local package builds. The Zero Docker runtime targets Debian trixie to match current Raspberry Pi OS and the ARM64 dependency binaries, and includes `util-linux` for the `setsid --wait --ctty` launch path.
Generated score assets are created during CMake build/package and copied into the Zero image context. Static font assets remain tracked under `package/assets/fonts`. Generated score assets are created during CMake build/package and copied into the Zero image context. Static font assets remain tracked under `package/assets/fonts`.
CI builds the generic artifact with `python3 modulus.py package --version latest`, then `package/scripts/publish_latest_generic_package.py` replaces the Gitea generic package version `latest` and deletes older Product Vamp generic package versions. `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.
Every run of `zero/install_zero.sh` is a clean installation. It stages configuration/scripts, verifies the manifest's Docker tag against the incoming archive's image digest, and probes that image before stopping Product Vamp. It then clears the owned `/etc/product_vamp`, `/var/lib/product_vamp`, `/run/product_vamp`, and obsolete host `/usr/share/product_vamp` trees, including old SQLite sidecars. The installed container uses assets from its image and no longer mounts a writable state database. Startup reads the verified image ID from `/etc/product_vamp/image_id`, rather than depending on a mutable Docker tag. The installer also removes the fixed legacy hyphenated Product Vamp paths, units, containers, and network profile after preflight, while rejecting symlinked destinations. It replaces its scripts and systemd units, removes their old overrides, recreates only the `product_vamp_control` NetworkManager profile, and requires a successful service health check before announcing success.
Configuration, AP credentials, and all prior 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 `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
The Pico firmware does not link Zero-side Linux application libraries such as Ligo, Luma, or Noto. Those remain Zero-side dependencies: Ligo serves the HTTP API, Luma renders the Zero display, and Noto owns SQLite persistence. Cross-device code should stay in `vamp_protocol` and `vamp_style`: plain C++17 with no OS, allocator-heavy, or Zero-only dependencies. The Pico firmware does not link Zero-side Linux application libraries such as Ligo, Luma, or Noto. Those remain Zero-side dependencies: Ligo serves the HTTP API, Luma renders the Zero display, and Noto owns SQLite persistence. Cross-device code should stay in `vamp_protocol` and `vamp_style`: plain C++17 with no OS, allocator-heavy, or Zero-only dependencies.
Luma owns the application shell and UI control tree. The score renderer uses Luma primitives for layout, staff lines, and simple vector strokes, and uses a small score-local Raylib font cache for Petaluma SMuFL glyphs until Luma grows first-class codepoint loading. Luma owns the application shell, UI control tree, texture loading, and UI-thread texture lifetime. Product Vamp's page cache uses the uncached loading API; there is no score-specific Raylib font cache or handwritten notation renderer.
## LSP ## LSP
`.clangd` points native Zero and shared protocol code at `build/build_x86_debug`, and Pico code at `build/build_pico_debug`. Refresh both compile databases with: `tooling.editor.clangd` in `modulus.json` generates the ignored `.clangd`, pointing native Zero and shared protocol code at `build/build_x86_debug`, and Pico code at `build/build_pico_debug`. Refresh both compile databases with:
```sh ```sh
python3 modulus.py lsp python3 modulus.py lsp
+12
View File
@@ -0,0 +1,12 @@
include_guard(GLOBAL)
if(NOT MODULUS_PYTHON_RUNNER OR NOT MODULUS_PYTHON_ROOT)
message(FATAL_ERROR "Modulus build Python is not configured. Run ./modulus.py configure first.")
endif()
find_package(Python3 COMPONENTS Interpreter REQUIRED)
set(VAMP_PYTHON_COMMAND ${Python3_EXECUTABLE} -u ${MODULUS_PYTHON_RUNNER} ${MODULUS_PYTHON_ROOT})
file(GLOB VAMP_SHARED_PYTHON_INPUTS CONFIGURE_DEPENDS
${MODULUS_PYTHON_ROOT}/modulus/*.py
${MODULUS_PYTHON_ROOT}/modulus/*_requirements.txt
)
list(APPEND VAMP_SHARED_PYTHON_INPUTS ${MODULUS_PYTHON_REQUIREMENTS})
+15 -9
View File
@@ -29,13 +29,15 @@ endif()
include(${PICO_SDK_PATH}/pico_sdk_init.cmake) include(${PICO_SDK_PATH}/pico_sdk_init.cmake)
project(product-vamp-pico VERSION ${VAMP_PROJECT_VERSION} LANGUAGES C CXX ASM) project(product_vamp_pico LANGUAGES C CXX ASM)
set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
pico_sdk_init() pico_sdk_init()
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/pico_artwork.cmake)
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/theme.cmake)
set(VAMP_MODULUS_INCLUDE_DIR "" CACHE PATH "Path to the Modulus public include directory") set(VAMP_MODULUS_INCLUDE_DIR "" CACHE PATH "Path to the Modulus public include directory")
set(VAMP_RESOLVED_MODULUS_INCLUDE_DIR "${VAMP_MODULUS_INCLUDE_DIR}") set(VAMP_RESOLVED_MODULUS_INCLUDE_DIR "${VAMP_MODULUS_INCLUDE_DIR}")
@@ -73,34 +75,38 @@ if(NOT EXISTS "${VAMP_RESOLVED_MODULUS_INCLUDE_DIR}/modulus/types.hpp")
message(FATAL_ERROR "VAMP_MODULUS_INCLUDE_DIR must contain modulus/types.hpp: ${VAMP_RESOLVED_MODULUS_INCLUDE_DIR}") message(FATAL_ERROR "VAMP_MODULUS_INCLUDE_DIR must contain modulus/types.hpp: ${VAMP_RESOLVED_MODULUS_INCLUDE_DIR}")
endif() endif()
add_executable(product-vamp-pico2w add_executable(product_vamp_pico2w
src/vamp_style.cpp src/vamp_style.cpp
src/vamp_style/font.cpp
src/vamp_pico/display.cpp src/vamp_pico/display.cpp
src/vamp_pico/http_client.cpp src/vamp_pico/http_client.cpp
src/vamp_pico/input.cpp
src/vamp_pico/main.cpp src/vamp_pico/main.cpp
src/vamp_pico/remote_display.cpp src/vamp_pico/remote_display.cpp
src/vamp_pico/screen.cpp
src/vamp_pico/touch.cpp src/vamp_pico/touch.cpp
src/vamp_protocol/page_control.cpp src/vamp_protocol/page_control.cpp
src/vamp_protocol/gesture.cpp
) )
target_include_directories(product-vamp-pico2w PRIVATE vamp_add_pico_artwork(product_vamp_pico2w)
vamp_add_theme(product_vamp_pico2w)
target_include_directories(product_vamp_pico2w PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/include
${VAMP_RESOLVED_MODULUS_INCLUDE_DIR} ${VAMP_RESOLVED_MODULUS_INCLUDE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/include/vamp_pico/capi ${CMAKE_CURRENT_SOURCE_DIR}/include/vamp_pico/capi
) )
target_compile_definitions(product-vamp-pico2w PRIVATE target_compile_definitions(product_vamp_pico2w PRIVATE
VAMP_PICO_WIFI_SSID="${VAMP_PICO_WIFI_SSID}" VAMP_PICO_WIFI_SSID="${VAMP_PICO_WIFI_SSID}"
VAMP_PICO_WIFI_PASSPHRASE="${VAMP_PICO_WIFI_PASSPHRASE}" VAMP_PICO_WIFI_PASSPHRASE="${VAMP_PICO_WIFI_PASSPHRASE}"
VAMP_ZERO_HOST="${VAMP_ZERO_HOST}" VAMP_ZERO_HOST="${VAMP_ZERO_HOST}"
VAMP_ZERO_PORT=${VAMP_ZERO_PORT} VAMP_ZERO_PORT=${VAMP_ZERO_PORT}
) )
target_link_libraries(product-vamp-pico2w PRIVATE target_link_libraries(product_vamp_pico2w PRIVATE
pico_stdlib pico_stdlib
hardware_i2c hardware_i2c
hardware_spi hardware_spi
pico_cyw43_arch_lwip_threadsafe_background pico_cyw43_arch_lwip_threadsafe_background
) )
pico_enable_stdio_usb(product-vamp-pico2w 1) pico_enable_stdio_usb(product_vamp_pico2w 1)
pico_enable_stdio_uart(product-vamp-pico2w 0) pico_enable_stdio_uart(product_vamp_pico2w 0)
pico_add_extra_outputs(product-vamp-pico2w) pico_add_extra_outputs(product_vamp_pico2w)
+32
View File
@@ -0,0 +1,32 @@
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/build_python.cmake)
execute_process(
COMMAND ${Python3_EXECUTABLE} -c "import PIL.features; import fontTools.ttLib; assert PIL.features.check_feature('raqm')"
RESULT_VARIABLE VAMP_PILLOW_STATUS
ERROR_QUIET
)
if(NOT VAMP_PILLOW_STATUS EQUAL 0)
message(FATAL_ERROR "Pico artwork requires Pillow with Raqm and fontTools. Install libfribidi0 and configure the Modulus assets Python group.")
endif()
# Zero uses packaged font files; only the Pico embeds this fixed sprite atlas.
function(vamp_add_pico_artwork target)
set(VAMP_PICO_ARTWORK_BINARY ${CMAKE_CURRENT_BINARY_DIR}/generated/pico/static.p4)
set(VAMP_PICO_ARTWORK_SOURCE ${CMAKE_CURRENT_BINARY_DIR}/generated/pico/artwork.S)
add_custom_command(
OUTPUT ${VAMP_PICO_ARTWORK_BINARY}
COMMAND ${VAMP_PYTHON_COMMAND} -m generator.pico_artwork --static-output ${VAMP_PICO_ARTWORK_BINARY}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/generator/pico_artwork.py
${VAMP_SHARED_PYTHON_INPUTS}
${CMAKE_CURRENT_SOURCE_DIR}/generator/palette.json
${CMAKE_CURRENT_SOURCE_DIR}/generator/theme.py
${CMAKE_CURRENT_SOURCE_DIR}/cmake/build_python.cmake
${CMAKE_CURRENT_SOURCE_DIR}/cmake/theme.cmake
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/petaluma/PetalumaScript.otf
VERBATIM
)
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_pico/artwork.S.in ${VAMP_PICO_ARTWORK_SOURCE} @ONLY)
set_source_files_properties(${VAMP_PICO_ARTWORK_SOURCE} PROPERTIES OBJECT_DEPENDS ${VAMP_PICO_ARTWORK_BINARY})
target_sources(${target} PRIVATE ${VAMP_PICO_ARTWORK_SOURCE})
endfunction()
+4
View File
@@ -0,0 +1,4 @@
if(NOT EXISTS "${SOURCE}/catalog.db")
message(FATAL_ERROR "Missing generated catalog: ${SOURCE}/catalog.db")
endif()
file(COPY "${SOURCE}/" DESTINATION "${DESTINATION}" PATTERN "*.json" EXCLUDE PATTERN "analysis" EXCLUDE PATTERN "print" EXCLUDE)
+14
View File
@@ -0,0 +1,14 @@
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/build_python.cmake)
function(vamp_add_theme target)
set(header ${CMAKE_CURRENT_BINARY_DIR}/generated/vamp_style/palette.hpp)
add_custom_command(
OUTPUT ${header}
COMMAND ${VAMP_PYTHON_COMMAND} -m generator.theme --header ${header}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
DEPENDS generator/theme.py generator/palette.json ${VAMP_SHARED_PYTHON_INPUTS}
VERBATIM
)
target_sources(${target} PRIVATE ${header})
target_include_directories(${target} PUBLIC ${CMAKE_CURRENT_BINARY_DIR}/generated)
endfunction()
+1 -2
View File
@@ -1,11 +1,10 @@
option(VAMP_PICO_FIRMWARE_PROJECT "Configure this CMake invocation as the Pi Pico 2 W firmware project" OFF) option(VAMP_PICO_FIRMWARE_PROJECT "Configure this CMake invocation as the Pi Pico 2 W firmware project" OFF)
option(VAMP_BUILD_PICO_FIRMWARE "Build the Pi Pico 2 W UF2 firmware artifact" OFF) option(VAMP_BUILD_PICO_FIRMWARE "Build the Pi Pico 2 W UF2 firmware artifact" OFF)
option(VAMP_BUILD_ZERO_IMAGE "Build the Pi Zero 2 W Docker image archive artifact" OFF) option(VAMP_BUILD_ZERO_IMAGE "Build the Pi Zero 2 W Docker image archive artifact" OFF)
option(BUILD_TESTING "Build Product Vamp tests" OFF)
set(VAMP_PICO_SDK_VERSION "2.3.0" CACHE STRING "Raspberry Pi Pico SDK git tag used when fetching the SDK") set(VAMP_PICO_SDK_VERSION "2.3.0" CACHE STRING "Raspberry Pi Pico SDK git tag used when fetching the SDK")
set(VAMP_PICO_WIFI_SSID "Vamp Control" CACHE STRING "Default Pico Wi-Fi SSID") set(VAMP_PICO_WIFI_SSID "Vamp Control" CACHE STRING "Default Pico Wi-Fi SSID")
set(VAMP_PICO_WIFI_PASSPHRASE "vamp-control" CACHE STRING "Default Pico Wi-Fi passphrase") set(VAMP_PICO_WIFI_PASSPHRASE "vamp-control" CACHE STRING "Default Pico Wi-Fi passphrase")
set(VAMP_ZERO_HOST "192.168.42.1" CACHE STRING "Default Zero control host for Pico firmware") set(VAMP_ZERO_HOST "192.168.42.1" CACHE STRING "Default Zero control host for Pico firmware")
set(VAMP_ZERO_PORT "8080" CACHE STRING "Default Zero control port for Pico firmware") set(VAMP_ZERO_PORT "8080" CACHE STRING "Default Zero control port for Pico firmware")
set(VAMP_ZERO_IMAGE_TAG "product-vamp-zero:dev" CACHE STRING "Docker image tag embedded in package metadata") set(VAMP_ZERO_IMAGE_TAG "product_vamp_zero:dev" CACHE STRING "Docker image tag embedded in package metadata")
+130 -58
View File
@@ -1,16 +1,41 @@
include(GNUInstallDirs) include(GNUInstallDirs)
option(BUILD_TESTING "Build Product Vamp tests" OFF)
include(CTest) include(CTest)
set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_EXTENSIONS OFF)
find_package(modulus CONFIG REQUIRED) find_package(modulus CONFIG REQUIRED COMPONENTS boost_json openssl raylib)
find_package(Threads REQUIRED) find_package(Threads REQUIRED)
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/build_python.cmake)
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/theme.cmake)
find_package(ligo CONFIG REQUIRED) find_package(ligo CONFIG REQUIRED)
find_package(noto CONFIG REQUIRED) find_package(noto CONFIG REQUIRED)
find_package(luma CONFIG REQUIRED) find_package(luma CONFIG REQUIRED)
find_package(Python3 COMPONENTS Interpreter REQUIRED) find_package(sonus CONFIG REQUIRED)
include(CheckCXXSourceCompiles)
include(CMakePushCheckState)
cmake_push_check_state(RESET)
set(CMAKE_TRY_COMPILE_CONFIGURATION "${CMAKE_BUILD_TYPE}")
set(CMAKE_REQUIRED_LIBRARIES sonus::sonus luma::luma)
unset(VAMP_DEPENDENCY_APIS_AVAILABLE CACHE)
check_cxx_source_compiles("
#include <sonus.hpp>
#include <luma.hpp>
#include <utility>
static_assert(requires(sonus::engine& engine, sonus::prepared_transport transport) {
engine.play_transport(std::move(transport));
});
static_assert(requires(luma::application& application, std::string path) {
application.load_texture_uncached(path);
});
int main() { return 0; }
" VAMP_DEPENDENCY_APIS_AVAILABLE)
cmake_pop_check_state()
if(NOT VAMP_DEPENDENCY_APIS_AVAILABLE)
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()
execute_process( execute_process(
COMMAND git rev-parse --short=12 HEAD COMMAND git rev-parse --short=12 HEAD
@@ -25,58 +50,67 @@ endif()
set(VAMP_STYLE_SOURCES set(VAMP_STYLE_SOURCES
src/vamp_style.cpp src/vamp_style.cpp
src/vamp_style/font.cpp
) )
add_library(vamp-style STATIC ${VAMP_STYLE_SOURCES}) add_library(vamp_style STATIC ${VAMP_STYLE_SOURCES})
target_include_directories(vamp-style PUBLIC vamp_add_theme(vamp_style)
target_include_directories(vamp_style PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/include
) )
target_compile_features(vamp-style PUBLIC cxx_std_17) target_compile_features(vamp_style PUBLIC cxx_std_17)
target_link_libraries(vamp-style PUBLIC target_link_libraries(vamp_style PUBLIC
modulus::modulus modulus::modulus
) )
set(VAMP_PROTOCOL_SOURCES set(VAMP_PROTOCOL_SOURCES
src/vamp_protocol/gesture.cpp
src/vamp_protocol/page_control.cpp src/vamp_protocol/page_control.cpp
) )
add_library(vamp-protocol STATIC ${VAMP_PROTOCOL_SOURCES}) add_library(vamp_protocol STATIC ${VAMP_PROTOCOL_SOURCES})
target_include_directories(vamp-protocol PUBLIC target_include_directories(vamp_protocol PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/include
) )
target_compile_features(vamp-protocol PUBLIC cxx_std_17) target_compile_features(vamp_protocol PUBLIC cxx_std_17)
target_link_libraries(vamp-protocol PUBLIC target_link_libraries(vamp_protocol PUBLIC
modulus::modulus modulus::modulus
) )
set(VAMP_CORE_SOURCES set(VAMP_CORE_SOURCES
src/vamp_zero/app.cpp src/vamp_zero/app.cpp
src/vamp_zero/audio.cpp
src/vamp_zero/color.cpp src/vamp_zero/color.cpp
src/vamp_zero/config.cpp src/vamp_zero/config.cpp
src/vamp_zero/database.cpp src/vamp_zero/database.cpp
src/vamp_zero/errors.cpp src/vamp_zero/errors.cpp
src/vamp_zero/health.cpp
src/vamp_zero/library.cpp src/vamp_zero/library.cpp
src/vamp_zero/menu.cpp
src/vamp_zero/menu_view.cpp
src/vamp_zero/navigation.cpp src/vamp_zero/navigation.cpp
src/vamp_zero/notation.cpp
src/vamp_zero/page_control.cpp src/vamp_zero/page_control.cpp
src/vamp_zero/portal.cpp src/vamp_zero/performance.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/types.cpp src/vamp_zero/types.cpp
src/vamp_zero/touch_input.cpp
src/vamp_zero/ui.cpp src/vamp_zero/ui.cpp
) )
add_library(vamp-zero-core STATIC ${VAMP_CORE_SOURCES}) add_library(vamp_zero_core STATIC ${VAMP_CORE_SOURCES})
target_include_directories(vamp-zero-core PUBLIC target_include_directories(vamp_zero_core PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated)
target_include_directories(vamp_zero_core PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/include
) )
target_compile_features(vamp-zero-core PUBLIC cxx_std_23) target_compile_features(vamp_zero_core PUBLIC cxx_std_23)
target_compile_definitions(vamp-zero-core PUBLIC target_compile_definitions(vamp_zero_core PUBLIC
VAMP_VERSION="${PROJECT_VERSION}"
VAMP_GIT_SHA="${VAMP_GIT_SHA}" VAMP_GIT_SHA="${VAMP_GIT_SHA}"
) )
target_link_libraries(vamp-zero-core PUBLIC target_link_libraries(vamp_zero_core PUBLIC
vamp-style vamp_style
vamp-protocol vamp_protocol
modulus::modulus modulus::modulus
modulus::boost_json modulus::boost_json
modulus::openssl modulus::openssl
@@ -84,11 +118,13 @@ target_link_libraries(vamp-zero-core PUBLIC
ligo::ligo ligo::ligo
noto::noto noto::noto
luma::luma luma::luma
sonus::sonus
PRIVATE modulus::raylib
) )
add_executable(vamp-zero src/vamp_zero/main.cpp) add_executable(vamp_zero src/vamp_zero/main.cpp)
target_compile_features(vamp-zero PRIVATE cxx_std_23) target_compile_features(vamp_zero PRIVATE cxx_std_23)
target_link_libraries(vamp-zero PRIVATE vamp-zero-core) target_link_libraries(vamp_zero PRIVATE vamp_zero_core)
if(BUILD_TESTING) if(BUILD_TESTING)
add_subdirectory(tests) add_subdirectory(tests)
@@ -99,46 +135,76 @@ set(VAMP_MANIFEST ${VAMP_BUNDLE_DIR}/manifest.json)
configure_file(package/manifest.json.in ${VAMP_MANIFEST} @ONLY) configure_file(package/manifest.json.in ${VAMP_MANIFEST} @ONLY)
file(GLOB_RECURSE VAMP_GENERATOR_INPUTS CONFIGURE_DEPENDS file(GLOB_RECURSE VAMP_GENERATOR_INPUTS CONFIGURE_DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/data/*.json ${CMAKE_CURRENT_SOURCE_DIR}/data/songs/*.xml
${CMAKE_CURRENT_SOURCE_DIR}/data/**/*.json ${CMAKE_CURRENT_SOURCE_DIR}/data/training/*.xml
${CMAKE_CURRENT_SOURCE_DIR}/data/training/*.json
${CMAKE_CURRENT_SOURCE_DIR}/data/models/*.json
${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/performance_summary.json
${CMAKE_CURRENT_SOURCE_DIR}/data/composition/*.json
${CMAKE_CURRENT_SOURCE_DIR}/generator/*.py ${CMAKE_CURRENT_SOURCE_DIR}/generator/*.py
${CMAKE_CURRENT_SOURCE_DIR}/generator/**/*.py ${CMAKE_CURRENT_SOURCE_DIR}/generator/**/*.py
${VAMP_SHARED_PYTHON_INPUTS}
${CMAKE_CURRENT_SOURCE_DIR}/generator/palette.json
${CMAKE_CURRENT_SOURCE_DIR}/generator/theme.py
${CMAKE_CURRENT_SOURCE_DIR}/cmake/build_python.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/**/*
) )
set(VAMP_GENERATED_ASSETS_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated-assets) set(VAMP_GENERATED_ASSETS_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated_assets)
set(VAMP_GENERATED_ASSETS_STAMP ${CMAKE_CURRENT_BINARY_DIR}/generated-assets.stamp) set(VAMP_GENERATED_ASSETS_STAMP ${CMAKE_CURRENT_BINARY_DIR}/generated_assets.stamp)
set(VAMP_ENGRAVING_JOBS 2 CACHE STRING "Maximum parallel Python engraving workers (1-16)")
set(VAMP_COMPOSITION_JOBS 2 CACHE STRING "Maximum independent musical role workers (1-16)")
set(VAMP_ENGRAVING_CACHE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/build/build_common/engraving_cache" CACHE PATH "Shared build-only page cache; empty disables caching")
set(VAMP_ENGRAVING_CACHE_ARGS)
if(VAMP_ENGRAVING_CACHE_DIR)
list(APPEND VAMP_ENGRAVING_CACHE_ARGS --engraving-cache "${VAMP_ENGRAVING_CACHE_DIR}")
endif()
string(JOIN "\n" VAMP_GENERATOR_INPUT_MANIFEST ${VAMP_GENERATOR_INPUTS})
file(CONFIGURE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/generated_assets.inputs
CONTENT "@VAMP_GENERATOR_INPUT_MANIFEST@\n" @ONLY)
add_custom_command( add_custom_command(
OUTPUT ${VAMP_GENERATED_ASSETS_STAMP} OUTPUT ${VAMP_GENERATED_ASSETS_STAMP}
COMMAND ${Python3_EXECUTABLE} BYPRODUCTS ${VAMP_GENERATED_ASSETS_DIR}/catalog.db
COMMAND ${VAMP_PYTHON_COMMAND}
-m generator -m generator
--data-dir ${CMAKE_CURRENT_SOURCE_DIR}/data --data-dir ${CMAKE_CURRENT_SOURCE_DIR}/data
--output-dir ${VAMP_GENERATED_ASSETS_DIR} --output-dir ${VAMP_GENERATED_ASSETS_DIR}
--static-assets-dir ${CMAKE_CURRENT_SOURCE_DIR}/package/assets --static-assets-dir ${CMAKE_CURRENT_SOURCE_DIR}/package/assets
--clean
--check --check
--jobs ${VAMP_ENGRAVING_JOBS}
--composition-jobs ${VAMP_COMPOSITION_JOBS}
--composition-cache ${CMAKE_CURRENT_SOURCE_DIR}/build/build_common/composition_cache
--progress
${VAMP_ENGRAVING_CACHE_ARGS}
COMMAND ${CMAKE_COMMAND} -E touch ${VAMP_GENERATED_ASSETS_STAMP} COMMAND ${CMAKE_COMMAND} -E touch ${VAMP_GENERATED_ASSETS_STAMP}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
DEPENDS ${VAMP_GENERATOR_INPUTS} DEPENDS ${VAMP_GENERATOR_INPUTS} ${CMAKE_CURRENT_BINARY_DIR}/generated_assets.inputs
USES_TERMINAL
VERBATIM VERBATIM
) )
add_custom_target(vamp-generated-assets ALL DEPENDS ${VAMP_GENERATED_ASSETS_STAMP}) add_custom_target(vamp_generated_assets ALL DEPENDS ${VAMP_GENERATED_ASSETS_STAMP})
if(BUILD_TESTING)
add_dependencies(product_vamp_test vamp_generated_assets)
endif()
install(TARGETS vamp-zero install(TARGETS vamp_zero
RUNTIME DESTINATION zero/bin RUNTIME DESTINATION zero/bin
) )
install(FILES package/config/vamp.example.json DESTINATION zero/config) install(FILES package/config/vamp.example.json DESTINATION zero/config)
install(DIRECTORY ${VAMP_GENERATED_ASSETS_DIR}/ DESTINATION zero/share/product-vamp) install(DIRECTORY ${VAMP_GENERATED_ASSETS_DIR}/ DESTINATION zero/share/product_vamp
install(DIRECTORY package/migrations/ DESTINATION zero/share/product-vamp/migrations) PATTERN "*.json" EXCLUDE PATTERN "analysis" EXCLUDE PATTERN "print" EXCLUDE)
install(PROGRAMS install(PROGRAMS
package/zero/install-zero.sh package/zero/install_zero.sh
package/zero/start-ap.sh package/zero/start_ap.sh
package/zero/start-zero.sh package/zero/start_zero.sh
DESTINATION zero DESTINATION zero
) )
install(FILES install(FILES
package/zero/product-vamp-ap.service package/zero/product_vamp_ap.service
package/zero/product-vamp-zero.service package/zero/product_vamp_zero.service
DESTINATION zero/systemd DESTINATION zero/systemd
) )
install(FILES package/pico/wiring.md DESTINATION pico) install(FILES package/pico/wiring.md DESTINATION pico)
@@ -151,45 +217,48 @@ endif()
if(VAMP_BUILD_ZERO_IMAGE) if(VAMP_BUILD_ZERO_IMAGE)
find_program(DOCKER_EXECUTABLE docker REQUIRED) find_program(DOCKER_EXECUTABLE docker REQUIRED)
set(VAMP_ZERO_IMAGE_CONTEXT ${CMAKE_CURRENT_BINARY_DIR}/zero-image-context) set(VAMP_ZERO_IMAGE_CONTEXT ${CMAKE_CURRENT_BINARY_DIR}/zero_image_context)
set(VAMP_ZERO_IMAGE_TAR ${CMAKE_CURRENT_BINARY_DIR}/product-vamp-zero-linux-arm64.docker.tar) set(VAMP_ZERO_IMAGE_TAR ${CMAKE_CURRENT_BINARY_DIR}/product_vamp_zero_linux_arm64.docker.tar)
add_custom_command( add_custom_command(
OUTPUT ${VAMP_ZERO_IMAGE_TAR} OUTPUT ${VAMP_ZERO_IMAGE_TAR}
COMMAND ${CMAKE_COMMAND} -E rm -rf ${VAMP_ZERO_IMAGE_CONTEXT} COMMAND ${CMAKE_COMMAND} -E rm -rf ${VAMP_ZERO_IMAGE_CONTEXT}
COMMAND ${CMAKE_COMMAND} -E make_directory ${VAMP_ZERO_IMAGE_CONTEXT}/usr/bin COMMAND ${CMAKE_COMMAND} -E make_directory ${VAMP_ZERO_IMAGE_CONTEXT}/usr/bin
COMMAND ${CMAKE_COMMAND} -E make_directory ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product-vamp COMMAND ${CMAKE_COMMAND} -E make_directory ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product_vamp
COMMAND ${CMAKE_COMMAND} -E copy $<TARGET_FILE:vamp-zero> ${VAMP_ZERO_IMAGE_CONTEXT}/usr/bin/vamp-zero COMMAND ${CMAKE_COMMAND} -E copy $<TARGET_FILE:vamp_zero> ${VAMP_ZERO_IMAGE_CONTEXT}/usr/bin/vamp_zero
COMMAND ${CMAKE_COMMAND} -E copy package/zero/Dockerfile ${VAMP_ZERO_IMAGE_CONTEXT}/Dockerfile COMMAND ${CMAKE_COMMAND} -E copy package/zero/Dockerfile ${VAMP_ZERO_IMAGE_CONTEXT}/Dockerfile
COMMAND ${CMAKE_COMMAND} -E copy_directory ${VAMP_GENERATED_ASSETS_DIR} ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product-vamp COMMAND ${CMAKE_COMMAND} -DSOURCE=${VAMP_GENERATED_ASSETS_DIR} -DDESTINATION=${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product_vamp -P cmake/runtime_assets.cmake
COMMAND ${CMAKE_COMMAND} -E make_directory ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product-vamp/config COMMAND ${CMAKE_COMMAND} -E make_directory ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product_vamp/config
COMMAND ${CMAKE_COMMAND} -E copy package/config/vamp.example.json ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product-vamp/config/vamp.example.json COMMAND ${CMAKE_COMMAND} -E copy package/config/vamp.example.json ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product_vamp/config/vamp.example.json
COMMAND ${CMAKE_COMMAND} -E copy_directory package/migrations ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product-vamp/migrations
COMMAND ${BASH_EXECUTABLE} package/scripts/build_zero_image.sh ${VAMP_ZERO_IMAGE_CONTEXT} ${VAMP_ZERO_IMAGE_TAR} ${VAMP_ZERO_IMAGE_TAG} COMMAND ${BASH_EXECUTABLE} package/scripts/build_zero_image.sh ${VAMP_ZERO_IMAGE_CONTEXT} ${VAMP_ZERO_IMAGE_TAR} ${VAMP_ZERO_IMAGE_TAG}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
DEPENDS DEPENDS
vamp-zero vamp_zero
package/scripts/build_zero_image.sh package/scripts/build_zero_image.sh
package/zero/Dockerfile package/zero/Dockerfile
package/config/vamp.example.json package/config/vamp.example.json
package/migrations/001_foundation.sql cmake/runtime_assets.cmake
package/migrations/002_song_import_metadata.sql
package/migrations/003_practice_catalog.sql
package/migrations/004_practice_hierarchy.sql
package/migrations/005_catalog_managed_rows.sql
${VAMP_GENERATED_ASSETS_STAMP} ${VAMP_GENERATED_ASSETS_STAMP}
VERBATIM VERBATIM
) )
add_custom_target(vamp-zero-image ALL DEPENDS ${VAMP_ZERO_IMAGE_TAR}) add_custom_target(vamp_zero_image ALL DEPENDS ${VAMP_ZERO_IMAGE_TAR})
install(FILES ${VAMP_ZERO_IMAGE_TAR} DESTINATION zero) install(FILES ${VAMP_ZERO_IMAGE_TAR} DESTINATION zero)
endif() endif()
if(VAMP_BUILD_PICO_FIRMWARE) if(VAMP_BUILD_PICO_FIRMWARE)
set(VAMP_PICO_BUILD_DIR ${CMAKE_CURRENT_BINARY_DIR}/pico-build) set(VAMP_PICO_BUILD_DIR ${CMAKE_CURRENT_BINARY_DIR}/pico_build)
set(VAMP_PICO_UF2 ${CMAKE_CURRENT_BINARY_DIR}/product-vamp-pico2w.uf2) set(VAMP_PICO_UF2 ${CMAKE_CURRENT_BINARY_DIR}/product_vamp_pico2w.uf2)
file(GLOB VAMP_PICO_SOURCES CONFIGURE_DEPENDS file(GLOB VAMP_PICO_SOURCES CONFIGURE_DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/CMakeLists.txt ${CMAKE_CURRENT_SOURCE_DIR}/CMakeLists.txt
${CMAKE_CURRENT_SOURCE_DIR}/cmake/pico.cmake ${CMAKE_CURRENT_SOURCE_DIR}/cmake/pico.cmake
${CMAKE_CURRENT_SOURCE_DIR}/cmake/pico_artwork.cmake
${CMAKE_CURRENT_SOURCE_DIR}/generator/pico_artwork.py
${VAMP_SHARED_PYTHON_INPUTS}
${CMAKE_CURRENT_SOURCE_DIR}/generator/palette.json
${CMAKE_CURRENT_SOURCE_DIR}/generator/theme.py
${CMAKE_CURRENT_SOURCE_DIR}/cmake/build_python.cmake
${CMAKE_CURRENT_SOURCE_DIR}/cmake/theme.cmake
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/petaluma/PetalumaScript.otf
${CMAKE_CURRENT_SOURCE_DIR}/cmake/vamp_options.cmake ${CMAKE_CURRENT_SOURCE_DIR}/cmake/vamp_options.cmake
${CMAKE_CURRENT_SOURCE_DIR}/include/*.hpp ${CMAKE_CURRENT_SOURCE_DIR}/include/*.hpp
${CMAKE_CURRENT_SOURCE_DIR}/include/vamp_style/*.hpp ${CMAKE_CURRENT_SOURCE_DIR}/include/vamp_style/*.hpp
@@ -198,8 +267,8 @@ if(VAMP_BUILD_PICO_FIRMWARE)
${CMAKE_CURRENT_SOURCE_DIR}/include/vamp_pico/*.hpp ${CMAKE_CURRENT_SOURCE_DIR}/include/vamp_pico/*.hpp
${CMAKE_CURRENT_SOURCE_DIR}/include/vamp_protocol/*.hpp ${CMAKE_CURRENT_SOURCE_DIR}/include/vamp_protocol/*.hpp
${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_style.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_style.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_style/*.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_pico/*.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_pico/*.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_pico/*.S.in
${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_protocol/*.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_protocol/*.cpp
) )
add_custom_command( add_custom_command(
@@ -214,12 +283,15 @@ if(VAMP_BUILD_PICO_FIRMWARE)
"${VAMP_ZERO_HOST}" "${VAMP_ZERO_HOST}"
"${VAMP_ZERO_PORT}" "${VAMP_ZERO_PORT}"
"${modulus_INCLUDE_DIR}" "${modulus_INCLUDE_DIR}"
"${Python3_EXECUTABLE}"
"${MODULUS_PYTHON_RUNNER}"
"${MODULUS_PYTHON_ROOT}"
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
DEPENDS DEPENDS
package/scripts/build_pico_firmware.sh package/scripts/build_pico_firmware.sh
${VAMP_PICO_SOURCES} ${VAMP_PICO_SOURCES}
VERBATIM VERBATIM
) )
add_custom_target(vamp-pico-firmware ALL DEPENDS ${VAMP_PICO_UF2}) add_custom_target(vamp_pico_firmware ALL DEPENDS ${VAMP_PICO_UF2})
install(FILES ${VAMP_PICO_UF2} DESTINATION pico) install(FILES ${VAMP_PICO_UF2} DESTINATION pico)
endif() endif()
+7 -1
View File
@@ -2,4 +2,10 @@ include(../../build/build_tools/conan/profiles/arm_64_gcc12_release)
[conf] [conf]
tools.build:jobs=2 tools.build:jobs=2
tools.build:compiler_executables={"c": "/usr/bin/aarch64-linux-gnu-gcc-12", "cpp": "/usr/bin/aarch64-linux-gnu-g++-12"} tools.build:compiler_executables={"c": "aarch64-linux-gnu-gcc-12", "cpp": "aarch64-linux-gnu-g++-12"}
# Exclude older internal binaries published with an unversioned cross compiler.
modulus/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
ligo/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
noto/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
luma/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
sonus/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
+27 -17
View File
@@ -3,7 +3,7 @@ import conan.tools.build
import conan.tools.cmake import conan.tools.cmake
class product_vamp_conan(conan.ConanFile): class product_vamp_conan(conan.ConanFile):
name = "product-vamp" name = "product_vamp"
version = "latest" version = "latest"
description = "Two-device sheet-music display and guitar remote" description = "Two-device sheet-music display and guitar remote"
license = "MIT" license = "MIT"
@@ -19,16 +19,29 @@ class product_vamp_conan(conan.ConanFile):
"with_tests": False, "with_tests": False,
} }
python_requires = "modulus/latest" python_requires = "modulus/latest"
exports = "modulus.json" exports = "modulus.json", "!build", "!.git", "!test_package/build"
exports_sources = ( exports_sources = (
"!build",
"!.git",
"!test_package/build",
".gitea/workflows/ci.yml",
"CMakeLists.txt", "CMakeLists.txt",
"modulus.py",
"modulus.json",
"cmake/*.cmake", "cmake/*.cmake",
"data/*.json", "data/songs/*.xml",
"data/**/*.json", "data/composition/*.json",
"data/models/*.json",
"data/training/*.json",
"data/training/*.xml",
"tests/fixtures/*",
"tests/fixtures/**/*",
"generator/*.py", "generator/*.py",
"generator/palette.json",
"generator/**/*.py", "generator/**/*.py",
"include/*.hpp", "include/*.hpp",
"include/vamp_style/*.hpp", "include/vamp_style/*.hpp",
"src/vamp_pico/*.S.in",
"include/vamp_pico/*.h", "include/vamp_pico/*.h",
"include/vamp_pico/capi/*.h", "include/vamp_pico/capi/*.h",
"include/vamp_pico/*.hpp", "include/vamp_pico/*.hpp",
@@ -36,40 +49,40 @@ class product_vamp_conan(conan.ConanFile):
"include/vamp_zero/*.hpp", "include/vamp_zero/*.hpp",
"package/assets/*", "package/assets/*",
"package/assets/**/*", "package/assets/**/*",
"package/Dockerfile.build",
"package/config/*.json", "package/config/*.json",
"package/manifest.json.in", "package/manifest.json.in",
"package/migrations/*.sql",
"package/pico/*", "package/pico/*",
"package/scripts/*.py", "package/scripts/*.py",
"package/scripts/*.sh", "package/scripts/*.sh",
"package/zero/*", "package/zero/*",
"src/vamp_style.cpp", "src/vamp_style.cpp",
"src/vamp_style/*.cpp",
"src/vamp_pico/*.cpp", "src/vamp_pico/*.cpp",
"src/vamp_protocol/*.cpp", "src/vamp_protocol/*.cpp",
"src/vamp_zero/*.cpp", "src/vamp_zero/*.cpp",
"tests/CMakeLists.txt", "tests/CMakeLists.txt",
"tests/*.cpp", "tests/*.cpp",
"tests/*.hpp", "tests/*.hpp",
"tests/*.py",
) )
def requirements(self): def requirements(self):
self.requires( self.requires(
str(self.python_requires["modulus"].ref), str(self.python_requires["modulus"].ref),
transitive_headers=True, transitive_headers=True,
transitive_libs=True, transitive_libs=True,
options={
"with_openssl": True,
"with_sqlite": True,
"with_raylib": True,
},
) )
for reference in ( for reference in (
"ligo/latest", "ligo/latest",
"noto/latest", "noto/latest",
"luma/latest", "luma/latest",
"sonus/latest",
): ):
self.requires( self.requires(reference, transitive_headers=True, transitive_libs=True)
reference,
transitive_headers=True,
transitive_libs=True,
)
def generate(self): def generate(self):
dependencies = conan.tools.cmake.CMakeDeps(self) dependencies = conan.tools.cmake.CMakeDeps(self)
dependencies.generate() dependencies.generate()
@@ -77,21 +90,18 @@ class product_vamp_conan(conan.ConanFile):
if hasattr(toolchain, "user_presets_path"): if hasattr(toolchain, "user_presets_path"):
toolchain.user_presets_path = False toolchain.user_presets_path = False
toolchain.variables["BUILD_TESTING"] = bool(self.options.with_tests) toolchain.variables["BUILD_TESTING"] = bool(self.options.with_tests)
toolchain.variables.update(self.python_requires["modulus"].module.python_cmake_variables(self))
toolchain.generate() toolchain.generate()
def build(self): def build(self):
cmake = conan.tools.cmake.CMake(self) cmake = conan.tools.cmake.CMake(self)
cmake.configure() cmake.configure()
cmake.build() cmake.build()
if self.options.with_tests and conan.tools.build.can_run(self): if self.options.with_tests and conan.tools.build.can_run(self):
cmake.test(target="check") cmake.test(target="check")
def package_id(self): def package_id(self):
self.info.options.rm_safe("with_tests") self.info.options.rm_safe("with_tests")
def package(self): def package(self):
cmake = conan.tools.cmake.CMake(self) cmake = conan.tools.cmake.CMake(self)
cmake.install() cmake.install()
def package_info(self): def package_info(self):
self.cpp_info.bindirs = ["zero/bin"] self.cpp_info.bindirs = ["zero/bin"]
File diff suppressed because it is too large Load Diff
+148
View File
@@ -0,0 +1,148 @@
{
"version": 1,
"provenance": "General written-chord/function and bounded tension policies. Source key supplies brief diatonic motion; no fixed scale maps. Numerical limits are engineering settings.",
"license": "MIT",
"colors": [
{
"id": "major_ninth",
"qualities": [
"major"
],
"degree": 9,
"alter": 0,
"max_beats": "4"
},
{
"id": "major_thirteenth",
"qualities": [
"major"
],
"degree": 13,
"alter": 0,
"max_beats": "4"
},
{
"id": "lydian_color",
"qualities": [
"major",
"dominant"
],
"degree": 11,
"alter": 1,
"max_beats": "2",
"resolve": true
},
{
"id": "minor_ninth",
"qualities": [
"minor"
],
"degree": 9,
"alter": 0,
"max_beats": "4"
},
{
"id": "minor_eleventh",
"qualities": [
"minor",
"half_diminished"
],
"degree": 11,
"alter": 0,
"max_beats": "4"
},
{
"id": "minor_sixth",
"qualities": [
"minor"
],
"degree": 6,
"alter": 0,
"max_beats": "4",
"function": "tonic_or_predominant",
"continuation": "Stable color on tonic/predominant minor harmony; duration and explicit chord exclusions remain binding."
},
{
"id": "dominant_ninth",
"qualities": [
"dominant"
],
"degree": 9,
"alter": 0,
"max_beats": "4"
},
{
"id": "dominant_thirteenth",
"qualities": [
"dominant"
],
"degree": 13,
"alter": 0,
"max_beats": "4"
},
{
"id": "dominant_flat_ninth",
"qualities": [
"dominant"
],
"degree": 9,
"alter": -1,
"max_beats": "4",
"resolve": false,
"function": "resolving_dominant",
"continuation": "Bounded dominant color. May continue through other chord tones/tensions rather than resolve immediately by step. Written exclusions and harmonic function remain binding."
},
{
"id": "dominant_sharp_ninth",
"qualities": [
"dominant"
],
"degree": 9,
"alter": 1,
"max_beats": "4",
"resolve": false,
"function": "resolving_dominant",
"continuation": "Bounded dominant color. May continue through other chord tones/tensions rather than resolve immediately by step. Written exclusions and harmonic function remain binding."
},
{
"id": "dominant_flat_thirteenth",
"qualities": [
"dominant"
],
"degree": 13,
"alter": -1,
"max_beats": "4",
"resolve": false,
"function": "resolving_dominant",
"continuation": "Bounded dominant color. May continue through other chord tones/tensions rather than resolve immediately by step. Written exclusions and harmonic function remain binding."
},
{
"id": "half_diminished_natural_sixth",
"qualities": [
"half_diminished"
],
"degree": 13,
"alter": 0,
"max_beats": "4",
"function": "predominant_to_dominant",
"continuation": "Natural sixth preparing the following dominant; requires the stated harmonic function."
}
],
"resolution_gap": "1",
"anticipation_max": "1",
"suspensions": [
{
"id": "prepared_step_down",
"preparation": "1/2",
"max_delay": "1",
"steps": [
-1,
-2
]
}
],
"sources": [
"https://www.berklee.edu/berklee-today/summer-2014/tension-and-resolution",
"https://college.berklee.edu/berklee-today-55"
]
}
+299
View File
@@ -0,0 +1,299 @@
{
"version": 1,
"provenance": "Generic target-relative approach/enclosure operators. Target-guided review motivated an explicitly separated enclosure and spelling-independent function. No pitches, chord progressions, bar schedules or extracted phrase templates are stored.",
"license": "MIT",
"patterns": [
{
"id": "below",
"kind": "approach",
"semitones": [
-1
],
"durations": [
"1/2"
],
"gap": "0"
},
{
"id": "above",
"kind": "approach",
"semitones": [
1
],
"durations": [
"1/2"
],
"gap": "0"
},
{
"id": "upper_lower",
"kind": "enclosure",
"semitones": [
1,
-1
],
"durations": [
"1/2",
"1/2"
],
"gap": "0"
},
{
"id": "lower_upper",
"kind": "enclosure",
"semitones": [
-1,
1
],
"durations": [
"1/2",
"1/2"
],
"gap": "0"
},
{
"id": "ascending",
"kind": "connection",
"semitones": [
-2,
-1
],
"durations": [
"1/2",
"1/2"
],
"gap": "0"
},
{
"id": "descending",
"kind": "connection",
"semitones": [
2,
1
],
"durations": [
"1/2",
"1/2"
],
"gap": "0"
},
{
"id": "delayed_below",
"kind": "approach",
"semitones": [
-1
],
"durations": [
"1/2"
],
"gap": "1/2"
},
{
"id": "mapped_upper_lower",
"kind": "enclosure",
"semitones": [
2,
-1
],
"durations": [
"1/2",
"1/2"
],
"gap": "0",
"diatonic_indices": [
0
]
},
{
"id": "lower_mapped_upper",
"kind": "enclosure",
"semitones": [
-1,
2
],
"durations": [
"1/2",
"1/2"
],
"gap": "0",
"diatonic_indices": [
1
]
},
{
"id": "separated_lower_upper",
"kind": "enclosure",
"semitones": [
-1,
2
],
"durations": [
"1/2",
"1/2"
],
"internal_gaps": [
"1/2"
],
"gap": "0",
"diatonic_indices": [
1
]
},
{
"id": "chromatic_scalar_up",
"kind": "approach",
"semitones": [
-3,
-2
],
"durations": [
"1/2",
"1/2"
],
"gap": "0",
"diatonic_indices": [
1
]
},
{
"id": "chromatic_scalar_down",
"kind": "approach",
"semitones": [
3,
2
],
"durations": [
"1/2",
"1/2"
],
"gap": "0",
"diatonic_indices": [
1
]
},
{
"id": "short_chromatic_up",
"kind": "connection",
"semitones": [
-2,
-1
],
"durations": [
"1/4",
"1/4"
],
"gap": "0",
"difficulty": "sixteenth"
},
{
"id": "short_chromatic_down",
"kind": "connection",
"semitones": [
2,
1
],
"durations": [
"1/4",
"1/4"
],
"gap": "0",
"difficulty": "sixteenth"
},
{
"id": "triplet_chromatic_up",
"kind": "connection",
"semitones": [
-2,
-1
],
"durations": [
"1/3",
"1/3"
],
"gap": "0",
"difficulty": "triplet"
},
{
"id": "triplet_chromatic_down",
"kind": "connection",
"semitones": [
2,
1
],
"durations": [
"1/3",
"1/3"
],
"gap": "0",
"difficulty": "triplet"
},
{
"id": "quarter_below",
"kind": "approach",
"semitones": [
-1
],
"durations": [
"1"
],
"gap": "0"
},
{
"id": "quarter_above",
"kind": "approach",
"semitones": [
1
],
"durations": [
"1"
],
"gap": "0"
}
],
"blues": [
{
"id": "minor_to_major_third",
"degree": 3,
"alter": -1,
"target_degree": 3
},
{
"id": "flat_fifth_to_fifth",
"degree": 5,
"alter": -1,
"target_degree": 5
}
],
"max_approach": "3/2",
"delayed_approach": {
"id": "bounded_semitone_resolution",
"max_note": "1/2",
"deadline": "4",
"steps": [
-1,
1
],
"arrivals": [
"1",
"3/2",
"2",
"5/2",
"3",
"7/2",
"4"
],
"holds": [
"1/2",
"1"
]
},
"passing": {
"id": "chromatic_passing",
"max_note": "1/2",
"steps": [
-1,
1
],
"endpoints": "Source-key/chord motion or separately validated colors; contiguous same-direction semitones."
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"version": 1,
"provenance": "Independently authored general composition policy; no validation-score measurements or material.",
"license": "MIT",
"roles": {
"acoustic_solo": {},
"electric_solo": {},
"acoustic_accompaniment": {
"seed": "acoustic_comp",
"bass": "primary",
"center": 53,
"voicing": {
"register_weight": 1
}
},
"electric_accompaniment": {
"seed": "electric_comp",
"bass": "essential",
"center": 60,
"voicing": {
"register_weight": 1
}
},
"electric_melody": {
"seed": "head",
"center": 60,
"voicing": {
"register_weight": 1
}
},
"vocal_melody": {
"seed": "voice"
}
},
"difficulty": {
"name": "moderate",
"triplet_max_bpm": 160,
"sixteenth_max_bpm": 144
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -1,17 +0,0 @@
{
"version": 1,
"title": "01 Cycles",
"composer": "",
"key": "C",
"time": "4/4",
"clef": "treble",
"part": "Vocal",
"role": "vocal",
"style": "Med. Swing",
"measures": [
"[G7] (G4 B4 F5):4 (G4 B4 F5):4 [C7] (C4 Bb4 E5):4 (C4 Bb4 E5):4",
"[F7] (F4 A4 Eb5):4 (F4 A4 Eb5):4 [Bb7] (Bb3 Ab4 D5):4 (Bb3 Ab4 D5):4",
"[Eb7] (Eb4 G4 Db5):4 (Eb4 G4 Db5):4 [Ab7] (Ab3 Gb4 C5):4 (Ab3 Gb4 C5):4",
"[Dbmaj7] (Db4 F4 C5):4 (Db4 F4 C5):4 [Db6] (Db4 F4 Bb4):4 (Db4 F4 Bb4):4"
]
}
@@ -1,21 +0,0 @@
{
"version": 1,
"title": "01 The Diatonic Scales",
"composer": "",
"key": "C",
"time": "4/4",
"clef": "treble",
"part": "Vocal",
"role": "vocal",
"style": "Med. Swing",
"measures": [
"{3:2 C4:8 D4:8 E4:8} {3:2 D4:8 E4:8 F4:8} {3:2 E4:8 F4:8 G4:8} {3:2 F4:8 G4:8 A4:8}",
"{3:2 G4:8 A4:8 B4:8} {3:2 A4:8 B4:8 C5:8} {3:2 B4:8 C5:8 D5:8} {3:2 C5:8 D5:8 E5:8}",
"{3:2 D5:8 E5:8 F5:8} {3:2 E5:8 F5:8 G5:8} {3:2 F5:8 G5:8 A5:8} {3:2 G5:8 A5:8 B5:8}",
"{3:2 A5:8 B5:8 C6:8} {3:2 B5:8 C6:8 D6:8} C6:2",
"{3:2 C6:8 B5:8 A5:8} {3:2 B5:8 A5:8 G5:8} {3:2 A5:8 G5:8 F5:8} {3:2 G5:8 F5:8 E5:8}",
"{3:2 F5:8 E5:8 D5:8} {3:2 E5:8 D5:8 C5:8} {3:2 D5:8 C5:8 B4:8} {3:2 C5:8 B4:8 A4:8}",
"{3:2 B4:8 A4:8 G4:8} {3:2 A4:8 G4:8 F4:8} {3:2 G4:8 F4:8 E4:8} {3:2 F4:8 E4:8 D4:8}",
"{3:2 E4:8 D4:8 C4:8} {3:2 D4:8 C4:8 B3:8} C4:2"
]
}
File diff suppressed because it is too large Load Diff
-46
View File
@@ -1,46 +0,0 @@
{
"version": 1,
"title": "Autumn Leaves",
"composer": "Joseph Kosma / Johnny Mercer / Jaques Prevert",
"key": "F",
"time": "4/4",
"clef": "treble",
"part": "Vocal",
"role": "vocal",
"style": "Med.",
"measures": [
"r:4 D4:4 \"The\" E4:4 \"fall-\" F4:4 \"ing\"",
"[G-7] Bb4:1~ \"leaves\"",
"[C7] ~Bb4:4 C4:4 \"drift\" D4:4 \"by\" E4:4 \"my\"",
"[Fmaj7] A4:2 \"win-\" A4:2~ \"dow,\"",
"[Bbmaj7] ~A4:4 Bb3:4 \"the\" C4:4 \"au-\" D4:4 \"tumn\"",
"[E-7b5] G4:1~ \"leaves\"",
"[A7] ~G4:4 A3:4 \"of\" B3:4 \"red\" C#4:4 \"and\"",
"[D-] F4:1 \"gold.\"",
"r:4 D4:4 \"I\" E4:4 \"see\" F4:4 \"your\"",
"[G-7] Bb4:1~ \"lips,\"",
"[C7] ~Bb4:4 C4:4 \"the\" D4:4 \"sum-\" E4:4 \"mer\"",
"[Fmaj7] A4:2 \"kiss-\" A4:2~ \"es,\"",
"[Bbmaj7] ~A4:4 Bb3:4 \"the\" C4:4 \"sun-\" D4:4 \"burnt\"",
"[E-7b5] G4:1~ \"hands\"",
"[A7] ~G4:4 E4:4 \"I\" G4:4 \"used\" F4:4 \"to\"",
"[D-] D4:1~ \"hold.\"",
"~D4:4 r:4 C#4:4 \"Since\" D4:4 \"you\"",
"[E-7b5] E4:4 \"went\" A3:4 \"a-\" E4:2~ \"way\"",
"[A7b9] ~E4:4 E4:4 \"the\" D4:4 \"days\" E4:4 \"grow\"",
"[D-] F4:1~ \"long,\"",
"~F4:4 F4:4 \"and\" E4:4 \"soon\" F4:4 \"I'll\"",
"[G-7] G4:1~ \"hear\"",
"[C7] ~G4:4 C4:4 \"old\" C5:4 \"win-\" Bb4:4 \"ter's\"",
"[Fmaj7] A4:1~ \"song.\"",
"~A4:4 r:4 G#4:4 \"But\" A4:4 \"I\"",
"[E-7b5] Bb4:4 \"miss\" Bb4:4 \"you\" G4:4 \"most\" G4:4 \"of\"",
"[A7b9] E4:2. \"all,\" Bb4:4 \"my\"",
"[D-7] A4:2 \"dar-\" [G7] A4:2~ \"ling,\"",
"[C-7] ~A4:2 [F7] D4:2 \"when\"",
"[E-7b5] G4:2. \"au-\" F4:4 \"tumn\"",
"[A7b9] E4:2 \"leaves\" F4:4 \"start\" A3:4 \"to\"",
"[D-] D4:1 \"fall.\"",
"r:4 r:4 r:4 r:4"
]
}
File diff suppressed because it is too large Load Diff
-45
View File
@@ -1,45 +0,0 @@
{
"version": 1,
"title": "It Could Happen to You",
"composer": "James Van Heusen / Johnny Burke",
"key": "Eb",
"time": "4/4",
"clef": "treble",
"part": "Vocal",
"role": "vocal",
"style": "Med.",
"measures": [
"[Ebmaj7] Bb3:2. \"Hide\" C4:4 \"your\"",
"[G-7b5] Db4:4 \"heart\" C4:8 \"from\" G4:8~ \"sight,\" [C7b9] ~G4:2",
"[F-7] C4:2. \"lock\" D4:4 \"your\"",
"[A-7b5] Eb4:4 \"dreams\" D4:8 \"at\" A4:8~ \"night,\" [D7b9] ~A4:2",
"[Ebmaj7] Bb4:2 \"it\" Bb4:2 \"could\"",
"[Ab7] Bb4:4 \"hap-\" Ab4:2 \"pen\" Eb4:4 \"to\"",
"[G-7b5] G4:1 \"you.\"",
"[C7b9] r:4 r:4 r:4 r:4",
"[F-7] G4:2 \"Don't\" G4:2 \"count\"",
"[Db7] G4:4 \"stars\" Ab4:4 \"or\" Eb4:4 \"you\" F4:8 \"might\" G4:8~ \"stum-\"",
"[Ebmaj7] ~G4:2 G4:2 \"ble,\"",
"[D-7b5] r:4 r:4 [G7] r:4 r:4",
"[C-7] Eb4:2 \"some-\" Eb4:2 \"one\"",
"[F7] Eb4:4 \"drops\" F4:4 \"a\" C4:4 \"sigh\" D4:8 \"and\" Eb4:8~ \"down\"",
"[F-7] ~Eb4:2 F4:2 \"you\"",
"[Bb7] C4:4. \"tum-\" D4:8~ \"ble.\" ~D4:2",
"[Ebmaj7] Bb3:2. \"Keep\" C4:4 \"an\"",
"[G-7b5] Db4:4 \"eye\" C4:8 \"on\" G4:8~ \"spring,\" [C7b9] ~G4:2",
"[F-7] C4:2. \"run\" D4:4 \"when\"",
"[A-7b9] Eb4:4 \"church\" D4:8 \"bells\" A4:8~ \"ring,\" [D7b9] ~A4:2",
"[Ebmaj7] Bb4:2 \"it\" Bb4:2 \"could\"",
"[Ab7] Bb4:4 \"hap-\" Ab4:2 \"pen\" Eb4:4 \"to\"",
"[G-7b5] G4:1 \"you.\"",
"[C7b9] r:4 r:4 r:4 r:4",
"[F-7] G4:2 \"All\" G4:2 \"I\"",
"[Db7] G4:4 \"did\" Ab4:4 \"was\" Eb4:4 \"won-\" F4:8 \"der\" G4:8~ \"how\"",
"[Ebmaj7] ~G4:4 D4:4 \"your\" [Ab7] F4:4 \"arms\" Eb4:8 \"would\" Bb4:8~ \"be,\"",
"[G-7b5] ~Bb4:1",
"[F-7] Bb3:2 \"and\" Bb3:2 \"it\"",
"[Bb7] Ab4:4 \"hap-\" F4:2 \"pened\" G4:4 \"to\"",
"[Ebmaj7] Eb4:1 \"me.\"",
"[(F-7)] r:4 r:4 [(Bb7)] r:4 r:4"
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"version": 1,
"provenance": "User-authored source/solo pairs for pooled supervised learning and exact registered playback. Explanatory annotations removed; all sounding music and written harmony preserved.",
"examples": [
{
"id": "all-of-me/solo1",
"source": "../songs/all-of-me.xml",
"solo": "all-of-me-solo-1.xml",
"pitch_convention": "guitar-written",
"slot": 1
},
{
"id": "all-of-me/solo2",
"source": "../songs/all-of-me.xml",
"solo": "all-of-me-solo-2.xml",
"pitch_convention": "guitar-written",
"slot": 2
},
{
"id": "autumn-leaves/solo1",
"source": "../songs/autumn_leaves.xml",
"solo": "autumn-leaves-solo-1.xml",
"pitch_convention": "guitar-written",
"slot": 1
},
{
"id": "autumn-leaves/solo2",
"source": "../songs/autumn_leaves.xml",
"solo": "autumn-leaves-solo-2.xml",
"pitch_convention": "guitar-written",
"slot": 2
}
]
}
-1
View File
@@ -1,2 +1 @@
"""Build-time score generation for Product Vamp.""" """Build-time score generation for Product Vamp."""
+115 -35
View File
@@ -1,56 +1,136 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
import os
import pathlib
import platform
import shutil import shutil
from pathlib import Path import tempfile
import time
from generator.arrange.generate import generate_performance_scores import generator.audio_assets
from generator.practice.generate import generate_practice_scores import generator.catalog
from generator.score import count_json_files, validate_generated_tree import generator.engraving.cache
from generator.summary import validate_expected_performance_summary, validate_practice_variants import generator.engraving.fonts
import generator.engraving.pipeline
import generator.engraving.types
import generator.parts
import generator.pico_artwork
import generator.printing
import generator.production
import generator.score
import generator.score_io
import generator.summary
import modulus.filesystem
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
def copy_runtime_fonts(static_assets_dir: pathlib.Path, output_dir: pathlib.Path) -> None:
source = static_assets_dir / "fonts" / "petaluma"
destination = output_dir / "fonts" / "petaluma"
destination.mkdir(parents=True, exist_ok=True)
for name in RUNTIME_FONT_FILES:
shutil.copy2(source / name, destination / name)
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate Product Vamp package score assets.") parser = argparse.ArgumentParser(description="Generate Product Vamp package score assets.")
parser.add_argument("--data-dir", type=Path, required=True) parser.add_argument("--data-dir", type=pathlib.Path)
parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--output-dir", type=pathlib.Path)
parser.add_argument("--static-assets-dir", type=Path, default=Path("package/assets")) parser.add_argument("--renderer-fingerprint", action="store_true", help="Print the engraving cache identity without generating assets.")
parser.add_argument("--clean", action="store_true") parser.add_argument("--static-assets-dir", type=pathlib.Path, default=pathlib.Path("package/assets"))
parser.add_argument("--check", action="store_true") parser.add_argument("--check", action="store_true")
return parser.parse_args() parser.add_argument("--engraving-cache", type=pathlib.Path)
parser.add_argument("--jobs", type=int, default=1, choices=range(1, 17), metavar="1-16")
parser.add_argument("--composition-jobs", type=int, default=2, choices=range(1, 17), metavar="1-16")
parser.add_argument("--composition-cache", type=pathlib.Path)
parser.add_argument("--trace-level", choices=("summary", "full"), default="summary", help="Diagnostic detail only; does not seed composition.")
parser.add_argument("--profile-output", type=pathlib.Path, help="Write build and per-role elapsed timings (not a search timeout).")
parser.add_argument("--progress", action="store_true")
args = parser.parse_args()
if not args.renderer_fingerprint and (args.data_dir is None or args.output_dir is None):
parser.error('--data-dir and --output-dir are required for generation')
return args
def generate_assets(args: argparse.Namespace) -> None:
def main() -> int:
args = parse_args()
data_dir = args.data_dir.resolve() data_dir = args.data_dir.resolve()
output_dir = args.output_dir.resolve() output_dir = args.output_dir.resolve()
static_assets_dir = args.static_assets_dir.resolve() static_assets_dir = args.static_assets_dir.resolve()
cache_dir = getattr(args, 'engraving_cache', None)
cache_dir = cache_dir.resolve() if cache_dir else None
if cache_dir and (cache_dir.is_relative_to(output_dir) or output_dir.is_relative_to(cache_dir)):
raise ValueError('engraving cache and generated assets must not overlap')
composition_cache = getattr(args, 'composition_cache', None)
if composition_cache:
composition_cache = composition_cache.resolve()
if composition_cache.is_relative_to(output_dir) or output_dir.is_relative_to(composition_cache):
raise ValueError('composition cache and generated assets must not overlap')
progress = getattr(args, 'progress', False)
started = time.monotonic()
timings = []
stages = []
last_stage = started
if args.clean and output_dir.exists(): def report(message):
shutil.rmtree(output_dir) nonlocal last_stage
output_dir.mkdir(parents=True, exist_ok=True) now = time.monotonic()
stages.append({'completed_before': message, 'seconds': now - last_stage})
last_stage = now
if progress:
print(f'Assets [{time.monotonic() - started:.0f}s]: {message}', flush=True)
fonts_src = static_assets_dir / "fonts" report('checking source paths')
if fonts_src.exists(): generator.parts.source_paths(data_dir / 'songs')
shutil.copytree(fonts_src, output_dir / "fonts", dirs_exist_ok=True) output_dir.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.vamp_assets_', dir=output_dir.parent) as temporary:
staged = pathlib.Path(temporary) / 'assets'
staged.mkdir(parents=True, exist_ok=True)
copy_runtime_fonts(static_assets_dir, staged)
report('generating performance parts')
performance_count = generator.production.generate_tree(data_dir / 'songs', staged / 'songs', jobs=getattr(args, 'composition_jobs', 2), cache_dir=getattr(args, 'composition_cache', None), progress=progress, timings=timings)
if getattr(args, 'trace_level', 'summary') == 'summary':
for path in (staged / 'analysis').glob('*.roles'):
roles = json.loads(path.read_text())
for role in roles.values():
trace = role.get('details', {}).get('selection_trace', {})
trace.pop('decisions', None)
path.write_text(json.dumps(roles, sort_keys=True, indent=2))
generator.score_io.validate_generated_tree(staged)
if args.check:
generator.summary.validate_expected_performance_summary(pathlib.Path(__file__).resolve().parents[1] / 'tests' / 'fixtures' / 'performance_summary.json', staged / 'songs')
report('copying supplied recordings')
song_ids = {generator.score.slugify(path.stem) for path in (data_dir / 'songs').glob('*.xml')}
audio_assets = generator.audio_assets.copy_audio(static_assets_dir, staged, song_ids)
generator.audio_assets.validate_recording_lengths(staged, audio_assets)
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)
report('generating printable parts')
generator.printing.generate_tree(staged, static_assets_dir / 'fonts' / 'petaluma', cache_dir=cache_dir, progress=progress)
report('rendering Pico titles')
generator.pico_artwork.generate_titles(staged, static_assets_dir / 'fonts' / 'petaluma')
report('validating score, page, and audio assets')
generator.score_io.validate_generated_tree(staged, validated_pages)
report('building catalog database')
generator.catalog.build_catalog(staged, audio_assets=audio_assets)
with modulus.filesystem.publish_directories([(staged, output_dir)]):
pass
report('publishing complete')
profile_path = getattr(args, 'profile_output', None)
if profile_path:
profile_path.parent.mkdir(parents=True, exist_ok=True)
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))
print(f'Generated Product Vamp assets: {performance_count} performance scores.')
if args.check:
print('Generator check passed.')
performance_count = generate_performance_scores(data_dir / "songs", output_dir / "songs") def main() -> int:
practice_count = generate_practice_scores(data_dir / "practice", output_dir / "practice") args = parse_args()
if args.renderer_fingerprint:
validate_generated_tree(output_dir) print(generator.engraving.cache.renderer_fingerprint(generator.engraving.fonts.Fonts(args.static_assets_dir / 'fonts' / 'petaluma'), generator.engraving.types.Style()))
print( else:
"Generated Product Vamp assets: " generate_assets(args)
f"{performance_count} performance scores, "
f"{practice_count} practice score variants, "
f"{count_json_files(output_dir)} total JSON files."
)
if args.check:
validate_expected_performance_summary(data_dir / "expected" / "performance_summary.json", output_dir / "songs")
validate_practice_variants(output_dir / "practice")
print("Generator check passed.")
return 0 return 0
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import fractions
import generator.model
def active_chord(measure: dict, onset: fractions.Fraction, previous: dict | None) -> dict | None:
active = previous
for chord in measure['harmonies']:
if generator.model.fraction(chord['onset']) > onset:
break
active = chord
return active if active and active['kind'] != 'none' else None
def carried_chords(measures: list[dict], initial: dict | None = None) -> list[dict | None]:
result = []
for measure in measures:
result.append(initial)
if measure['harmonies']:
initial = measure['harmonies'][-1]
return result
def same_chord(left: dict | None, right: dict | None) -> bool:
return left is not None and right is not None and all(left.get(k) == right.get(k) for k in ('root', 'kind', 'degrees', 'bass', 'inversion'))
def normalize_beams(events: list[dict]) -> None:
for level in sorted({beam['level'] for event in events for beam in event.get('beams', [])}):
groups, group = [], []
for event in events:
beam = next((b for b in event.get('beams', []) if b['level'] == level), None)
if not beam or beam['type'] in ('begin', 'forward hook', 'backward hook'):
groups.append(group)
group = []
if beam:
group.append((event, beam))
if beam['type'] in ('end', 'forward hook', 'backward hook'):
groups.append(group)
group = []
groups.append(group)
for group in groups:
for index, (event, beam) in enumerate(group):
if len(group) > 1:
beam['type'] = 'begin' if index == 0 else 'end' if index == len(group) - 1 else 'continue'
elif level > 1 and any(b['level'] == level - 1 for b in event['beams']):
beam['type'] = 'backward hook' if beam['type'] in ('end', 'backward hook') else 'forward hook'
else:
event['beams'].remove(beam)
for event in events:
if not event.get('beams'):
event.pop('beams', None)
def written_durations(length: fractions.Fraction):
while length:
written = generator.model.written_for(length)
duration = length
if 'ratio' in written and length.denominator & (length.denominator - 1) == 0:
duration = next((size for size in generator.model.TYPE_LENGTHS.values() if size < length), length)
written = generator.model.written_for(duration)
yield duration, written
length -= duration
def connect_continuations(document: dict) -> bool:
previous = {}
changed = False
for measure in document['staves'][0]['measures']:
if measure.get('section_label'):
previous.clear()
for voice in measure['voices']:
for note in voice['notes']:
continuation = note.pop('_rhythm_continuation', False)
source_note = previous.get(voice['id'])
if continuation and source_note and source_note['pitches'] and note['pitches']:
old = {(p['tablature']['string'], p['tablature']['fret']): p for p in source_note['pitches']}
for tone in note['pitches']:
source = old[(tone['tablature']['string'], tone['tablature']['fret'])]
tone['tie_from'] = source['id']
source['tie_to'] = tone['id']
changed = True
elif continuation:
note.update(kind='rest', pitches=[])
note.pop('beams', None)
changed = True
previous[voice['id']] = note
return changed
+184
View File
@@ -0,0 +1,184 @@
from __future__ import annotations
import dataclasses
import fractions
import itertools
import generator.accompaniment
import generator.model
import generator.score
import generator.harmonic_time
@dataclasses.dataclass
class HarmonySpan:
harmony: dict
location: str
notes: list[dict] = dataclasses.field(default_factory=list)
iteration: int = 0
def harmony_spans(document: dict) -> list[HarmonySpan]:
"""Group attacks by continuous harmony within each arrangement iteration."""
spans, span, active = [], None, None
iteration = 0
for measure in document['staves'][0]['measures']:
if measure.get('section_label'):
iteration += 1
span = None
if {v['id'] for v in measure['voices']} != {'bass', 'upper'}:
span, active = None, None
continue
events = [(generator.model.fraction(h['onset']), 0, h) for h in measure['harmonies']]
events.extend((generator.model.fraction(n['onset']), 1, n) for v in measure['voices'] for n in v['notes'] if n.get('_guitar_profile') == 'acoustic')
for onset, kind, item in sorted(events, key=lambda event: event[:2]):
harmony = item if kind == 0 else item['_harmony']
if not generator.accompaniment.same_chord(active, harmony):
span = None
active = harmony
if kind == 0:
continue
if span is None:
span = HarmonySpan(harmony, f'measure {measure["number"]}, event {item["id"]}, onset {onset}', iteration=iteration)
spans.append(span)
span.notes.append(item)
return spans
def measures(document: dict):
staff = document['staves'][0]
if staff.get('arrangement_part_type') == 'acoustic':
yield from (m for m in staff['measures'] if {v['id'] for v in m['voices']} == {'bass', 'upper'})
def prepare(document: dict) -> None:
"""Release each grip before the next attack; never require overlapping lanes."""
for measure in measures(document):
length = generator.model.fraction(measure['duration'])
attacks = sorted((n for v in measure['voices'] for n in v['notes'] if n['pitches'] or '_harmony' in n), key=lambda n: generator.model.fraction(n['onset']))
groups = [(time, list(notes)) for time, notes in itertools.groupby(attacks, key=lambda n: generator.model.fraction(n['onset']))]
for index, (start, notes) in enumerate(groups):
end = min(start + generator.model.fraction(n['duration']) for n in notes)
end = min(end, groups[index + 1][0] if index + 1 < len(groups) else length)
# A duration needing ties becomes one shorter attack followed by silence.
duration, written = next(generator.accompaniment.written_durations(end - start))
for note in notes:
if generator.model.fraction(note['duration']) != duration:
note.update(duration=generator.model.rational(duration), written=written)
note.pop('beams', None)
for pitch in note['pitches']:
pitch.update(duration=note['duration'], written=written)
for voice in measure['voices']:
notes, cursor = [], fractions.Fraction()
for note in voice['notes']:
start = generator.model.fraction(note['onset'])
if start > cursor:
notes.append(generator.model.make_event(f'{note["id"]}_release', cursor, start - cursor, 'rest') | {'voice': voice['id']})
notes.append(note)
cursor = start + generator.model.fraction(note['duration'])
if cursor < length:
notes.append(generator.model.make_event(f'{measure["id"]}_{voice["id"]}_release', cursor, length - cursor, 'rest') | {'voice': voice['id']})
voice['notes'] = notes
generator.accompaniment.normalize_beams(notes)
def combine(document: dict) -> None:
"""One rhythmic voice; split held pitches into ties rather than release them."""
originals = {p['id']: p for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']}
edges = [(p['id'], p['tie_to']) for p in originals.values() if p.get('tie_to')]
fragments = {}
for measure in measures(document):
length = generator.model.fraction(measure['duration'])
sounding = [(generator.model.fraction(n['onset']), generator.model.fraction(n['onset']) + generator.model.fraction(p['duration']), p)
for v in measure['voices'] for n in v['notes'] for p in n['pitches']]
boundaries = sorted({fractions.Fraction(), length, *(t for a, b, _ in sounding for t in (a, b))})
notes = []
for a, b in zip(boundaries, boundaries[1:]):
active = sorted((p for start, end, p in sounding if start <= a < end), key=lambda p: (generator.model.pitch_number(p), p['tablature']['string']))
for duration, written in generator.accompaniment.written_durations(b - a):
pitches = [{k: v for k, v in p.items() if k not in ('tie_from', 'tie_to', 'stem')} for p in active]
note = generator.model.make_event(f'{measure["id"]}_acoustic_{len(notes) + 1}', a, duration, 'note' if pitches else 'rest', pitches, written)
note['voice'] = 'voice_arranged'
for old, new in zip(active, note['pitches']):
fragments.setdefault(old['id'], []).append(new)
notes.append(note)
a += duration
if not sounding and length == generator.model.meter_length(measure['meter']):
notes = [generator.model.make_event(f'{measure["id"]}_acoustic_1', fractions.Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'voice': 'voice_arranged', 'measure_rest': True}]
measure['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
for pieces in fragments.values():
for a, b in zip(pieces, pieces[1:]):
a['tie_to'], b['tie_from'] = b['id'], a['id']
for start, end in edges:
if start in fragments or end in fragments:
a = fragments[start][-1] if start in fragments else originals[start]
b = fragments[end][0] if end in fragments else originals[end]
a['tie_to'], b['tie_from'] = b['id'], a['id']
def sounding_lanes(measures):
"""Recover complete string-lane events, preserving ties versus reattacks."""
tones, starts, cursor = {}, {}, fractions.Fraction()
for measure in measures:
for note in generator.score.measure_notes(measure):
for pitch in note['pitches']:
tones[pitch['id']] = pitch
starts[pitch['id']] = cursor + generator.model.fraction(note['onset'])
cursor += generator.model.fraction(measure['duration'])
groups = {}
for identifier, pitch in tones.items():
if pitch.get('tie_from') in tones:
if tones[pitch['tie_from']].get('tie_to') != identifier:
raise ValueError('broken acoustic sustain link')
continue
start, tail = starts[identifier], pitch
seen = {identifier}
while tail.get('tie_to') in tones:
target = tail['tie_to']
if target in seen:
raise ValueError('cyclic acoustic sustain')
continuation = tones[target]
if continuation.get('tie_from') != tail['id'] or starts[target] != starts[tail['id']] + generator.model.fraction(tail['duration']):
raise ValueError('broken acoustic sustain link or timing')
if generator.model.pitch_number(continuation) != generator.model.pitch_number(tail) or continuation.get('tablature') != tail.get('tablature'):
raise ValueError('acoustic sustain changes pitch or position')
seen.add(target)
tail = continuation
end = starts[tail['id']] + generator.model.fraction(tail['duration'])
string = pitch.get('tablature', {}).get('string')
if string is None:
raise ValueError('acoustic comping requires placed notes')
groups.setdefault((start, end), set()).add('bass' if string >= 5 else 'upper')
return groups
def validate_assignment(measures, events):
expected = {}
for e in events:
lanes = ['bass', 'upper'] if e.lane == 'combined' else [e.lane]
expected.setdefault((e.start, e.end), set()).update(lanes)
if sounding_lanes(measures) != expected:
raise ValueError('rendered acoustic attacks disagree with the selected bass/upper assignment')
def validate_comping(document: dict) -> None:
"""Check complete sounding events, not barline or notation fragments."""
groups = sounding_lanes(document['staves'][0]['measures'])
events = []
for (start, end), lanes in groups.items():
if end <= start or (end - start < 1 and end - start != fractions.Fraction(1, 2)):
raise ValueError('acoustic comping requires quarter/eighth notes or authored sustains')
events.append((start, end))
events.sort()
# A combined attack is one interval with both lanes. Separate attacks must
# be sequential, including bass/upper alternation and cross-bar ties.
for i, a in enumerate(events):
for b in events[i + 1:]:
if a[1] > b[0]:
raise ValueError('acoustic sound must release before the next attack')
spans = []
for span in generator.harmonic_time.read(document['staves'][0]):
if spans and generator.accompaniment.same_chord(spans[-1].chord, span.chord):
spans[-1] = dataclasses.replace(spans[-1], end=span.end)
else:
spans.append(span)
for span in spans:
if span.chord is None:
continue
first = next((event for event in events if span.start <= event[0] < span.end), None)
# A bounded anticipated bass can already be sounding into this harmony.
anticipated = any(span.start - fractions.Fraction(1, 2) <= a < span.start < b and 'bass' in groups[a, b] for a, b in events)
if first and not anticipated and not any('bass' in groups[e] for e in events if e[0] == first[0]):
raise ValueError(f'measure {span.source_measure}, beat {span.beat}: first acoustic attack for a chord must include bass')
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
import dataclasses
import functools
import itertools
import typing
import generator.acoustic
import generator.fretboard
import generator.harmony
import generator.hand_position
class Voicing(typing.NamedTuple):
bass: generator.fretboard.TabPosition
string4: generator.fretboard.TabPosition
string3: generator.fretboard.TabPosition
string2: generator.fretboard.TabPosition
def playable(voicing: Voicing) -> bool:
return voicing.bass.string_number in (5, 6) and tuple(p.string_number for p in voicing[1:]) == (4, 3, 2) and all(generator.fretboard.MIN_FRET <= p.fret <= generator.fretboard.MAX_FRET for p in voicing) and generator.fretboard.has_fret_anchor(voicing) and generator.fretboard.within_fret_span(voicing) and voicing.bass.midi == min(p.midi for p in voicing)
@functools.lru_cache(maxsize=256)
def candidates(requirements: generator.fretboard.ChordRequirements, complete_triad: bool = False) -> tuple[Voicing, ...]:
options = {s: tuple(generator.fretboard.TabPosition(s, f, midi + f) for f in range(generator.fretboard.MIN_FRET, generator.fretboard.MAX_FRET + 1) if (midi + f) % 12 in requirements.allowed) for s, midi in generator.fretboard.STANDARD_TUNING if s != 1}
result = []
for bass in (*options[5], *options[6]):
if requirements.basses and bass.midi % 12 not in requirements.basses:
continue
for upper in itertools.product(*(options[s] for s in (4, 3, 2))):
voicing = Voicing(bass, *upper)
if playable(voicing) and requirements.covers({p.midi % 12 for p in voicing}):
result.append(voicing)
if complete_triad:
complete = [v for v in result if set(requirements.allowed) <= {p.midi % 12 for p in v}]
result = complete or result
return tuple(sorted(result))
@functools.lru_cache(maxsize=16384)
def movement(previous: Voicing, current: Voicing, old_hand: int, hand: int) -> int:
return generator.hand_position.motion(previous, current, old_hand, hand, True).shift
class Cost(typing.NamedTuple):
extended_grips: int = 0
travel: int = 0
peak_position: int = 0
effort: int = 0
missing_tones: int = 0
@dataclasses.dataclass
class Placement:
cost: Cost
voicing: Voicing | None = None
previous: Placement | None = None
hand: int = 0
def advance(options: tuple[Voicing, ...], requirements: generator.fretboard.ChordRequirements, states: list[Placement]) -> list[Placement]:
result = {}
for voicing in options:
missing = len(set(requirements.allowed) - {p.midi % 12 for p in voicing})
for hand in generator.hand_position.regions(voicing):
for previous in states:
motion = generator.hand_position.motion(previous.voicing, voicing, previous.hand, hand, True)
cost = Cost(previous.cost.extended_grips + generator.fretboard.span_penalty(voicing), previous.cost.travel + motion.shift, max(previous.cost.peak_position, hand), previous.cost.effort + motion.ergonomics, previous.cost.missing_tones + missing)
key = (voicing, hand, cost.peak_position)
if key not in result or cost < result[key].cost:
result[key] = Placement(cost, voicing, previous, hand)
return list(result.values())
def select(spans: list[generator.acoustic.HarmonySpan]) -> list[Voicing]:
states = [Placement(Cost())]
for span in spans:
try:
requirements = generator.fretboard.chord_requirements(span.harmony)
options = candidates(requirements, generator.harmony.is_triad(span.harmony))
lanes = {n['_guitar_lane'] for n in span.notes}
options = tuple(v for v in options if ('bass' not in lanes or v.bass.fret <= generator.fretboard.MAX_SINGLE_FRET) and ('upper' not in lanes or generator.fretboard.has_fret_anchor(v[1:])))
if not options:
raise ValueError(f'no acoustic grip on strings 2-4 plus bass 5 or 6 within frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET}, at most {generator.fretboard.MAX_FRET_SPAN} consecutive fret positions; played bass notes need frets 1-{generator.fretboard.MAX_SINGLE_FRET} and played upper chords need their own anchor <= {generator.fretboard.MAX_ANCHOR_FRET}')
except ValueError as error:
raise ValueError(f'{span.location}, {generator.harmony.symbol(span.harmony)}: {error}') from error
states = advance(options, requirements, states)
selected = min(states, key=lambda p: (p.cost, p.voicing))
result = []
while selected.previous is not None:
result.append(selected.voicing)
selected = selected.previous
return list(reversed(result))
def place(document: dict) -> None:
"""Plan every iteration before materializing its four string-voice journeys."""
for measure in document['staves'][0]['measures']:
for state in (measure, *measure.get('attributes', [])):
if 'staff_details' in state:
generator.fretboard.validate_tuning(state, measure['id'])
choices = []
for _, group in itertools.groupby(generator.acoustic.harmony_spans(document), key=lambda span: span.iteration):
spans = list(group)
choices.extend(zip(spans, select(spans)))
for span, voicing in choices:
for note in span.notes:
positions = (voicing.bass,) if note['_guitar_lane'] == 'bass' else voicing[1:]
for pitch in generator.fretboard.chord_pitches(span.harmony, positions):
note['pitches'].append({**pitch, 'id': f'{note["id"]}_s{pitch["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']})
for field in ('_harmony', '_guitar_profile', '_guitar_lane'):
note.pop(field, None)
document['staves'][0]['tablature'] = True
generator.fretboard.validate_document(document)
-1
View File
@@ -1,2 +1 @@
"""Performance arrangement generation.""" """Performance arrangement generation."""
+70
View File
@@ -0,0 +1,70 @@
"""One learned accompaniment per instrument, reused at authored form positions."""
from __future__ import annotations
import fractions
import generator.accompaniment
import generator.arrange.form
import generator.arrange.pickup
import generator.model
import generator.score
def harmonic_key(measure, previous):
def key(chord):
return repr({k: chord.get(k) for k in ('root', 'kind', 'degrees', 'bass', 'inversion')}) if chord else ''
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']
if generator.model.fraction(h['onset']) > 0), measure['duration'], measure['time_signature'])
def audit(source, documents, *, role_documents=False):
"""Compare music, not generated IDs, at every reused form position."""
authored = generator.score.source_staff(source)['measures']
result = {}
for name, document in documents.items():
part = name.split('_')[0] if role_documents else name
wanted = {'solo': 'slash', 'melody': 'vocal' if part == 'vocal' else 'melody'}.get(name.split('_')[-1], 'accompaniment') if role_documents else 'accompaniment'
if part == 'vocal' and not role_documents:
continue
sections = generator.arrange.form.full_arrangement_sections(authored, part)
entry_positions = {s.source_measure_index for s in sections if s.role == wanted}
measures = document['staves'][0]['measures']
carried = generator.accompaniment.carried_chords(measures)
seen, exceptions, offset, reused = {}, [], 0, 0
for si, section in enumerate(sections):
following = sections[si + 1] if si + 1 < len(sections) else None
for j in range(section.measure_count):
if section.role != wanted:
continue
m = measures[offset + j]
if wanted == 'accompaniment' and 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'})
continue
if wanted == 'accompaniment' and j == section.measure_count - 1 and following and following.role == 'slash' and generator.score.body_start_measure_index(authored):
exceptions.append({'measure': m['number'], 'reason': 'source-defined solo pickup allocation'})
continue
position = section.source_measure_index + j
# A complete figure depends on both measures' effective harmony and
# source-defined participation. Changed final turnarounds/pickups are
# explicit contexts, not incidental changes between iterations.
first = generator.score.body_start_measure_index(authored)
block_start = first + 2 * ((position - first) // 2)
neighbors = range(max(section.source_measure_index, block_start), min(section.source_measure_index + section.measure_count, block_start + 2))
context = tuple(repr(harmonic_key(measures[offset + k - section.source_measure_index], carried[offset + k - section.source_measure_index])) for k in neighbors)
boundary = following.role if following and block_start + 2 >= section.source_measure_index + section.measure_count and generator.score.body_start_measure_index(authored) and following.role in ('slash', 'melody') else ''
key = (position, context, boundary)
signature = tuple((v['id'], generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
tuple((generator.model.pitch_number(p), generator.model.fraction(p['duration']), p.get('tablature', {}).get('string'), p.get('tablature', {}).get('fret'), bool(p.get('tie_from')) if position not in entry_positions or generator.model.fraction(n['onset']) else False, bool(p.get('tie_to'))) for p in n['pitches']))
for v in m['voices'] for n in v['notes'])
if key in seen:
if signature != seen[key]:
raise ValueError(f'{name}, arrangement measure {m["number"]}: canonical {wanted} changed')
reused += 1
else:
if any(k[0] == key[0] for k in seen):
exceptions.append({'measure': m['number'], 'reason': 'changed two-bar harmony, duration, or protected participation'})
seen[key] = signature
offset += section.measure_count
result[name] = {'reused_bars': reused, 'distinct_form_bars': len(seen), 'exceptions': exceptions}
return result
+119
View File
@@ -0,0 +1,119 @@
"""Assemble source-defined role windows without composing or respelling."""
from __future__ import annotations
import copy
import fractions
import generator.accompaniment
import generator.arrange.form
import generator.arrange.pickup
import generator.composition_types
import generator.fretboard
import generator.model
import generator.parts
import generator.score
import generator.validation
def mask(document, allowed):
"""Apply source-defined role exclusions, without observing another part."""
absolute = fractions.Fraction()
for measure in document['staves'][0]['measures']:
length = generator.model.fraction(measure['duration'])
for voice in measure['voices']:
selected = []
for n in voice['notes']:
start = absolute + generator.model.fraction(n['onset'])
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]
for a, b in intervals:
note = copy.deepcopy(n)
if a != start:
note['onset'] = generator.model.rational(a - absolute)
for pitch in note['pitches']:
pitch.pop('tie_from', None)
generator.arrange.pickup.truncate(note, b - absolute)
if a < b:
selected.append(note)
voice['notes'] = fill(selected, length, measure['id'] + '_' + voice['id'], voice['id'])
absolute += length
staff = document['staves'][0]
staff['tuplets'] = generator.arrange.pickup.retain_links(staff['measures'], staff.get('tuplets', []))
def fill(notes, length, prefix, voice='voice_arranged'):
notes = sorted(notes, key=lambda n: generator.model.fraction(n['onset']))
result = []
cursor = fractions.Fraction()
def rests(end):
nonlocal cursor
for duration, written in generator.accompaniment.written_durations(end - cursor):
result.append(generator.model.make_event(prefix + '_rest_' + str(len(result)), cursor, duration, 'rest', written=written) | {'voice': voice})
cursor += duration
for n in notes:
start = generator.model.fraction(n['onset'])
if start < cursor:
raise ValueError(prefix + ': overlapping independently generated roles')
rests(start)
n['voice'] = voice
result.append(n)
cursor = start + generator.model.fraction(n['duration'])
rests(length)
return result
def improvisation(source, template, part):
"""Fill the source's solo allocation, including pickups, with beat slashes."""
document = copy.deepcopy(template)
staff = document['staves'][0]
for field in ('tuplets', 'octave_shifts', 'scale_spans'):
staff[field] = []
for measure in staff['measures']:
measure['id'] = part + '_improv_' + measure['id']
measure['voices'] = [{'id': 'voice_arranged', 'notes': generator.parts.generated_voice(measure, 'slash')}]
generator.score.reidentify(staff['measures'], [])
plan = generator.arrange.form.FormPlan.read(source.document(), part)
mask(document, [(a, b) for a, b, role in plan.windows if role == part + '_solo'])
return document
def assemble(source, results, *, solos_enabled=True):
"""Union prevalidated role windows; no selection or pitch/fingering rewrite."""
documents = {}
for part in generator.parts.PART_TYPES:
selected = [results[r]['document'] for r in generator.composition_types.ROLES
if r.startswith(part + '_') and (solos_enabled or not r.endswith('_solo'))]
if not solos_enabled and part != 'vocal':
selected.append(improvisation(source, selected[0], part))
document = copy.deepcopy(selected[0])
staff = document['staves'][0]
if part == 'vocal':
documents[part] = document
continue
staff['tuplets'] = []
staff['octave_shifts'] = []
staff['scale_spans'] = []
for other in selected:
for field in ('tuplets', 'octave_shifts', 'scale_spans'):
staff[field].extend(copy.deepcopy(other['staves'][0].get(field, [])))
for i, m in enumerate(staff['measures']):
# Tuplet rests belong to the source/role rhythm and must survive union.
lanes = {}
for d in selected:
for voice in d['staves'][0]['measures'][i]['voices']:
lane = voice['id'] if part == 'acoustic' and voice['id'] in ('upper', 'bass') else 'voice_arranged'
sounding = [copy.deepcopy(n) for n in voice['notes'] if n['pitches'] or n.get('tuplet_ids') or n.get('notehead') == 'slash']
if sounding:
lanes.setdefault(lane, []).extend(sounding)
lanes = lanes or {'voice_arranged': []}
m['voices'] = [{'id': lane, 'notes': fill(notes, generator.model.fraction(m['duration']), m['id'] + '_' + lane, lane)} for lane, notes in lanes.items()]
# Attribute changes belong to source melody pickups and are assembly metadata.
for d in selected:
other = d['staves'][0]['measures'][i]
if other.get('attributes'):
m['attributes'] = copy.deepcopy(other['attributes'])
staff['tuplets'] = generator.arrange.pickup.retain_links(staff['measures'], staff['tuplets'])
owners = {n['id']: v['id'] for m in staff['measures'] for v in m['voices'] for n in v['notes']}
for span in staff['tuplets']:
span['voice'] = owners[span['start']]
generator.validation.validate_document(document)
generator.fretboard.validate_document(document)
documents[part] = document
return documents
+72
View File
@@ -0,0 +1,72 @@
"""The recorded three-bar hold, expressed once as source-derived score time."""
import copy
import fractions
import generator.accompaniment
import generator.acoustic_voicing
import generator.arrange.ending_plan
import generator.comping
import generator.fretboard
import generator.model
import generator.parts
import generator.score
import generator.vocabulary
def chord(source):
effective = [{**m, 'harmonies': [h for h in m['harmonies'] if not h.get('chord_optional')]} for m in source]
prior = generator.accompaniment.carried_chords(effective)[-1]
return generator.accompaniment.active_chord(effective[-1], generator.model.fraction(effective[-1]['duration']), prior)
def append(source, output, part, *, sounding=False, center=55, composition=None, incoming=None):
composition = composition or generator.vocabulary.current()
if not source or not output:
return
model = generator.arrange.ending_plan.template(source)
harmony = chord(source)
grip = ()
if sounding:
if harmony is None:
raise ValueError('Ending: final performed harmony is missing or N.C.; cannot invent a closing chord')
options = generator.comping.voicings(generator.comping.semantic_key(harmony), part, composition=composition)
recent = {}
for m in reversed(output):
for n in sorted(generator.score.measure_notes(m), key=lambda n: generator.model.fraction(n['onset']), reverse=True):
for p in n['pitches']:
if 'tablature' in p:
tab = p['tablature']
recent.setdefault(tab['string'], generator.fretboard.TabPosition(tab['string'], tab['fret'], int(generator.model.pitch_number(p)) - 12))
if len(recent) >= 4:
break
previous = tuple(recent[s] for s in sorted(recent, reverse=True))
if part == 'acoustic':
bass = next((recent[s] for s in (5, 6) if s in recent), None)
previous = generator.acoustic_voicing.Voicing(bass, *(recent[s] for s in (4, 3, 2))) if bass and all(s in recent for s in (4, 3, 2)) else ()
if incoming is not None:
previous, hand = incoming
else:
hand = None
costs = {v: generator.comping.local_cost(part, generator.comping.semantic_key(harmony), v, center, composition=composition) for v in options}
path, _, _ = generator.comping.select_grips((options,), (costs,), part, 'ending-position', left=previous, left_hand=hand)
grip = path[0]
length = generator.model.fraction(model['duration'])
previous = None
for index in range(generator.arrange.ending_plan.BARS):
measure = generator.parts.build_measure(model, f'measure_{len(output) + 1}', 'rest', part, None, 'Ending' if index == 0 else '')
measure['ending_sustain'] = True
if index == 0 and harmony:
measure['harmonies'] = [{**copy.deepcopy(harmony), 'onset': generator.model.rational(0)}]
measure['harmonies'][0].pop('chord_optional', None)
if grip:
notes, cursor = [], fractions.Fraction()
for duration, written in generator.accompaniment.written_durations(length):
n = generator.model.make_event(f'ending_{part}_{index}_{len(notes)}', cursor, duration, 'note', generator.fretboard.chord_pitches(harmony, grip), written)
n['voice'] = 'voice_arranged'
if previous:
for a, b in zip(previous['pitches'], n['pitches']):
a['tie_to'], b['tie_from'] = b['id'], a['id']
notes.append(n)
previous = n
cursor += duration
measure['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
output.append(measure)
generator.score.reidentify(output[-generator.arrange.ending_plan.BARS:], [])
+22
View File
@@ -0,0 +1,22 @@
"""Source-derived ending geometry, independent of instrument composition."""
import copy
import fractions
import generator.model
BARS = 3
def template(source):
result = copy.deepcopy(source[-1])
# An ending is three full bars in the final active meter, never a pickup.
for change in result.get('attributes', []):
for key in ('meter', 'key', 'clef', 'transpose', 'staff_details'):
if key in change:
result[key] = copy.deepcopy(change[key])
result.update(duration=generator.model.rational(generator.model.meter_length(result['meter'])), implicit=False,
attributes=[], harmonies=[], voices=[], tempos=[], section_label='')
result['time_signature'] = generator.model.meter_label(result['meter'])
return result
def duration(source):
return BARS * generator.model.fraction(template(source)['duration']) if source else fractions.Fraction()
+69 -19
View File
@@ -1,21 +1,21 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass import dataclasses
from typing import Literal import fractions
import typing
from generator.score import body_start_measure_index import generator.arrange.ending_plan
import generator.arrange.pickup
import generator.model
import generator.score
SectionRole = Literal["rhythm", "accompaniment", "melody", "vocal", "rest", "slash"] @dataclasses.dataclass(frozen=True)
@dataclass(frozen=True)
class ArrangementSection: class ArrangementSection:
label: str label: str
source_measure_index: int source_measure_index: int
measure_count: int measure_count: int
section_index: int section_index: int
role: SectionRole role: typing.Literal["accompaniment", "melody", "vocal", "rest", "slash"]
def phrase_aware_edge_measure_count(body_measure_count: int) -> int: def phrase_aware_edge_measure_count(body_measure_count: int) -> int:
if body_measure_count >= 8: if body_measure_count >= 8:
@@ -24,25 +24,23 @@ def phrase_aware_edge_measure_count(body_measure_count: int) -> int:
return 4 return 4
return body_measure_count return body_measure_count
def section_role(part_type: str, section_index: int) -> typing.Literal["accompaniment", "melody", "vocal", "rest", "slash"]:
def section_role(part_type: str, section_index: int) -> SectionRole:
if part_type == "acoustic": if part_type == "acoustic":
return "slash" if section_index == 3 else "rhythm" return "slash" if section_index == 3 else "accompaniment"
if part_type == "electric": if part_type == "electric":
if section_index == 4: if section_index == 4:
return "slash" return "slash"
if section_index == 2: if section_index == 2:
return "melody" return "melody"
if section_index in {1, 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:
return [] return []
body_start = body_start_measure_index(measures) body_start = generator.score.body_start_measure_index(measures)
if body_start >= len(measures): if body_start >= len(measures):
return [] return []
body_measure_count = len(measures) - body_start body_measure_count = len(measures) - body_start
@@ -52,10 +50,9 @@ def full_arrangement_sections(measures: list[dict], part_type: str) -> list[Arra
("Rhythm Lead-in", edge_source_measure, edge_measure_count, 0), ("Rhythm Lead-in", edge_source_measure, edge_measure_count, 0),
("Vocal Melody", body_start, body_measure_count, 1), ("Vocal Melody", body_start, body_measure_count, 1),
("Guitar Melody", body_start, body_measure_count, 2), ("Guitar Melody", body_start, body_measure_count, 2),
("Guitar Solo", body_start, body_measure_count, 3), ("Acoustic Solo", body_start, body_measure_count, 3),
("Guitar 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))
@@ -63,3 +60,56 @@ def full_arrangement_sections(measures: list[dict], part_type: str) -> list[Arra
if count > 0 if count > 0
] ]
@dataclasses.dataclass(frozen=True)
class FormPlan:
sections: tuple[ArrangementSection, ...]
windows: tuple[tuple[fractions.Fraction, fractions.Fraction, str], ...]
stops: tuple[tuple[int, fractions.Fraction] | None, ...]
@classmethod
def read(cls, document, part):
source = generator.score.source_staff(document)['measures']
sections = tuple(full_arrangement_sections(source, part))
stops = tuple(section_stop(source, section, sections[i + 1] if i + 1 < len(sections) else None) for i, section in enumerate(sections))
return cls(sections, _windows(document, part, sections), stops)
def section_stop(source, section, following):
if following is None or not generator.score.body_start_measure_index(source):
return None
last = section.source_measure_index + section.measure_count - 1
pad = generator.arrange.pickup.offset(source[last], source[0])
if following.role == 'slash':
return last, pad
if following.role == 'melody' and generator.arrange.pickup.enters(following, source):
attacks = [generator.model.fraction(note['onset']) for note in generator.score.measure_notes(source[0]) if note['pitches']]
if attacks:
return last, pad + min(attacks)
return None
def windows(document, part):
return FormPlan.read(document, part).windows
def _windows(document, part, sections):
source = generator.score.source_staff(document)['measures']
result, cursor = [], fractions.Fraction()
for section in sections:
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)
if section.role in ('vocal', 'melody') and generator.arrange.pickup.enters(section, source) and result:
attacks = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(source[0]) if n['pitches']]
if attacks:
start = cursor - generator.model.fraction(source[0]['duration']) + min(attacks)
a, _, old = result[-1]
result[-1] = (a, start, old)
result.append((start, cursor, part + '_' + role))
end = cursor + duration
start = cursor
if section.role == 'slash' and generator.score.body_start_measure_index(source) and result:
# Allocate the source's complete pickup bar before composition. This
# does not inspect either generated solo; a role may leave it silent.
start = cursor - generator.model.fraction(source[0]['duration'])
a, _, old = result[-1]
result[-1] = (a, start, old)
result.append((start, end, part + '_' + role))
cursor = end
result.append((cursor, cursor + generator.arrange.ending_plan.duration(source), part + ('_accompaniment' if part != 'vocal' else '_rest')))
return tuple(result)
-218
View File
@@ -1,218 +0,0 @@
from __future__ import annotations
from itertools import product
from typing import NamedTuple
from generator.transposition import mod12
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
class TabPosition(NamedTuple):
string_number: int
fret: int
midi: int
class ChordSymbol(NamedTuple):
root: str
quality: str
bass: str
ROOTS = {
"C": 0,
"C#": 1,
"Db": 1,
"D": 2,
"D#": 3,
"Eb": 3,
"E": 4,
"F": 5,
"F#": 6,
"Gb": 6,
"G": 7,
"G#": 8,
"Ab": 8,
"A": 9,
"A#": 10,
"Bb": 10,
"B": 11,
}
def parse_root(symbol: str) -> tuple[str, str] | None:
symbol = symbol.strip()
if not symbol or symbol[0].upper() not in "ABCDEFG":
return None
length = 1
if len(symbol) > 1 and symbol[1] in {"#", "b"}:
length = 2
return symbol[:length], symbol[length:]
def parse_chord_symbol(symbol: str) -> ChordSymbol | None:
clean = symbol.strip().replace(" ", "")
base, _, slash = clean.partition("/")
parsed = parse_root(base)
if parsed is None:
return None
root, quality = parsed
bass = ""
if slash:
bass_parsed = parse_root(slash)
if bass_parsed is not None:
bass = bass_parsed[0]
return ChordSymbol(root, quality, bass)
def unique_intervals(values: tuple[int, ...]) -> tuple[int, ...]:
result: list[int] = []
seen: set[int] = set()
for value in values:
interval = mod12(value)
if interval in seen:
continue
seen.add(interval)
result.append(interval)
return tuple(result)
def color_intervals(value: str) -> tuple[int, ...]:
colors: list[int] = []
if "alt" in value:
colors.append(1)
if "b9" in value:
colors.append(1)
if "#9" in value:
colors.append(3)
if "9" in value and "b9" not in value and "#9" not in value:
colors.append(2)
if "#11" in value:
colors.append(6)
elif "11" in value:
colors.append(5)
if "b13" in value:
colors.append(8)
elif "13" in value:
colors.append(9)
return tuple(colors)
def chord_intervals(quality: str) -> tuple[int, ...]:
value = quality.lower().replace(" ", "")
if "7b5" in value or "ø" in value or "half" in value:
return (0, 3, 6, 10)
if "dim" in value or "o" in value:
return (0, 3, 6, 9)
colors = color_intervals(value)
if "aug" in value or "+" in value:
return unique_intervals((0, 4, 8, 10) + colors) if ("7" in value or colors) else (0, 4, 8)
if "sus" in value:
return unique_intervals((0, 5, 10) + colors) if ("7" in value or colors) else (0, 5, 7)
minor = value.startswith("-") or (value.startswith("m") and not value.startswith(("maj", "ma")))
if minor:
if "6" in value and "7" not in value:
return unique_intervals((0, 3, 7, 9) + colors)
if "7" in value or colors:
return unique_intervals((0, 3, 10) + colors)
return (0, 3, 7)
if "maj" in value or value.startswith("ma") or "Δ" in value:
return unique_intervals((0, 4, 11) + colors)
if "6" in value and "7" not in value:
return unique_intervals((0, 4, 7, 9) + colors)
if "7" in value or colors:
return unique_intervals((0, 4, 10) + colors)
return (0, 4, 7)
def pitch_class_for_root(root: str) -> int | None:
return ROOTS.get(root[0].upper() + root[1:], ROOTS.get(root.upper()))
def chord_pitch_classes(symbol: str) -> tuple[int, ...]:
parsed = parse_chord_symbol(symbol)
if parsed is None:
return ()
root_pc = pitch_class_for_root(parsed.root)
if root_pc is None:
return ()
pitch_classes = [mod12(root_pc + interval) for interval in chord_intervals(parsed.quality)]
bass_pc = pitch_class_for_root(parsed.bass) if parsed.bass else None
if bass_pc is not None and bass_pc not in pitch_classes:
pitch_classes.append(bass_pc)
return tuple(pitch_classes)
def positions_for_string(string_number: int, open_midi: int, pitch_classes: tuple[int, ...]) -> list[TabPosition]:
result: list[TabPosition] = []
for fret in range(0, 13):
midi = open_midi + fret
if mod12(midi) in pitch_classes:
result.append(TabPosition(string_number, fret, midi))
return result
def candidate_score(candidate: tuple[TabPosition, ...], pitch_classes: tuple[int, ...]) -> int:
fretted = [position.fret for position in candidate if position.fret > 0]
if not fretted:
span = 0
low = 0
else:
span = max(fretted) - min(fretted)
low = min(fretted)
covered = {mod12(position.midi) for position in candidate}
missing = len(set(pitch_classes) - covered)
open_penalty = sum(1 for position in candidate if position.fret == 0)
return missing * 1000 + span * 80 + abs(low - 5) * 12 + open_penalty * 8 + sum(position.fret for position in candidate)
def find_playable_voicing(symbol: str) -> list[TabPosition]:
pitch_classes = chord_pitch_classes(symbol)
if not pitch_classes:
return []
string_sets = ((6, 4, 3, 2), (5, 4, 3, 2), (4, 3, 2, 1), (6, 5, 4, 3), (5, 3, 2, 1))
by_string = {string_number: positions_for_string(string_number, open_midi, pitch_classes) for string_number, open_midi in STANDARD_TUNING}
best: tuple[TabPosition, ...] | None = None
best_score = 10**9
for string_set in string_sets:
options = [by_string[string_number][:8] for string_number in string_set]
if any(not item for item in options):
continue
for candidate in product(*options):
fretted = [position.fret for position in candidate if position.fret > 0]
if fretted and max(fretted) - min(fretted) > 4:
continue
covered = {mod12(position.midi) for position in candidate}
if len(covered & set(pitch_classes)) < min(3, len(set(pitch_classes))):
continue
score = candidate_score(candidate, pitch_classes)
if score < best_score:
best = candidate
best_score = score
return sorted(best or [], key=lambda item: item.midi)
def pitch_from_midi(midi: int) -> dict[str, int | str]:
names = (
("c", "n"),
("c", "#"),
("d", "n"),
("e", "b"),
("e", "n"),
("f", "n"),
("f", "#"),
("g", "n"),
("a", "b"),
("a", "n"),
("b", "b"),
("b", "n"),
)
letter, accidental = names[mod12(midi)]
return {"letter": letter, "accidental": accidental, "octave": midi // 12 - 1}
def pitches_for_voicing(symbol: str) -> list[dict[str, int | str]]:
return [pitch_from_midi(position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET) for position in find_playable_voicing(symbol)]
+116 -270
View File
@@ -1,279 +1,125 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path import copy
import fractions
from generator.arrange.form import ArrangementSection, full_arrangement_sections import generator.accompaniment
from generator.arrange.fretboard import pitches_for_voicing import generator.acoustic
from generator.score import ( import generator.acoustic_voicing
QUARTER_TICKS, import generator.arrange.ending
apply_source_chord_markers, import generator.arrange.form
active_chord_at_offset, import generator.arrange.pickup
chord_markers, import generator.comping
chord_symbol_at_offset, import generator.electric
copy_source_voice, import generator.fretboard
first_chord_symbol, import generator.guitar
first_voice, import generator.melody
leading_pickup_measure_index, import generator.model
load_json, import generator.parts
make_arranged_document, import generator.score
make_measure, import generator.vocabulary
measure_capacity_ticks,
renderable_duration,
renderable_rest_notes,
rest_note,
sanitized_note,
slash_note,
slugify,
source_staff,
source_note_attack_at,
write_json,
)
PART_TYPES = ("acoustic", "electric", "vocal") FINAL_SECTION_INDEX = 5
def arrange_document(document: dict, part_type: str, *, composition=None) -> dict:
"""Canonical source-only template; solos are inserted by production roles."""
composition = composition or generator.vocabulary.CompositionContext.create(generator.vocabulary.load(), part_type + '_accompaniment')
document = generator.parts.prepare_document(document)
backing = generator.comping.Canonical(document, part_type, 'canonical', composition=composition) if part_type != 'vocal' else None
return _arrange_document(document, part_type, accompaniment=backing, composition=composition)
FINAL_SECTION_INDEX = 6 def section_source(source: list[dict], section, next_section=None) -> list[dict]:
result = copy.deepcopy(source)
for measure in result:
def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> list[str]: measure['harmonies'] = [h for h in measure['harmonies'] if section.section_index != FINAL_SECTION_INDEX or not h.get('chord_optional')]
result: list[str] = [] for chord in measure['harmonies']:
current = "" chord.pop('chord_optional', None)
for measure in measures: if generator.arrange.pickup.enters(next_section, source):
current = first_chord_symbol(measure, current, include_optional=include_optional) destination = result[section.source_measure_index + section.measure_count - 1]
result.append(current) generator.arrange.pickup.harmonies(destination, result[0])
return result return result
def _arrange_document(document: dict, part_type: str, plan=None, *, owned=None, accompaniment=None, composition=None, placement_report=None) -> dict:
def playable_chord_note(note_id: str, duration_ticks: int, voicing_chord_symbol: str, visible_chord_symbol: str = "") -> dict: composition = composition or generator.vocabulary.current()
note = { if part_type not in generator.parts.PART_TYPES:
"id": note_id, raise ValueError(f'unknown arrangement part: {part_type}')
"kind": "note", staff = generator.score.source_staff(document)
"duration_ticks": duration_ticks, source = staff['measures']
"pitches": pitches_for_voicing(voicing_chord_symbol), plan = plan or generator.arrange.form.FormPlan.read(document, part_type)
"hidden": False, sections = plan.sections
} output, tuplets, octave_shifts, voicing_origins = [], [], [], []
if not note["pitches"]: absolute = fractions.Fraction()
return rest_note(note_id, duration_ticks, visible_chord_symbol) source_starts = []
if visible_chord_symbol: source_time = fractions.Fraction()
note["chord_symbol"] = visible_chord_symbol for m in source:
return note source_starts.append(source_time)
source_time += generator.model.fraction(m['duration'])
for index, section in enumerate(sections):
def rhythm_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]: role = section.role if owned is None or section.role in owned else 'rest'
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4"))) section_measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None)
hit_offsets = (0, QUARTER_TICKS + QUARTER_TICKS // 2, QUARTER_TICKS * 2, QUARTER_TICKS * 2 + QUARTER_TICKS // 2, QUARTER_TICKS * 3 + QUARTER_TICKS // 2) previous = generator.accompaniment.carried_chords(section_measures)
hit_duration = QUARTER_TICKS // 2 segment = []
notes: list[dict] = [] stop = plan.stops[index]
cursor = 0 next_section = sections[index + 1] if index + 1 < len(sections) else None
for offset in hit_offsets: for si in range(section.source_measure_index, section.source_measure_index + section.measure_count):
if offset >= capacity: m = section_measures[si]
break if role == 'accompaniment' and accompaniment is not None:
if offset > cursor: result = accompaniment(m, previous[si], si, sources=section_measures, bounds=(section.source_measure_index, section.source_measure_index + section.measure_count), stop=stop)
rests, sequence = renderable_rest_notes("arr-rest", offset - cursor, sequence) result['id'] = f'measure_{len(output) + len(segment) + 1}'
notes.extend(rests) result['section_label'] = section.label if not segment else ''
cursor = offset
active_chord = active_chord_at_offset(measure, offset, chord_symbol, include_optional=include_optional)
visible_chord = chord_symbol_at_offset(measure, offset, include_optional=include_optional)
sequence += 1
notes.append(playable_chord_note(f"arr-note-{sequence}", min(hit_duration, capacity - offset), active_chord, visible_chord))
cursor = offset + min(hit_duration, capacity - offset)
if cursor < capacity:
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
notes.extend(rests)
return apply_source_chord_markers(notes, measure, include_optional=include_optional), sequence
def accompaniment_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
notes: list[dict] = []
lead_rest = min(QUARTER_TICKS // 2, capacity)
if lead_rest:
rests, sequence = renderable_rest_notes("arr-rest", lead_rest, sequence, chord_symbol_at_offset(measure, 0, include_optional=include_optional))
notes.extend(rests)
if lead_rest < capacity:
hit = min(QUARTER_TICKS // 2, capacity - lead_rest)
visible_chord = chord_symbol_at_offset(measure, lead_rest, include_optional=include_optional)
active_chord = active_chord_at_offset(measure, lead_rest, chord_symbol, include_optional=include_optional)
sequence += 1
if source_note_attack_at(measure, lead_rest):
notes.append(rest_note(f"arr-rest-{sequence}", hit, visible_chord))
else:
notes.append(playable_chord_note(f"arr-note-{sequence}", hit, active_chord, visible_chord))
cursor = lead_rest + hit
if cursor < capacity:
markers = [marker for marker in chord_markers(measure, include_optional=include_optional) if marker[0] >= cursor]
if markers:
segment_cursor = cursor
for index, (start, _, chord) in enumerate(markers):
if start > segment_cursor:
rests, sequence = renderable_rest_notes("arr-rest", start - segment_cursor, sequence)
notes.extend(rests)
next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity
rests, sequence = renderable_rest_notes("arr-rest", next_start - start, sequence, chord)
notes.extend(rests)
segment_cursor = next_start
if segment_cursor < capacity:
rests, sequence = renderable_rest_notes("arr-rest", capacity - segment_cursor, sequence)
notes.extend(rests)
else: else:
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence) result = generator.parts.build_measure(m, f'measure_{len(output) + len(segment) + 1}', role, part_type, previous[si], section.label if not segment else '')
notes.extend(rests) segment.append(result)
return apply_source_chord_markers(notes, measure, include_optional=include_optional), sequence if part_type == 'electric' and section.role in ('melody', 'accompaniment'):
voicing_origins.append(generator.electric.MeasureOrigin(source_starts[si], section.role, result))
segment_length = sum((generator.model.fraction(m['duration']) for m in segment), fractions.Fraction())
def slash_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]: if role in ('vocal', 'melody'):
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4"))) start = source_starts[section.source_measure_index]
notes: list[dict] = [] for shift in staff.get('octave_shifts', []):
cursor = 0 low, high = max(start, generator.model.fraction(shift['start'])), min(start + segment_length, generator.model.fraction(shift['end']))
while cursor < capacity: if low < high:
sequence += 1 octave_shifts.append({**shift, 'id': f'section_{section.section_index}_{shift["id"]}', 'start': generator.model.rational(absolute + low - start), 'end': generator.model.rational(absolute + high - start)})
duration = min(QUARTER_TICKS, capacity - cursor) pickup = None
notes.append(slash_note(f"arr-slash-{sequence}", duration)) if role in ('vocal', 'melody') and generator.arrange.pickup.enters(section, source) and output:
cursor += duration pickup = generator.parts.build_measure(source[0], f'pickup_{section.section_index}', section.role, part_type, None)
return apply_source_chord_markers(notes, measure, include_optional=include_optional), sequence tuplets.extend(generator.score.reidentify(([pickup] if pickup else []) + segment, staff.get('tuplets', [])))
if pickup:
destination = output[-1]
def rest_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]: pad = generator.arrange.pickup.offset(destination, pickup)
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4"))) onset = generator.arrange.pickup.splice(destination, pickup)
markers = chord_markers(measure, include_optional=include_optional) if part_type == 'electric':
if not markers: voicing_origins = [generator.electric.MeasureOrigin(o.onset, o.role, o.measure, o.start, onset if o.measure is destination else o.end) for o in voicing_origins]
notes, sequence = renderable_rest_notes("arr-rest", capacity, sequence) voicing_origins.append(generator.electric.MeasureOrigin(-pad, 'melody', destination, onset))
return notes, sequence for shift in staff.get('octave_shifts', []):
low, high = max(onset - pad, generator.model.fraction(shift['start'])), min(generator.model.fraction(pickup['duration']), generator.model.fraction(shift['end']))
notes: list[dict] = [] if low < high:
cursor = 0 offset = absolute - generator.model.fraction(pickup['duration'])
for index, (start, _, chord) in enumerate(markers): octave_shifts.append({**shift, 'id': f'pickup_{section.section_index}_{shift["id"]}', 'start': generator.model.rational(offset + low), 'end': generator.model.rational(offset + high)})
if start > cursor: output.extend(segment)
if not renderable_duration(start - cursor): absolute += segment_length
return source_rest_skeleton_notes(measure, sequence, include_optional=include_optional) if sections and sections[-1].section_index == FINAL_SECTION_INDEX:
rests, sequence = renderable_rest_notes("arr-rest", start - cursor, sequence) generator.arrange.ending.append(source, output, part_type,
notes.extend(rests) sounding=part_type != 'vocal' and (owned is None or 'accompaniment' in owned),
next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity center=composition.role(part_type + '_accompaniment').get('center', 55), composition=composition, incoming=accompaniment.last_position if accompaniment is not None else None)
if not renderable_duration(next_start - start): tuplets = generator.arrange.pickup.retain_links(output, tuplets)
return source_rest_skeleton_notes(measure, sequence, include_optional=include_optional) result = generator.score.make_arranged_document(document, staff, staff_id='staff_arranged_' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts)
rests, sequence = renderable_rest_notes("arr-rest", next_start - start, sequence, chord) result['metadata'].setdefault('tempo', 124)
notes.extend(rests) if part_type == 'acoustic':
cursor = next_start if accompaniment is None and (owned is None or 'accompaniment' in owned):
if cursor < capacity: generator.acoustic.prepare(result)
if not renderable_duration(capacity - cursor): generator.acoustic_voicing.place(result)
return source_rest_skeleton_notes(measure, sequence, include_optional=include_optional) generator.acoustic.validate_comping(result)
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence) generator.acoustic.combine(result)
notes.extend(rests) elif part_type == 'electric' and (owned is None or 'melody' in owned):
return notes, sequence generator.melody.harmonize(result)
report = generator.guitar.place_guitar(result, composition=composition)
if generator.accompaniment.connect_continuations(result):
def source_rest_skeleton_notes(measure: dict, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]: report = generator.guitar.place_guitar(result, composition=composition)
notes: list[dict] = [] if placement_report is not None:
for source_note in first_voice(measure).get("notes", []): placement_report.update(report)
if not isinstance(source_note, dict): # Source labels identify the head while arranging; published labels identify
continue # the complete performance, including its lead-in and repeated choruses.
sequence += 1 for number, measure in enumerate(output, 1):
chord = "" if not include_optional and source_note.get("chord_optional") is True else str(source_note.get("chord_symbol", "")) measure['number'] = str(number)
note = rest_note(f"arr-rest-{sequence}", int(source_note.get("duration_ticks", QUARTER_TICKS)), chord) generator.fretboard.validate_document(result)
if "display_ticks" in source_note: return result
note["display_ticks"] = source_note["display_ticks"]
if "tuplet_id" in source_note:
note["tuplet_id"] = source_note["tuplet_id"]
notes.append(note)
if not notes:
sequence += 1
notes.append(rest_note(f"arr-rest-{sequence}", measure_capacity_ticks(str(measure.get("time_signature", "4/4")))))
return notes, sequence
def melody_voice(measure: dict, *, strip_lyrics: bool, include_optional: bool = True) -> dict:
voice = copy_source_voice(measure, strip_lyrics=strip_lyrics, include_optional=include_optional)
voice["id"] = "voice-arranged-source"
voice["notes"] = [sanitized_note(note, strip_lyrics=strip_lyrics, include_optional=include_optional) for note in first_voice(measure).get("notes", []) if isinstance(note, dict)]
return voice
def apply_vocal_pickup_overlap(output: list[dict], source_measures: list[dict], section: ArrangementSection) -> None:
pickup = leading_pickup_measure_index(source_measures)
if pickup is None or section.source_measure_index != pickup + 1 or not output:
return
voice = copy_source_voice(source_measures[pickup], strip_lyrics=False)
for note in voice["notes"]:
if isinstance(note, dict):
note.pop("chord_symbol", None)
output[-1]["voices"][0]["id"] = "voice-arranged-vocal"
output[-1]["voices"][0]["tuplets"] = voice["tuplets"]
output[-1]["voices"][0]["notes"] = voice["notes"]
def voice_for_section(section: ArrangementSection, measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[dict, int]:
if section.role == "vocal":
return melody_voice(measure, strip_lyrics=False, include_optional=include_optional), sequence
if section.role == "melody":
return melody_voice(measure, strip_lyrics=True, include_optional=include_optional), sequence
if section.role == "rhythm":
notes, sequence = rhythm_notes(measure, chord_symbol, sequence, include_optional=include_optional)
elif section.role == "accompaniment":
notes, sequence = accompaniment_notes(measure, chord_symbol, sequence, include_optional=include_optional)
elif section.role == "slash":
notes, sequence = slash_notes(measure, chord_symbol, sequence, include_optional=include_optional)
else:
notes, sequence = rest_notes(measure, chord_symbol, sequence, include_optional=include_optional)
return {"id": "voice-arranged-guitar", "tuplets": [], "notes": notes}, sequence
def arranged_measures(source_measures: list[dict], part_type: str) -> list[dict]:
sections = full_arrangement_sections(source_measures, part_type)
all_chords = propagated_chords(source_measures)
required_chords = propagated_chords(source_measures, include_optional=False)
output: list[dict] = []
sequence = 0
for section in sections:
include_optional = section.section_index != FINAL_SECTION_INDEX
chords = all_chords if include_optional else required_chords
if part_type == "vocal" and section.role == "vocal":
apply_vocal_pickup_overlap(output, source_measures, section)
for offset in range(section.measure_count):
source_index = section.source_measure_index + offset
source_measure = source_measures[source_index]
chord_symbol = chords[source_index] if source_index < len(chords) else ""
voice, sequence = voice_for_section(section, source_measure, chord_symbol, sequence, include_optional=include_optional)
output_measure = make_measure(
f"measure-{len(output) + 1}",
source_measure,
voice.get("notes", []),
section_label=section.label if offset == 0 else "",
tuplets=voice.get("tuplets", []),
)
output_measure["voices"][0]["id"] = str(voice.get("id", output_measure["voices"][0]["id"]))
output.append(output_measure)
return output
def arrange_document(document: dict, part_type: str) -> dict:
staff = source_staff(document)
measures = staff.get("measures")
if not isinstance(measures, list) or not measures:
raise ValueError("performance arrangement requires at least one source measure")
output = make_arranged_document(
document,
staff,
staff_id="staff-arranged-vocal" if part_type == "vocal" else "staff-arranged-guitar",
staff_name=part_type.capitalize(),
role="vocal" if part_type == "vocal" else "guitar",
arrangement_part_type=part_type,
measures=arranged_measures([measure for measure in measures if isinstance(measure, dict)], part_type),
)
metadata = output.setdefault("metadata", {})
if isinstance(metadata, dict):
metadata.setdefault("tempo", 124)
return output
def generate_performance_scores(source_dir: Path, output_dir: Path) -> int:
if not source_dir.exists():
raise ValueError(f"performance source directory not found: {source_dir}")
count = 0
for source_path in sorted(source_dir.glob("*.json")):
document = load_json(source_path)
for part_type in PART_TYPES:
output = arrange_document(document, part_type)
target = output_dir / f"{slugify(source_path.stem)}-{part_type}.json"
write_json(target, output)
count += 1
return count
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
import copy
import fractions
import generator.accompaniment
import generator.model
import generator.score
def enters(section, source: list[dict]) -> bool:
return section is not None and section.section_index in (1, 2, 5) and section.source_measure_index == 1 and generator.score.leading_pickup_measure_index(source) == 0
def offset(destination: dict, pickup: dict) -> fractions.Fraction:
pad = generator.model.fraction(destination['duration']) - generator.model.fraction(pickup['duration'])
if pad < 0:
raise ValueError(f'measure {destination["number"]}: pickup is longer than the preceding arrangement measure')
return pad
def harmonies(destination: dict, pickup: dict) -> None:
pad = offset(destination, pickup)
if pickup['harmonies']:
first = pad + generator.model.fraction(pickup['harmonies'][0]['onset'])
destination['harmonies'] = [h for h in destination['harmonies'] if generator.model.fraction(h['onset']) < first]
destination['harmonies'].extend({**copy.deepcopy(h), 'onset': generator.model.rational(pad + generator.model.fraction(h['onset']))} for h in pickup['harmonies'])
def truncate(note: dict, end: fractions.Fraction) -> None:
duration = end - generator.model.fraction(note['onset'])
if generator.model.fraction(note['duration']) <= duration:
return
note.update(duration=generator.model.rational(duration), written=generator.model.written_for(duration))
for field in ('measure_rest', 'beams'):
note.pop(field, None)
for pitch in note['pitches']:
if generator.model.fraction(pitch['duration']) > duration:
pitch.update(duration=note['duration'], written=copy.deepcopy(note['written']))
pitch.pop('tie_to', None)
def splice(destination: dict, pickup: dict) -> fractions.Fraction:
"""Keep outgoing music until the pickup's first sounding event, then hand over."""
pad = offset(destination, pickup)
sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(pickup) if n['pitches']]
if not sounding:
return generator.model.fraction(destination['duration'])
start = pad + min(sounding)
voices = {v['id']: {'id': v['id'], 'notes': []} for v in pickup['voices']}
primary = pickup['voices'][0]['id']
for voice in destination['voices']:
identifier = primary if len(destination['voices']) == 1 and voice['id'] not in voices else voice['id']
notes = voices.setdefault(identifier, {'id': identifier, 'notes': []})['notes']
for note in voice['notes']:
if generator.model.fraction(note['onset']) < start:
truncate(note, start)
note['voice'] = identifier
notes.append(note)
for voice in pickup['voices']:
notes = voices[voice['id']]['notes']
for note in voice['notes']:
onset = pad + generator.model.fraction(note['onset'])
end = onset + generator.model.fraction(note['duration'])
if onset < start:
if end <= start:
continue
note.update(duration=generator.model.rational(end - start), written=generator.model.written_for(end - start))
note.pop('measure_rest', None)
onset = start
note['onset'] = generator.model.rational(onset)
notes.append(note)
generator.accompaniment.normalize_beams(notes)
destination['voices'] = [voice for voice in voices.values() if voice['notes']]
state = {k: copy.deepcopy(pickup[k]) for k in ('key', 'clef', 'meter', 'transpose', 'staff_details')}
for change in pickup.get('attributes', []):
if pad + generator.model.fraction(change['onset']) <= start:
state.update({k: copy.deepcopy(v) for k, v in change.items() if k != 'onset'})
destination['attributes'] = [a for a in destination.get('attributes', []) if generator.model.fraction(a['onset']) < start]
previous = dict(destination)
for change in destination['attributes']:
previous.update(change)
state = {k: v for k, v in state.items() if previous.get(k) != v}
if start and state:
destination['attributes'].append({'onset': generator.model.rational(start), **state})
elif not start:
destination.update(state)
destination['key_signature'] = generator.model.key_label(destination['key'])
destination['time_signature'] = generator.model.meter_label(destination['meter'])
destination['attributes'].extend({**copy.deepcopy(a), 'onset': generator.model.rational(pad + generator.model.fraction(a['onset']))} for a in pickup.get('attributes', []) if pad + generator.model.fraction(a['onset']) > start)
return start
def retain_links(measures: list[dict], tuplets: list[dict]) -> list[dict]:
notes = [n for m in measures for n in generator.score.measure_notes(m)]
tones = {p['id']: p for n in notes for p in n['pitches']}
for pitch in tones.values():
for field, reverse in (('tie_to', 'tie_from'), ('tie_from', 'tie_to')):
if field in pitch and tones.get(pitch[field], {}).get(reverse) != pitch['id']:
pitch.pop(field)
members = {}
for note in notes:
for identifier in note.get('tuplet_ids', []):
members.setdefault(identifier, []).append(note['id'])
result = []
for span in tuplets:
if events := members.get(span['id']):
result.append({**span, 'start': events[0], 'end': events[-1], 'continued_start': span.get('continued_start', False) or span['start'] != events[0], 'continued_end': span.get('continued_end', False) or span['end'] != events[-1]})
return result
+68
View File
@@ -0,0 +1,68 @@
"""Validate and copy supplied recordings without synthesis or transformation."""
from __future__ import annotations
import fractions
import hashlib
import json
import re
import shutil
import wave
import generator.model
import generator.performance
TEMPOS = tuple(range(60, 121, 10))
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 = generator.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")
+82
View File
@@ -0,0 +1,82 @@
"""Content identities and atomic storage shared by generator cache adapters."""
from __future__ import annotations
import ast
import contextlib
import functools
import hashlib
import json
import os
import pathlib
import shutil
import tempfile
ROOT = pathlib.Path(__file__).resolve().parent
@functools.lru_cache(maxsize=256)
def imports(content):
modules = set()
for node in ast.walk(ast.parse(content)):
if isinstance(node, ast.Import):
modules.update(alias.name for alias in node.names if alias.name.startswith('generator.'))
elif isinstance(node, ast.ImportFrom) and node.module and node.module.startswith('generator'):
modules.add(node.module)
modules.update(node.module + '.' + alias.name for alias in node.names)
return tuple(sorted(modules))
def source_identity(entrypoints):
"""Conservatively include the transitive imports of explicit stage roots.
Read bytes on every call, so edits in a long-lived build process invalidate
both code content and newly introduced dependencies. Only parsing is cached.
"""
pending, sources = list(entrypoints), {}
while pending:
module = pending.pop()
relative = module.split('.')[1:]
path = ROOT.joinpath(*relative).with_suffix('.py') if relative else ROOT / '__init__.py'
if not path.is_file():
path = ROOT.joinpath(*relative, '__init__.py')
if path in sources or not path.is_file():
continue
content = path.read_bytes()
sources[path] = hashlib.sha256(content).hexdigest()
pending.extend(imports(content))
pending.extend('.'.join(module.split('.')[:i]) for i in range(1, len(module.split('.'))))
return {path.relative_to(ROOT).as_posix(): sources[path] for path in sorted(sources)}
def fingerprint(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def write_json(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
temporary = None
try:
with tempfile.NamedTemporaryFile('w', dir=path.parent, delete=False) as output:
temporary = pathlib.Path(output.name)
json.dump(value, output, sort_keys=True)
os.replace(temporary, path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def has_symlink(path):
return any(parent.is_symlink() for parent in (path, *path.parents))
@contextlib.contextmanager
def stage_entry(entry, prefix):
if has_symlink(entry.parent):
raise ValueError('cache directories must not be symlinks')
entry.parent.mkdir(parents=True, exist_ok=True)
if entry.is_symlink() or entry.is_file():
entry.unlink(missing_ok=True)
entry.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix=prefix, dir=entry.parent) as temporary:
yield pathlib.Path(temporary)
def publish_files(entry, staged, names):
"""Publish complete files in order, with the artifact manifest last."""
for name in names:
target = entry / name
if target.is_dir() and not target.is_symlink():
shutil.rmtree(target)
(staged / name).replace(target)
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
import argparse
import json
import pathlib
import re
import sqlite3
import tempfile
import sys
if __name__ == '__main__' and __package__ is None:
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
import generator.audio_assets
APPLICATION_ID = 0x56414D50
SONG_COLUMNS = """
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 160),
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
composer TEXT NOT NULL,
key_signature TEXT NOT NULL,
source_path TEXT NOT NULL UNIQUE CHECK(length(source_path) > 0),
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
measure_count INTEGER NOT NULL CHECK(measure_count > 0),
total_pages INTEGER NOT NULL CHECK(total_pages BETWEEN 1 AND 10000),
title_image TEXT NOT NULL CHECK(length(title_image) = 64 AND title_image NOT GLOB '*[^0-9a-f]*')
"""
def metadata(path: pathlib.Path, root: pathlib.Path) -> dict:
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
raise ValueError('score escapes the asset root')
text = path.read_text(encoding='utf-8')
document = json.loads(text)
if not isinstance(document, dict) or not isinstance(document.get('music'), dict) or not isinstance(document.get('presentation'), dict):
raise ValueError('score bundle requires music and presentation objects')
music, presentation = document['music'], document['presentation']
if len(music['staves']) != 1 or (presentation['width'], presentation['height']) != (976, 484):
raise ValueError('expected one staff and 976x484 pages')
staff, meta = music['staves'][0], music['metadata']
for name in ('title', 'composer', 'key_signature', 'time_signature'):
if not isinstance(meta[name], str):
raise ValueError(f'{name} must be a string')
key = meta['key_signature']
title_image = presentation.get('title_image')
if not isinstance(title_image, str) or not re.fullmatch('[0-9a-f]{64}', title_image):
raise ValueError('presentation requires a generated Pico title image; regenerate assets')
if not isinstance(staff['name'], str):
raise ValueError('staff name must be a string')
solos_enabled = meta.get('solos_enabled', True)
if type(solos_enabled) is not bool:
raise ValueError('solos_enabled must be a boolean')
relative = path.relative_to(root)
return dict(id=path.stem, title=meta['title'], composer=meta['composer'], key_signature=key,
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'], solos_enabled=int(solos_enabled))
VARIANT_ERRORS = """
SELECT json_extract(source_json, '$.music.metadata.song_id'), part FROM songs
WHERE json_extract(source_json, '$.music.staves[0].arranged') = 1
GROUP BY json_extract(source_json, '$.music.metadata.song_id'), part
HAVING (part IN ('Acoustic', 'Electric') AND (count(*) != 2 OR sum(solos_enabled) != 1))
OR (part = 'Vocal' AND (count(*) != 1 OR sum(solos_enabled) != 1))
"""
def populate(connection: sqlite3.Connection, root: pathlib.Path, audio_assets=None) -> None:
if not root.is_dir():
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.audio_assets.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.audio_assets.describe_wav(path, root, song_id=match[1], bpm=int(match[2])))
folder = root / 'songs'
if (folder.exists() or folder.is_symlink()) and not folder.is_dir():
raise ValueError(f'{folder}: score asset directory not found')
with connection:
connection.execute('BEGIN')
connection.execute(f'PRAGMA application_id = {APPLICATION_ID}')
connection.execute(f'CREATE TABLE songs ({SONG_COLUMNS}, part TEXT NOT NULL, solos_enabled INTEGER NOT NULL CHECK(solos_enabled IN (0, 1))) STRICT')
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')):
try:
if path.parent != folder:
raise ValueError('unexpected songs score path')
row = metadata(path, root)
connection.execute(f'INSERT INTO songs ({", ".join(row)}) VALUES ({", ".join("?" for _ in row)})', tuple(row.values()))
except (ValueError, KeyError, TypeError, sqlite3.Error) as error:
raise ValueError(f'{path}: {error}') from error
if connection.execute(VARIANT_ERRORS).fetchall():
raise ValueError('missing or duplicate solo variants; regenerate score assets')
if connection.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
raise ValueError('catalog integrity check failed')
def build_catalog(root: pathlib.Path, destination: pathlib.Path | None = None, *, audio_assets=None) -> pathlib.Path:
if not root.is_dir():
raise ValueError(f'{root}: score asset directory not found')
destination = destination or root / 'catalog.db'
destination.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.vamp_catalog_', dir=destination.parent) as temporary:
staged = pathlib.Path(temporary) / 'catalog.db'
connection = sqlite3.connect(staged)
try:
populate(connection, root, audio_assets)
finally:
connection.close()
staged.replace(destination)
return destination
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Compile generated score bundles into a read-only runtime catalog.')
parser.add_argument('assets', type=pathlib.Path)
parser.add_argument('destination', type=pathlib.Path, nargs='?')
arguments = parser.parse_args()
build_catalog(arguments.assets, arguments.destination)
+56
View File
@@ -0,0 +1,56 @@
"""Transposable guitar families, derived from the written chord and legal grips."""
import generator.fretboard
import generator.harmony
import generator.model
import generator.vocabulary
def acoustic(chord, grip, *, composition=None):
composition = composition or generator.vocabulary.current()
degrees = generator.harmony.degrees(chord)
root = int(generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter']))
classes = {d: (root + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
guide = next((d for d in (3, 4, 2) if d in degrees), None)
terminal = next((d for d in (7, 6) if d in degrees), None)
color = max((d for d in degrees if d > 7), default=None)
kind = 'extended' if terminal and color else 'tetrad' if terminal else 'triad'
bass = next((d for d in (1, 5, 3) if classes.get(d) == grip[0].midi % 12), None)
slots = {'root': 1, 'fifth': 5, 'guide': guide, 'terminal': terminal, 'color': color}
policy = composition.accompaniment('acoustic')['voicing_families']
orders = policy[kind].get(f'{grip[0].string_number}/{bass}', ())
upper = tuple(p.midi % 12 for p in grip[1:])
preferred = any(all(slots[s] in classes for s in order) and tuple(classes[slots[s]] for s in order) == upper for order in orders)
# The physical/harmonic enumerator remains authoritative: an explicit bass
# or an unrepresented extension can use another valid compact voicing.
return {'id': f'{kind}/s{grip[0].string_number}/bass{bass or "written"}/' + '-'.join(str(next((d for d in sorted(classes) if pc == classes[d]), 'bass')) for pc in upper),
'preferred': preferred, 'bass_degree': bass, 'bass_string': grip[0].string_number,
'relative_frets': [p.fret - grip[0].fret for p in grip]}
def electric(grip):
"""Classify actual voicing geometry, without inventing chord permissions."""
pitches = sorted(p.midi for p in grip)
if len(grip) == 3:
return 'three-note-shell'
if len(set(p % 12 for p in pitches)) < len(pitches):
return 'compact-doubled'
if len(pitches) == 4:
lifted = sorted([pitches[0] + 12, *pitches[1:]])
if lifted[-1] - lifted[0] < 12 and lifted[2] == pitches[0] + 12:
return 'drop-two'
return 'close-position' if pitches and pitches[-1] - pitches[0] < 12 else 'compact-open'
def coverage(chord, options, part, *, composition=None):
composition = composition or generator.vocabulary.current()
if part == 'electric':
return {name: sum(electric(v) == name for v in options) for name in composition.accompaniment('electric')['voicing_families']['rank']}
families = [acoustic(chord, v, composition=composition) for v in options]
result = []
for string in (5, 6):
for degree in (1, 5):
found = [f for f in families if f['bass_degree'] == degree and f['bass_string'] == string]
result.append({'bass_string': string, 'bass_degree': degree, 'candidates': len(found), 'preferred': sum(f['preferred'] for f in found),
'reason': '' if found else 'no legal grip for this bass under written harmony and current fret/span limits'})
return result
def span_coverage(options):
return {'primary': sum(not generator.fretboard.span_penalty(v) for v in options),
'fallback': sum(generator.fretboard.span_penalty(v) for v in options)}
+497
View File
@@ -0,0 +1,497 @@
"""Source-only accompaniment: joint layered rhythms and connected grips."""
from __future__ import annotations
import copy
import dataclasses
import fractions
import functools
import json
import typing
import generator.accompaniment
import generator.acoustic
import generator.acoustic_voicing
import generator.arrange.form
import generator.chord_families
import generator.comping_rhythm
import generator.fretboard
import generator.guitar
import generator.harmony
import generator.hand_position
import generator.model
import generator.parts
import generator.score
import generator.solo.policy
import generator.vocabulary
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)
def requirements(chord, part):
result = generator.fretboard.chord_requirements(chord, respect_bass=part != 'electric')
if part == 'electric' or result.basses:
return result
degrees = generator.harmony.degrees(chord)
plain = degrees in ({1: 0, 3: 0, 5: 0}, {1: 0, 3: -1, 5: 0})
root = int(generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter']))
basses = tuple(sorted({(root + generator.harmony.degree_semitones(d, degrees[d])) % 12 for d in ((1, 3, 5) if plain else (1, 5)) if d in degrees}))
if not basses:
raise ValueError('acoustic accompaniment requires a written bass, root or fifth')
return dataclasses.replace(result, basses=basses)
def voicings(key, part, *, composition=None):
return _voicings(key, part, composition or generator.vocabulary.current())
@functools.lru_cache(maxsize=2048)
def _voicings(key, part, composition):
chord = json.loads(key)
required = requirements(chord, part)
if part == 'electric':
options = generator.guitar.chord_positions(required, prune=False, profile='electric', composition=composition)
elif part == 'acoustic':
degrees = generator.harmony.degrees(chord)
plain = degrees in ({1: 0, 3: 0, 5: 0}, {1: 0, 3: -1, 5: 0})
options = tuple(v for v in generator.acoustic_voicing.candidates(required, plain)
if v.bass.fret <= generator.fretboard.MAX_SINGLE_FRET
and generator.fretboard.has_fret_anchor(v[1:])
and (not plain or set(required.allowed) <= {p.midi % 12 for p in v}))
else:
raise ValueError('Unknown accompaniment instrument: ' + part)
options = tuple(v for v in options if generator.fretboard.playable_attack((p, fractions.Fraction(1)) for p in v))
if not options:
layout = 'bass on 5/6 plus upper strings 2/3/4' if part == 'acoustic' else 'melody chord families on strings 1–4'
raise ValueError(f'{generator.harmony.symbol(chord)}: no legal {part} voicing ({layout}); required degrees/bass, no open strings, fret anchor 1–12 and at most {generator.fretboard.MAX_FRET_SPAN} consecutive frets remain binding')
return tuple(sorted(options))
def movement(part, previous, current, old_hand, hand):
return generator.hand_position.motion(previous, current, old_hand, hand, part == 'acoustic').shift
class VoicingCost(typing.NamedTuple):
extended_grips: int = 0
family: int = 0
travel: int = 0
peak_position: int = 0
effort: fractions.Fraction = fractions.Fraction()
missing_tones: int = 0
register: fractions.Fraction = fractions.Fraction()
def plus(self, other):
return VoicingCost(*(max(a, b) if field == 'peak_position' else a + b for field, a, b in zip(self._fields, self, other)))
def moved(self, distance, ergonomics=0):
return self._replace(travel=self.travel + distance, effort=self.effort + ergonomics)
def local_cost(part, key, grip, center, *, composition=None):
composition = composition or generator.vocabulary.current()
required = requirements(json.loads(key), part)
register = generator.guitar.voicing_preferences(grip, generator.guitar.voicing_preference(part + '_accompaniment', center, composition=composition))
if part == 'electric':
value = generator.guitar.voicing_cost(grip, 'electric', required.allowed, composition=composition)
return VoicingCost(extended_grips=value.extended_grips, family=3 * value.string_shortfall + value.family_penalty, effort=value.effort, missing_tones=value.missing_tones, register=register)
# Keep the familiar families, then compare physical effort before position.
# Sounding register only breaks otherwise equal placement decisions.
family = generator.chord_families.acoustic(json.loads(key), grip, composition=composition)
return VoicingCost(extended_grips=generator.fretboard.span_penalty(grip), family=int(not family['preferred']), missing_tones=len(set(required.allowed) - {p.midi % 12 for p in grip}), register=register)
def select_grips(layers, costs, part, seed, *, left=(), right=(), left_hand=None, right_hand=None, close=False, fixed_hands=None):
"""Return complete grips, cost and hand windows, including repeat travel.
A concrete grip may occupy several windows. Preserve distinct earlier peaks
at a common grip/hand, and fix both starting values for a cyclic path.
"""
if not layers:
return (), VoicingCost(), ()
def hands(grip, fixed=None):
options = generator.hand_position.regions(grip)
if fixed is not None:
return tuple(h for h in options if h == fixed)
return options
retained = []
for i, layer in enumerate(layers):
tier = min(costs[i][v].extended_grips for v in layer)
fixed = fixed_hands[i] if fixed_hands is not None else None
options = tuple((v, h) for v in layer if costs[i][v].extended_grips == tier for h in hands(v, fixed))
if not options:
raise ValueError(f'accompaniment span {i}: no grip fits the committed hand position')
retained.append(options)
ranks = [{v: generator.solo.policy.stable_rank(seed, (i, v)) for v, _ in layer} for i, layer in enumerate(retained)]
winners = []
for first in retained[0] if close else [None]:
states = {(left, h, h): (VoicingCost(peak_position=h), (), (), ()) for h in hands(left, left_hand)}
for i, layer in enumerate(retained):
following = {}
for grip, hand in [first] if i == 0 and first is not None else layer:
local = costs[i][grip]._replace(peak_position=hand)
for (previous, old_hand, _), (cost, ties, path, positions) in states.items():
motion = generator.hand_position.motion(previous, grip, old_hand, hand, part == 'acoustic')
total = cost.plus(local).moved(motion.shift, motion.ergonomics)
candidate = (total, (*ties, (ranks[i][grip], hand)), (*path, grip), (*positions, hand))
key = (grip, hand, total.peak_position)
if key not in following or candidate < following[key]:
following[key] = candidate
# Future travel is identical at a common concrete grip and hand.
best = {}
for (grip, hand, _), value in following.items():
primary = value[0][:3]
best[grip, hand] = min(best.get((grip, hand), primary), primary)
states = {key: value for key, value in following.items() if value[0][:3] == best[key[:2]]}
endpoints = [first] if close else [(right, h) for h in hands(right, right_hand)]
for (grip, hand, _), (cost, ties, path, positions) in states.items():
for endpoint, end_hand in endpoints:
motion = generator.hand_position.motion(grip, endpoint, hand, end_hand, part == 'acoustic')
total = cost.plus(VoicingCost(peak_position=end_hand)).moved(motion.shift, motion.ergonomics)
winners.append((total, ties, path, positions))
if not winners:
raise ValueError('accompaniment boundary grip does not fit the committed hand position')
cost, _, path, positions = min(winners)
return path, cost, positions
@dataclasses.dataclass(frozen=True)
class HarmonyChoice:
index: int
start: fractions.Fraction
end: fractions.Fraction
chord: dict
events: tuple
figure: dict
options: tuple
hand: int | None = None
@property
def key(self):
return (self.index, self.start, self.end, semantic_key(self.chord), self.events)
def split_measures(flat, inputs, part):
"""Project a selected sounding figure into notation; barlines create ties only."""
output, fragments, original = [], {}, {}
cursor = fractions.Fraction()
for m, prior, index in inputs:
length = generator.model.fraction(m['duration'])
result = generator.parts.build_measure(m, f'comp_{part}_{index}', 'rest', part, prior)
voices = []
for voice in flat['voices']:
notes = []
for n in voice['notes']:
start = generator.model.fraction(n['onset'])
end = start + generator.model.fraction(n['duration'])
a, b = max(cursor, start), min(cursor + length, end)
if a >= b:
continue
for duration, written in generator.accompaniment.written_durations(b - a):
pitches = [{k: v for k, v in p.items() if k not in ('tie_from', 'tie_to')} for p in n['pitches']]
event = generator.model.make_event(f'comp_{part}_{index}_{voice["id"]}_{len(notes)}', a - cursor, duration, 'note' if pitches else 'rest', pitches, written)
event['voice'] = voice['id']
for old, new in zip(n['pitches'], event['pitches']):
fragments.setdefault(old['id'], []).append(new)
original[old['id']] = old
notes.append(event)
a += duration
voices.append({'id': voice['id'], 'notes': notes})
result['voices'] = voices
for chord in result['harmonies']:
chord.pop('chord_optional', None)
output.append(result)
cursor += length
for pieces in fragments.values():
for a, b in zip(pieces, pieces[1:]):
a['tie_to'], b['tie_from'] = b['id'], a['id']
for identifier, old in original.items():
if old.get('tie_to') in fragments:
a, b = fragments[identifier][-1], fragments[old['tie_to']][0]
a['tie_to'], b['tie_from'] = b['id'], a['id']
return tuple(output)
class Canonical:
def __init__(self, document, part, seed, *, composition=None, plan=None):
self.composition = composition or generator.vocabulary.current()
self.part, self.seed = part, seed
self.policy = self.composition.role(part + '_accompaniment')
self.cache, self._reports, self._rhythm_reports = {}, {}, {}
self.cache_positions = {}
self.last_position = ((), 0)
self.measures = generator.score.source_staff(document)['measures']
# A held accompaniment chord may continue into the bar containing the
# source melody pickup. Keep its voice identity through that splice.
self.voice_id = self.measures[0]['voices'][0]['id']
self.melody = generator.comping_rhythm.melody_contexts(document)
self.first = generator.score.body_start_measure_index(self.measures)
plan = plan or generator.arrange.form.FormPlan.read(document, part)
sections = plan.sections
self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(self.measures)
and b.source_measure_index == self.first for a, b in zip(sections, sections[1:]))
carried = generator.accompaniment.carried_chords(self.measures)
self.groups = []
index = self.first
while index < len(self.measures):
size = 1
if index + 1 < len(self.measures):
lengths = [generator.model.fraction(m['duration']) for m in self.measures[index:index + 2]]
if self.measures[index]['time_signature'] == self.measures[index + 1]['time_signature'] and any(tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths) for p in self.composition.accompaniment(part)['patterns']):
size = 2
self.groups.append(tuple((self.measures[i], carried[i], i) for i in range(index, index + size)))
index += size
self.acoustic_cache = {}
self.pair_breaks = {self.first, len(self.measures)}
self.pair_stops = []
for section in sections:
if section.role == 'accompaniment':
self.pair_breaks.update((section.source_measure_index, section.source_measure_index + section.measure_count))
self.pair_stops = [stop for section, stop in zip(sections, plan.stops) if section.role == 'accompaniment' and stop is not None]
breaks = [(i, fractions.Fraction()) for i in self.pair_breaks] + self.pair_stops
choices, locations, self.groove_report = generator.comping_rhythm.choose_cycles(self.groups, part, self.melody, seed, breaks=breaks, composition=self.composition)
self.patterns = {g[0][2]: (p, a) for g, (p, a, _) in zip(self.groups, choices)}
self.locations = {g[0][2]: location for g, location in zip(self.groups, locations)}
selected = [r for _, _, r in choices]
self.block_rhythms = {group[0][2]: rhythm for group, rhythm in zip(self.groups, selected)}
prepared = [self.prepare(group, rhythm) for group, rhythm in zip(self.groups, selected)]
active = tuple(span for spans in prepared for span in spans if span.events)
self.canonical_spans = {group[0][2]: spans for group, spans in zip(self.groups, prepared)}
path, self.path_cost, self.hand_order = self.select(active, close=self.closed)
self.base = dict(zip((s.key for s in active), path))
self.hands = dict(zip((s.key for s in active), self.hand_order))
self.order = tuple((s.index, s.start, v) for s, v in zip(active, path))
self.rhythms = {}
previous = (path[-1], self.hand_order[-1]) if self.closed and path else ((), 0)
for group, rhythm, spans in zip(self.groups, selected, prepared):
grips = tuple((self.base[s.key], self.hands[s.key]) for s in spans if s.events)
key = (self.key(group), rhythm.signature) if part == 'acoustic' else self.key(group)
self.cache[key], self.cache_positions[key] = self.render(group, rhythm, spans, grips, previous, False)
cursor = fractions.Fraction()
for m, _, i in group:
length = generator.model.fraction(m['duration'])
self.rhythms[i] = dataclasses.replace(rhythm, events=tuple(dataclasses.replace(e, start=max(e.start, cursor) - cursor, end=min(e.end, cursor + length) - cursor, harmonic_time=e.harmonic_time - cursor if e.harmonic_time is not None else None) for e in rhythm.events if e.start < cursor + length and e.end > cursor))
cursor += length
if grips:
previous = grips[-1]
@property
def report(self):
return [row for key in sorted(self._reports, key=repr) for row in self._reports[key]]
@property
def placement_report(self):
previous = self.hand_order[-1] if self.closed and self.hand_order else None
shifts = []
for hand in self.hand_order:
shifts.append(abs(hand - previous) if previous is not None else 0)
previous = hand
return {'version': generator.hand_position.VERSION, 'priority': 'hand travel, maximum hand position, ergonomics, coverage/register, stable tie',
**generator.hand_position.summary(shifts), 'peak_position': self.path_cost.peak_position,
'cost': {k: str(v) for k, v in self.path_cost._asdict().items()},
'positions': [{'source_index': i, 'onset': str(t), 'hand_position': h} for (i, t, _), h in zip(self.order, self.hand_order)]}
@property
def rhythm_report(self):
return [row for key in sorted(self._rhythm_reports, key=repr) for row in self._rhythm_reports[key]]
@staticmethod
def key(inputs, limit=None):
return (tuple((i, str(generator.model.fraction(m['duration'])), tuple((str(a), str(b), semantic_key(c) if c else '') for a, b, c in generator.comping_rhythm.spans(m, p))) for m, p, i in inputs), str(limit))
def candidates(self, inputs, limit=None):
flat = generator.comping_rhythm.flatten(inputs)
melody, cursor, lengths = [], fractions.Fraction(), []
for m, _, i in inputs:
melody.extend((a + cursor, b + cursor) for a, b in self.melody[i])
lengths.append(generator.model.fraction(m['duration']))
cursor += lengths[-1]
return generator.comping_rhythm.candidates(flat, None, self.part, tuple(melody), self.seed + '/' + str(inputs[0][2]), lengths=tuple(lengths), limit=limit, composition=self.composition)
def acoustic_realizations(self, sources, bounds=None, stop=None):
"""Assign the canonical attack stream once, before splitting it into blocks.
A variant uses the same patterns with the source's actual harmony and
participation boundaries. No selected notes from another role enter here.
"""
carried = generator.accompaniment.carried_chords(sources)
full = tuple((m, carried[i], i) for i, m in enumerate(sources))
key = (self.key(full), bounds, stop)
if key in self.acoustic_cache:
return self.acoustic_cache[key]
starts = [fractions.Fraction()]
for m in sources:
starts.append(starts[-1] + generator.model.fraction(m['duration']))
breaks = [(i, fractions.Fraction()) for i in self.pair_breaks | set(bounds or ())] + self.pair_stops
if stop:
breaks.append(stop)
groups, raw = [], []
for original in self.groups:
group = tuple(full[i] for _, _, i in original)
index = group[0][2]
pattern, anticipate = self.patterns[index]
limit = starts[stop[0]] + stop[1] - starts[index] if stop and any(i == stop[0] for _, _, i in group) else None
rhythm = generator.comping_rhythm.realize(pattern, group, 'acoustic', self.melody, anticipate=anticipate, limit=limit, boundary=True, assign=False)
if rhythm is None:
raise ValueError(f'source measure {sources[index]["number"]}: selected acoustic rhythm cannot fit written harmony')
raw.append(rhythm)
groups.append(group)
context = generator.comping_rhythm.acoustic_context(groups, self.melody, breaks)
realized = generator.comping_rhythm.realize_acoustic_form(raw, context)
result = {g[0][2]: r for g, r in zip(groups, realized)}
self.acoustic_cache[key] = result
return result
def prepare(self, inputs, rhythm):
flat = generator.comping_rhythm.flatten(inputs)
spans = []
offsets, cursor = [], fractions.Fraction()
for m, _, i in inputs:
offsets.append((cursor, i))
cursor += generator.model.fraction(m['duration'])
for start, end, chord in generator.comping_rhythm.spans(flat, None):
if not chord:
continue
offset, index = next((a, i) for a, i in reversed(offsets) if a <= start)
events = tuple(e for e in rhythm.events if start <= (e.harmonic_time if e.harmonic_time is not None else e.start) < end)
try:
options = voicings(semantic_key(chord), self.part, composition=self.composition)
except ValueError as error:
raise ValueError(f'source measure {inputs[index - inputs[0][2]][0]["number"]}, beat {start - offset + 1}: {error}') from error
spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options))
return tuple(spans)
def select(self, spans, *, left=(), right=(), left_hand=None, right_hand=None, close=False):
layers = tuple(s.options for s in spans)
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55), composition=self.composition) for v in layer} for s, layer in zip(spans, layers))
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, left_hand=left_hand, right_hand=right_hand, close=close, fixed_hands=tuple(s.hand for s in spans))
def __call__(self, source, prior, index, *, sources=None, bounds=None, stop=None):
group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),))
original_group = group
sources = sources or self.measures
if self.part == 'acoustic' and source != sources[index]:
sources = [source if i == index else m for i, m in enumerate(sources)]
carried = generator.accompaniment.carried_chords(sources)
low, high = bounds or (self.first, len(sources))
group = tuple((source if i == index else sources[i], prior if i == index else carried[i], i) for _, _, i in group if low <= i < high)
limit = None
if stop and any(i == stop[0] for _, _, i in group):
limit = sum((generator.model.fraction(m['duration']) for m, _, i in group if i < stop[0]), fractions.Fraction()) + stop[1]
key = self.key(group, limit)
complete = None
if self.part == 'acoustic' and original_group[0][2] in self.patterns:
complete = self.acoustic_realizations(sources, (low, high), stop)[original_group[0][2]]
key = (key, complete.signature)
if key not in self.cache:
start, end = group[0][2], group[-1][2]
offset = sum((generator.model.fraction(m['duration']) for m, _, i in original_group if i < start), fractions.Fraction())
if original_group[0][2] in self.patterns:
pattern, anticipate = self.patterns[original_group[0][2]]
if self.part == 'acoustic':
end_time = offset + (limit if limit is not None else sum(generator.model.fraction(m['duration']) for m, _, _ in group))
rhythm = dataclasses.replace(complete, events=tuple(dataclasses.replace(e, start=max(e.start, offset) - offset, end=min(e.end, end_time) - offset, harmonic_time=e.harmonic_time - offset if e.harmonic_time is not None and e.harmonic_time >= offset else None)
for e in complete.events if e.start < end_time and e.end > offset))
elif offset:
# Fit the incoming context before slicing. A raw template sustain
# may already have ended at a chord change; projecting it first
# would resurrect that note as an unintended new downbeat attack.
context = tuple((source if i == index else sources[i], prior if i == index else carried[i], i)
for _, _, i in original_group if i <= end)
incoming = generator.comping_rhythm.realize(pattern, context, self.part, self.melody, anticipate=anticipate, limit=offset + limit if limit is not None else None, boundary=True)
if incoming is None:
raise ValueError(f'source measure {source["number"]}: selected groove cannot fit incoming harmony')
pattern = {**pattern, 'events': [{'onset': str(e.start), 'duration': str(e.end - e.start),
'layer': e.lane, 'anticipate': e.harmonic_time is not None and e.lane in ('upper', 'electric')} for e in incoming.events]}
if self.part != 'acoustic':
pattern = generator.comping_rhythm.project(pattern, offset, tuple(generator.model.fraction(m['duration']) for m, _, _ in group))
rhythm = generator.comping_rhythm.realize(pattern, group, self.part, self.melody, anticipate=anticipate, limit=limit, boundary=True)
if rhythm is None:
raise ValueError(f'source measure {source["number"]}: selected groove cannot fit protected boundary')
else:
# A source pickup outside the repeating body uses the existing partial
# vocabulary. It never selects or changes the body groove.
rhythm = min(self.candidates(group, limit), key=lambda r: (r.cost, generator.solo.policy.stable_rank(self.seed, r.signature)))
spans = self.prepare(group, rhythm)
# Keep every unchanged harmonic interval's canonical grip, even when a
# neighbor loses an optional turnaround or a section starts mid-pattern.
fixed = []
for span in spans:
original = next((s for s in self.canonical_spans.get(original_group[0][2], ()) if s.key in self.base
and s.start <= span.start + offset < s.end and semantic_key(s.chord) == semantic_key(span.chord)), None)
fixed.append(dataclasses.replace(span, options=(self.base[original.key],), hand=self.hands[original.key]) if original else span)
spans = tuple(fixed)
before = [(v, h) for (i, _, v), h in zip(self.order, self.hand_order) if i < start]
after = [(v, h) for (i, _, v), h in zip(self.order, self.hand_order) if i > end]
left = before[-1] if before else (self.order[-1][2], self.hand_order[-1]) if self.closed and self.order else ((), 0)
right = after[0] if after else (self.order[0][2], self.hand_order[0]) if self.closed and self.order else ((), 0)
selected, _, hands = self.select(tuple(s for s in spans if s.events), left=left[0], right=right[0], left_hand=left[1], right_hand=right[1])
self.cache[key], self.cache_positions[key] = self.render(group, rhythm, spans, tuple(zip(selected, hands)), left, True, limit)
placement = self.cache_positions[key][index - group[0][2]]
if placement is not None:
self.last_position = placement
return copy.deepcopy(self.cache[key][index - group[0][2]])
def render(self, inputs, rhythm, spans, selected, previous, variant, limit=None):
flat = generator.comping_rhythm.flatten(inputs)
length = generator.model.fraction(flat['duration'])
index = inputs[0][2]
attacks, rows, placed_events = [], [], []
choices = iter(selected)
for span in spans:
grip, hand = next(choices) if span.events else ((), 0)
motion = generator.hand_position.motion(previous[0], grip, previous[1], hand, self.part == 'acoustic')
bass = grip[0] if grip and self.part == 'acoustic' else None
degrees = generator.harmony.degrees(span.chord)
root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter']))
rows.append({'source_index': span.index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord),
'figure': span.figure, 'pattern_id': rhythm.pattern_id, 'tones': len(grip), 'bass': bool(bass), 'variant': variant,
'family': (generator.chord_families.acoustic(span.chord, grip, composition=self.composition) if self.part == 'acoustic' else generator.chord_families.electric(grip)) if grip else None,
'family_coverage': generator.chord_families.coverage(span.chord, span.options, self.part, composition=self.composition),
'fret_span': generator.fretboard.fret_span(grip),
'span_group': ('fallback' if generator.fretboard.span_penalty(grip) else 'primary') if grip else None,
'span_candidates': generator.chord_families.span_coverage(span.options),
'positions': [{'string': p.string_number, 'fret': p.fret, 'sounding_midi': p.midi} for p in grip],
'bass_degree': next((d for d, a in degrees.items() if bass and (root + generator.harmony.degree_semitones(d, a)) % 12 == bass.midi % 12), None),
'bass_string': bass.string_number if bass else None,
'movement': motion.shift, 'voicing_candidates': len(span.options)})
if grip:
local = local_cost(self.part, semantic_key(span.chord), grip, self.policy.get('center', 55), composition=self.composition)
rows[-1]['hand_position'] = hand
rows[-1]['voicing_cost'] = {'movement': motion.shift, 'hand_shift': motion.shift, 'finger_adjustment': motion.fingers,
'string_changes': motion.strings, 'grip_effort': str(local.effort), 'register_tiebreak': str(local.register),
'secondary_effort': str(motion.ergonomics + local.effort)}
for event in span.events:
placed_events.append((event.start, event.end, (grip, hand)))
lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane]
for lane in lanes:
positions = (grip[0],) if lane == 'bass' else grip[1:] if lane == 'upper' else grip
attacks.append((lane, event.start, event.end, positions, span.chord))
if grip:
previous = (grip, hand)
key = self.key(inputs, limit)
if self.part == 'acoustic':
key = (key, rhythm.signature)
measure_hands, cursor = [], fractions.Fraction()
for m, _, _ in inputs:
end = cursor + generator.model.fraction(m['duration'])
played = sorted((a, b, p) for a, b, p in placed_events if a < end and b > cursor)
measure_hands.append(played[-1][2] if played else None)
cursor = end
self._reports[key] = rows
voices = []
for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']:
voice_id = lane if self.part == 'acoustic' else self.voice_id
notes, cursor = [], fractions.Fraction()
def emit(a, b, positions=(), chord=None):
previous = None
for duration, written in generator.accompaniment.written_durations(b - a):
pitches = generator.fretboard.chord_pitches(chord, positions) if positions else []
n = generator.model.make_event(f'comp_{self.part}_{index}_{lane}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written)
n['voice'] = voice_id
if previous and pitches:
for old, new in zip(previous['pitches'], n['pitches']):
old['tie_to'], new['tie_from'] = new['id'], old['id']
notes.append(n)
previous = n
a += duration
for _, start, end, positions, chord in sorted((e for e in attacks if e[0] == lane), key=lambda e: e[1]):
if cursor < start:
emit(cursor, start)
emit(start, end, positions, chord)
cursor = end
if cursor < length:
emit(cursor, length)
voices.append({'id': voice_id, 'notes': notes})
result = split_measures({'voices': voices}, inputs, self.part)
if self.part == 'acoustic':
generator.acoustic.validate_assignment(result, rhythm.events)
reports, cursor = [], fractions.Fraction()
for m, _, i in inputs:
duration = generator.model.fraction(m['duration'])
reports.append({'source_index': i, 'pattern_id': rhythm.pattern_id, 'pattern_start': index,
**next((self.locations[g[0][2]] for g in self.groups if any(j == i for _, _, j in g)), {}),
'families': dict(rhythm.families), 'contributions': dict(rhythm.contributions), 'selection_cost': rhythm.cost,
'exceptions': rhythm.exceptions, 'source_melody': [(str(a), str(b)) for a, b in self.melody[i]],
'events': [{'layer': e.lane, 'onset': str(max(e.start, cursor) - cursor), 'duration': str(min(e.end, cursor + duration) - max(e.start, cursor)),
'continuation': e.start < cursor, 'harmonic_target': str(e.harmonic_time - cursor) if e.harmonic_time is not None else None} for e in rhythm.events if e.start < cursor + duration and e.end > cursor]})
cursor += duration
self._rhythm_reports[key] = reports
return result, tuple(measure_hands)
+547
View File
@@ -0,0 +1,547 @@
"""Complete, source-only comping figures. Time is in rational quarter notes."""
import dataclasses
import fractions
import itertools
import generator.accompaniment
import generator.model
import generator.solo.policy
import generator.source
import generator.vocabulary
def meter_context(measure):
"""Nominal meter and explicit additive grouping, independent of bar length.
In particular, a three-quarter-note pickup or a 6/8 bar is not 3/4.
"""
groups = measure['meter']['groups']
if not groups or any(g['unit'] != 4 for g in groups):
raise ValueError(f"source measure {measure['number']}: no comping vocabulary for {measure['time_signature']}")
beats = tuple(b for g in groups for b in g['beats'])
total = sum(beats)
if total not in (3, 4, 5) or (len(beats) > 1 and beats not in ((3, 2), (2, 3))):
raise ValueError(f"source measure {measure['number']}: unsupported comping grouping {measure['time_signature']}")
return f'{total}/4', beats if len(beats) > 1 else ()
def grouping_for(run, melody):
"""Choose one source-only quintuple feel, shared by independent roles.
Written grouping wins. Otherwise compare actual chord changes, entrances
after an eighth-note breath, then all melodic attacks at the group boundary.
Counts are evidence, not inferred accents or performance dynamics.
"""
meter, explicit = meter_context(run[0][0][0])
if meter != '5/4':
return (), {'reason': 'simple_meter'}
if explicit:
return explicit, {'reason': 'written'}
evidence = {}
for boundary in (3, 2):
changes, entrances, attacks = 0, 0, 0
for group in run:
for m, prior, i in group:
changes += sum(a == boundary and c is not None for a, _, c in spans(m, prior))
context = melody[i]
for a, _ in context:
if a != boundary:
continue
attacks += 1
previous = max((b for x, b in context if x < a), default=fractions.Fraction())
entrances += a - previous >= fractions.Fraction(1, 2)
evidence[boundary] = (changes, entrances, attacks)
boundary = 2 if evidence[2] > evidence[3] else 3
return (boundary, 5 - boundary), {'reason': 'source' if evidence[2] != evidence[3] else 'default_3+2',
'boundary_evidence': {str(k): dict(zip(('chord_changes', 'melody_entrances', 'melody_attacks'), v)) for k, v in evidence.items()}}
def patterns_for(policy, measure, lengths, grouping=()):
meter, explicit = meter_context(measure)
grouping = explicit or grouping or ((3, 2) if meter == '5/4' else ())
# Untagged in-memory figures are allowed for independently authored unit
# tests; the resource loader requires metadata for every production entry.
return [p for p in policy['patterns'] if tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths)
and (p.get('meter') == 'partial' or (p.get('meter', meter) == meter and tuple(p.get('grouping', grouping)) == grouping))]
def relevant_policy(policy, measures):
"""An unused meter's vocabulary must not influence this role's decisions."""
meters = {meter_context(m)[0] for m in measures}
lengths = {generator.model.fraction(m['duration']) for m in measures}
return {**policy, 'patterns': [p for p in policy['patterns'] if p.get('meter') in meters
or (p.get('meter') == 'partial' and fractions.Fraction(p['lengths'][0]) in lengths)]}
@dataclasses.dataclass(frozen=True, order=True)
class Attack:
lane: str
start: fractions.Fraction
end: fractions.Fraction
harmonic_time: fractions.Fraction | None = None
@dataclasses.dataclass(frozen=True)
class Rhythm:
events: tuple
families: tuple
contributions: tuple
exceptions: tuple = ()
pattern_id: str = ''
@property
def cost(self):
return sum(value for _, value in self.contributions)
@property
def signature(self):
return tuple((e.lane, str(e.start), str(e.end - e.start), str(e.harmonic_time)) for e in self.events)
def melody_contexts(document):
melody = generator.source.attacks(document)
result, cursor = [], fractions.Fraction()
for m in document['staves'][0]['measures']:
length = generator.model.fraction(m['duration'])
result.append(tuple((n.start - cursor, n.end - cursor) for n in melody if n.start < cursor + length and n.end > cursor))
cursor += length
return tuple(result)
def spans(measure, prior):
length = generator.model.fraction(measure['duration'])
boundaries = sorted({fractions.Fraction(), length, *(generator.model.fraction(h['onset']) for h in measure['harmonies'])})
result = []
for a, b in zip(boundaries, boundaries[1:]):
chord = generator.accompaniment.active_chord(measure, a, prior)
if result and generator.accompaniment.same_chord(result[-1][2], chord):
result[-1] = (result[-1][0], b, chord)
else:
result.append((a, b, chord))
return tuple(result)
def flatten(inputs):
"""Harmony/timing adapter only; never concatenate separately chosen rhythms."""
first = inputs[0][0]
result = {**first, 'harmonies': []}
cursor = fractions.Fraction()
for m, prior, _ in inputs:
chord = generator.accompaniment.active_chord(m, fractions.Fraction(), prior)
initial = chord or {'kind': 'none'}
result['harmonies'].append({**initial, 'onset': generator.model.rational(cursor)})
result['harmonies'].extend({**h, 'onset': generator.model.rational(cursor + generator.model.fraction(h['onset']))} for h in m['harmonies'] if generator.model.fraction(h['onset']))
cursor += generator.model.fraction(m['duration'])
result['duration'] = generator.model.rational(cursor)
return result
def validate_events(events, part, lengths=None):
ordered = sorted(events, key=lambda e: e.start)
for e in ordered:
duration = e.end - e.start
if duration <= 0:
raise ValueError('comping event must have positive sounding duration')
if part == 'acoustic':
if e.lane not in ('bass', 'upper', 'combined') or (duration < 1 and duration != fractions.Fraction(1, 2)):
raise ValueError('acoustic attacks require quarter/eighth notes or authored sustains')
elif e.lane != 'electric':
raise ValueError('electric comping requires chord events')
for i, a in enumerate(ordered):
for b in ordered[i + 1:]:
if a.end > b.start:
raise ValueError('comping sound must release before the next attack')
def assign_acoustic(events, harmony, breaks=()):
"""Assign attacks, not notation fragments, across a complete source span.
Pair left to right within an effective harmony. An anticipated attack is
already playing its target harmony. Form/participation boundaries break
pairs; ordinary barlines and computational blocks do not.
"""
ordered = sorted(events, key=lambda e: e.start)
if len({e.start for e in ordered}) != len(ordered):
raise ValueError('acoustic rhythm must have one event per attack position')
def region(event):
time = event.harmonic_time if event.harmonic_time is not None else event.start
return next(i for i, (a, b, _) in enumerate(harmony) if a <= time < b)
lanes, i = ['combined'] * len(ordered), 0
while i + 1 < len(ordered):
a, b = ordered[i:i + 2]
if b.start - a.start == fractions.Fraction(1, 2) and region(a) == region(b) and not any(a.start < t <= b.start for t in breaks):
lanes[i:i + 2] = ['bass', 'upper']
i += 2
else:
i += 1
result = [dataclasses.replace(e, lane=lane) for e, lane in zip(ordered, lanes)]
# One rhythm-guitar stream: every new attack releases the previous sound,
# including a change of lane. A combined attack has one shared release.
for i, a in enumerate(result):
following = result[i + 1].start if i + 1 < len(result) else a.end
result[i] = dataclasses.replace(a, end=min(a.end, following))
# Shortening must not strand an anticipation before its promised arrival.
# Rebind that event to the current chord, then reconsider its pair once.
expired = {e.start for e in result if e.harmonic_time is not None and e.end <= e.harmonic_time}
if expired:
rebound = []
for e in ordered:
if e.start in expired:
boundary = next(b for a, b, _ in harmony if a <= e.start < b)
e = dataclasses.replace(e, harmonic_time=None, end=min(e.end, boundary))
rebound.append(e)
return assign_acoustic(rebound, harmony, breaks)
validate_events(result, 'acoustic')
return tuple(result)
def fit(pattern, measure, prior, part, *, anticipate=False, limit=None, assign=True):
"""Bind a complete figure to written harmony, with bounded early arrivals."""
length = generator.model.fraction(measure['duration'])
limit = length if limit is None else min(length, limit)
harmony = spans(measure, prior)
events, exceptions, available = [], [], []
for row in pattern['events']:
start = fractions.Fraction(row['onset'])
end = min(start + fractions.Fraction(row['duration']), limit)
if start >= limit:
continue
active = next(((a, b, c) for a, b, c in harmony if a <= start < b), None)
if active is None or active[2] is None:
exceptions.append(('space', 'no written harmony at ' + str(start)))
continue
available.append((dict(row), start, end, active))
lengths = tuple(map(fractions.Fraction, pattern.get('lengths', (length,))))
for row, start, end, active in available:
_, boundary, chord = active
target = None
if anticipate and row.get('anticipate'):
future = next(((a, b, c) for a, b, c in harmony if a == start + fractions.Fraction(1, 2) and c), None)
if future and future[0] < end and not generator.accompaniment.same_chord(chord, future[2]):
target, boundary = future[0], future[1]
end = min(end, boundary)
events.append(Attack('acoustic' if part == 'acoustic' else row['layer'], start, end, target))
try:
if part == 'acoustic' and assign:
events = assign_acoustic(events, harmony)
exceptions.append(('layer_assignment', 'combined attacks; same-harmony eighth pairs use bass then upper'))
elif part == 'acoustic' and any(e.end <= e.start or (e.end - e.start < 1 and e.end - e.start != fractions.Fraction(1, 2)) for e in events):
raise ValueError('acoustic articulation cannot fit this boundary')
if part != 'acoustic' or assign:
validate_events(events, part, lengths)
except ValueError:
return None
return tuple(events), tuple(exceptions)
def evaluate(events, part, melody, harmony, lengths):
activity, support, coverage, articulation = 0., 0., 0., 0.
cursor = fractions.Fraction()
for length in lengths:
local = [e for e in events if cursor <= e.start < cursor + length]
attacks = [a for a, b in melody if cursor <= a < cursor + length]
busy = len(attacks) >= length
sounding = any(e.start < cursor + length and e.end > cursor for e in events)
target = (1 if busy else 1.5) if part == 'electric' else (2 if busy else 2.5)
# Sustained occupancy and planned space are not missing attacks.
activity += .22 * max(0, abs(len({e.start for e in local}) - target) - .75) if not (sounding and not local) else 0
# A bass paired with the upper chord is one rhythmic attack, regardless
# of whether unequal releases require two internal lane events.
attacks_by_time = {e.start: max((v for v in local if v.start == e.start), key=lambda v: v.end) for e in local}
for e in attacks_by_time.values():
gap = not any(a <= e.start < b for a, b in melody)
entrance = any(a == e.start and (i == 0 or a - melody[i - 1][1] >= fractions.Fraction(1, 2)) for i, (a, _) in enumerate(melody))
support += -.2 if entrance else -.12 if gap else .18 if busy and e.start in attacks else 0
articulation += .08 if busy and e.end - e.start > 2 else 0
cursor += length
for a, b, chord in harmony:
if chord and b - a >= 1 and not any(e.start < b and e.end > a for e in events):
coverage += .18 # A preference, never a chord-attack quota.
divisor = max(1, len(lengths))
return tuple((k, v / divisor) for k, v in (('activity', activity), ('melody_support', support), ('harmonic_support', coverage), ('articulation', articulation)))
def candidates(measure, prior, part, melody, seed, *, lengths=None, limit=None, grouping=(), composition=None):
composition = composition or generator.vocabulary.current()
lengths = lengths or (generator.model.fraction(measure['duration']),)
policy = composition.accompaniment(part)
result = []
for pattern in patterns_for(policy, measure, lengths, grouping):
for anticipate in (False, True):
fitted = fit(pattern, measure, prior, part, anticipate=anticipate, limit=limit)
if fitted is None:
continue
events, exceptions = fitted
if anticipate and not any(e.harmonic_time is not None for e in events):
continue
contributions = evaluate(events, part, melody, spans(measure, prior), lengths)
result.append(Rhythm(events, (('pattern', pattern['family']),), contributions, exceptions, pattern['id'] + ('.anticipated' if anticipate else '')))
if not result:
raise ValueError(f'measure {measure["number"]}: no {part} pattern for lengths {lengths} and written harmonic/release constraints')
return representatives(result, policy['pattern_candidates'], seed)
def representatives(candidates, limit, seed):
unique = {}
for c in candidates:
rank = (c.cost, generator.solo.policy.stable_rank(seed, (c.pattern_id, c.signature)))
if c.signature not in unique or rank < unique[c.signature][0]:
unique[c.signature] = rank, c
ordered = [c for _, c in sorted(unique.values(), key=lambda item: item[0])]
retained, families = [], set()
for c in ordered:
if c.families not in families:
retained.append(c)
families.add(c.families)
retained.extend(c for c in ordered if c not in retained)
return tuple(retained[:limit])
def project(pattern, offset, lengths):
"""Take a form-position slice, rather than restarting a pattern at an entry."""
end = offset + sum(lengths)
rows = []
for row in pattern['events']:
a = fractions.Fraction(row['onset'])
b = a + fractions.Fraction(row['duration'])
if a < end and b > offset:
rows.append({**row, 'onset': str(max(a, offset) - offset), 'duration': str(min(b, end) - max(a, offset))})
return {**pattern, 'lengths': list(map(str, lengths)), 'events': rows}
def realize(pattern, inputs, part, melody, *, anticipate=False, limit=None, boundary=False, assign=True):
flat = flatten(inputs)
lengths = tuple(generator.model.fraction(m['duration']) for m, _, _ in inputs)
fitted = fit(pattern, flat, None, part, anticipate=anticipate, limit=limit, assign=assign)
if fitted is None and boundary:
# Keep the selected onset/layers at a protected handoff, using the longest
# permitted short articulation that fits rather than another groove.
clipped = project(pattern, fractions.Fraction(), (min(sum(lengths), limit) if limit is not None else sum(lengths),))
rows = []
for row in clipped['events']:
a, duration = fractions.Fraction(row['onset']), fractions.Fraction(row['duration'])
_, b, _ = next(s for s in spans(flat, None) if s[0] <= a < s[1])
duration = min(duration, b - a)
if part == 'acoustic' and duration <= 1:
duration = next((d for d in (fractions.Fraction(1), fractions.Fraction(1, 2)) if d <= duration), fractions.Fraction())
if duration:
rows.append({**row, 'duration': str(duration)})
fitted = fit({**pattern, 'events': rows}, flat, None, part, anticipate=False, limit=limit, assign=assign)
if fitted is not None:
fitted = fitted[0], (*fitted[1], ('release', 'shortened articulation at source-defined boundary'))
if fitted is None:
return None
events, exceptions = fitted
context, cursor = [], fractions.Fraction()
for m, _, i in inputs:
context.extend((a + cursor, b + cursor) for a, b in melody[i])
cursor += generator.model.fraction(m['duration'])
return Rhythm(events, (('pattern', pattern['family']),), evaluate(events, part, context, spans(flat, None), lengths), exceptions,
pattern['id'] + ('.anticipated' if any(e.harmonic_time is not None for e in events) else ''))
def identity(pattern):
"""Layer swaps and anticipation permissions alone are not different rhythms."""
return tuple((fractions.Fraction(r['onset']), fractions.Fraction(r['duration'])) for r in pattern['events'])
def acoustic_context(groups, melody, breaks=()):
"""Immutable source timing for assigning and evaluating a complete stream.
Breaks are (source measure index, offset) pairs. Ordinary storage boundaries
are absent: adjacent figures must see each other's attacks before scoring.
"""
blocks, starts, cursor = [], {}, fractions.Fraction()
for group in groups:
start, context, lengths = cursor, [], []
for m, _, i in group:
starts[i] = cursor
context.extend((a + cursor - start, b + cursor - start) for a, b in melody[i])
lengths.append(generator.model.fraction(m['duration']))
cursor += lengths[-1]
starts[i + 1] = cursor
blocks.append((start, cursor, tuple(context), spans(flatten(group), None), tuple(lengths)))
full = tuple(row for group in groups for row in group)
return {'blocks': tuple(blocks), 'harmony': spans(flatten(full), None),
'breaks': tuple(sorted(starts[i] + t for i, t in breaks if i in starts))}
def realize_acoustic_form(rhythms, context):
"""The shared release/lane contract for selection, fills and final output.
Inputs retain authored releases. Re-evaluate those releases whenever an
edited neighboring figure changes the next attack; never restore duration
from previously shortened notation.
"""
if len(rhythms) != len(context['blocks']):
raise ValueError('acoustic realization requires one rhythm per source block')
events = []
for rhythm, (start, _, _, _, _) in zip(rhythms, context['blocks']):
events.extend(dataclasses.replace(e, start=e.start + start, end=e.end + start, harmonic_time=e.harmonic_time + start if e.harmonic_time is not None else None) for e in rhythm.events)
assigned = assign_acoustic(events, context['harmony'], context['breaks'])
result = []
for rhythm, (start, end, melody, harmony, lengths) in zip(rhythms, context['blocks']):
local = tuple(dataclasses.replace(e, start=e.start - start, end=e.end - start, harmonic_time=e.harmonic_time - start if e.harmonic_time is not None else None)
for e in assigned if start <= e.start < end)
exceptions = tuple(e for e in rhythm.exceptions if e[0] != 'layer_assignment')
result.append(dataclasses.replace(rhythm, events=local, contributions=evaluate(local, 'acoustic', melody, harmony, lengths),
pattern_id=rhythm.pattern_id.removesuffix('.anticipated') + ('.anticipated' if any(e.harmonic_time is not None for e in local) else ''),
exceptions=(*exceptions, ('layer_assignment', 'combined attacks or bass/upper eighth pairs; release at every next attack'))))
return tuple(result)
def form_cost(rhythms, groups):
return sum(r.cost * len(g) for r, g in zip(rhythms, groups))
def choose_cycles(groups, part, melody, seed, *, breaks=(), composition=None):
"""Select a small recurring vocabulary against every occurrence in the form.
No local shortlist precedes whole-form evaluation. A pattern/mode is retained
as one decision even where a chord boundary changes its legal release.
"""
composition = composition or generator.vocabulary.current()
policy = composition.accompaniment(part)
settings = policy['groove']
selected, locations, reports = [], [], []
runs = []
for group in groups:
m = group[0][0]
key = (meter_context(m), generator.model.fraction(m['duration']))
if not runs or runs[-1][0] != key:
runs.append((key, []))
runs[-1][1].append(group)
for ((meter, _), length), run in runs:
grouping, grouping_evidence = grouping_for(run, melody)
patterns = patterns_for(policy, run[0][0][0], (length, length), grouping)
if not patterns:
patterns = patterns_for(policy, run[0][0][0], (length,), grouping)
requested = settings['cycle_patterns']
identities = {p['id']: identity(p) for p in patterns}
available = len(set(identities.values()))
cache, estimates = {}, {}
context = acoustic_context(run, melody, breaks) if part == 'acoustic' else None
for i, group in enumerate(run):
lengths = tuple(generator.model.fraction(m['duration']) for m, _, _ in group)
local_context = acoustic_context((group,), melody, breaks) if context else None
for p in patterns:
for anticipate in (False, True):
value = realize(project(p, fractions.Fraction(), lengths), group, part, melody, anticipate=anticipate, assign=part != 'acoustic')
cache[i, p['id'], anticipate] = value
try:
estimates[i, p['id'], anticipate] = realize_acoustic_form((value,), local_context)[0] if context and value is not None else value
except ValueError:
estimates[i, p['id'], anticipate] = None
winner, examined = None, 0
for width in range(min(requested, len(run), available), 0, -1):
layers = []
for slot in range(width):
unique = {}
for p in patterns:
for anticipate in (False, True):
values = [estimates[i, p['id'], anticipate] for i in range(slot, len(run), width)]
if any(v is None for v in values):
continue
cost = sum(v.cost * len(run[i]) for i, v in zip(range(slot, len(run), width), values))
rank = (cost, generator.solo.policy.stable_rank(seed, (meter, slot, p['id'], anticipate)))
option = (rank, p, anticipate)
key = identities[p['id']]
if key not in unique or rank < unique[key][0]:
unique[key] = option
ordered = sorted(unique.values(), key=lambda o: o[0])
diverse, families = [], set()
for option in ordered:
if option[1]['family'] not in families:
diverse.append(option)
families.add(option[1]['family'])
diverse.extend(o for o in ordered if o not in diverse)
layers.append(diverse[:settings['slot_candidates']])
for cycle in itertools.product(*layers):
if len({identities[o[1]['id']] for o in cycle}) != width:
continue
examined += 1
cost = sum(o[0][0] for o in cycle)
if context:
values = [cache[i, cycle[i % width][1]['id'], cycle[i % width][2]] for i in range(len(run))]
try:
cost = form_cost(realize_acoustic_form(values, context), run)
except ValueError:
continue
rank = (cost, tuple(o[0][1] for o in cycle))
if winner is None or rank < winner[0]:
winner = rank, cycle
if winner is not None:
break
if winner is None:
raise ValueError(f'source measure {run[0][0][0]["number"]}: no legal recurring {part} groove for {meter}; check harmonic releases and pattern vocabulary')
cycle = winner[1]
report = {'source_start': run[0][0][2], 'source_end': run[-1][-1][2], 'meter': meter,
'grouping': list(grouping), 'grouping_evidence': grouping_evidence, 'eligible_patterns': len(patterns),
'requested_patterns': requested, 'cycle_patterns': len(cycle), 'patterns': [o[1]['id'] for o in cycle],
'anticipation': [o[2] for o in cycle], 'cost': winner[0][0], 'complete_cycles_compared': examined,
'retrieval_cost': sum(o[0][0] for o in cycle),
'reduction': 'available distinct, legal patterns or source length' if len(cycle) < requested else None}
reports.append(report)
for i, group in enumerate(run):
_, p, anticipate = cycle[i % len(cycle)]
selected.append((p, anticipate, cache[i, p['id'], anticipate]))
locations.append({'cycle_start': run[0][0][2], 'cycle_slot': i % len(cycle), 'base_pattern': p['id'], 'fill': None})
context = acoustic_context(groups, melody, breaks) if part == 'acoustic' else None
add_fills(groups, selected, locations, part, melody, seed, context=context, composition=composition)
if context:
realized = realize_acoustic_form([r for _, _, r in selected], context)
selected = [(p, a, r) for (p, a, _), r in zip(selected, realized)]
for report in reports:
report['final_cost'] = sum(r.cost * len(g) for g, r in zip(groups, realized) if report['source_start'] <= g[0][2] <= report['source_end'])
report['attack_assignment'] = 'sequential_combined_except_eighth_pairs'
return selected, locations, reports
def add_fills(groups, selected, locations, part, melody, seed, *, context=None, composition=None):
"""Fixed source-position answers; no iteration seed or generated lead input."""
composition = composition or generator.vocabulary.current()
policy = composition.accompaniment(part)
spacing = policy['groove']['fill_spacing_measures']
if not groups:
return
body_start, body_end = groups[0][0][2], groups[-1][-1][2] + 1
body_length = body_end - body_start
options = []
if body_length < spacing:
return
original = realize_acoustic_form([r for _, _, r in selected], context) if context else tuple(r for _, _, r in selected)
def proposal(i, candidate, baseline):
if not context:
return candidate.cost < baseline[i].cost - 1e-9, baseline[i].cost - candidate.cost
raw = [r for _, _, r in selected]
raw[i] = candidate
try:
revised = realize_acoustic_form(raw, context)
except ValueError:
return False, 0
middle = generator.model.fraction(groups[i][0][0]['duration'])
# A second-bar fill must not alter the preceding/following block's lane
# assignment or release, nor any part of its own first bar.
if any(a.events != b.events for j, (a, b) in enumerate(zip(baseline, revised)) if j != i):
return False, 0
if tuple(e for e in baseline[i].events if e.start < middle) != tuple(e for e in revised[i].events if e.start < middle):
return False, 0
gain = form_cost(baseline, groups) - form_cost(revised, groups)
return gain > 1e-9, gain
for i, (group, (base, anticipate, _)) in enumerate(zip(groups, selected)):
current = original[i]
if len(group) != 2:
continue
last, _, index = group[-1]
length = generator.model.fraction(last['duration'])
reason = 'form_end' if i == len(groups) - 1 else 'section_end' if groups[i + 1][0][0].get('section_label') else None
if reason is None and max((min(b, length) for a, b in melody[index]), default=fractions.Fraction()) <= length - 1:
reason = 'melody_breath'
if reason is None:
continue
middle = generator.model.fraction(group[0][0]['duration'])
if any(e.start < middle < e.end for e in current.events):
continue
for variation in policy['patterns']:
if variation['family'] != base['family'] or tuple(variation['lengths']) != tuple(base['lengths']) or variation.get('meter') != base.get('meter') or tuple(variation.get('grouping', ())) != tuple(base.get('grouping', ())):
continue
hybrid = {**base, 'id': base['id'] + '.fill.' + variation['id'], 'events':
[r for r in base['events'] if fractions.Fraction(r['onset']) < middle] +
[r for r in variation['events'] if fractions.Fraction(r['onset']) >= middle]}
candidate = realize(hybrid, group, part, melody, anticipate=anticipate, assign=part != 'acoustic')
if candidate is None:
continue
if not context and tuple(e for e in candidate.events if e.start < middle) != tuple(e for e in current.events if e.start < middle):
continue
valid, gain = proposal(i, candidate, original)
if not valid:
continue
options.append((-gain, generator.solo.policy.stable_rank(seed, (index, hybrid['id'])), i, index, hybrid, anticipate, candidate, reason))
chosen = []
for _, _, i, index, pattern, anticipate, candidate, reason in sorted(options):
if any(min(abs(index - other), body_length - abs(index - other)) < spacing for other in chosen):
continue
if context and not proposal(i, candidate, realize_acoustic_form([r for _, _, r in selected], context))[0]:
continue
selected[i] = pattern, anticipate, candidate
locations[i]['fill'] = {'pattern': pattern['id'], 'source_index': index, 'reason': reason}
chosen.append(index)
+30
View File
@@ -0,0 +1,30 @@
"""Explicit single-role build. Does not publish or consume an existing ensemble."""
import argparse
import json
import pathlib
import time
import generator.composition
import generator.composition_types
import generator.musicxml
import generator.parts
import generator.source
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--song', type=pathlib.Path, required=True)
parser.add_argument('--role', choices=generator.composition_types.ROLES, required=True)
parser.add_argument('--data-dir', type=pathlib.Path, default=pathlib.Path('data'))
parser.add_argument('--output', type=pathlib.Path, required=True)
parser.add_argument('--cache-dir', type=pathlib.Path)
parser.add_argument('--settings', type=pathlib.Path)
args = parser.parse_args()
source = generator.source.SourceAnalysis.read(generator.parts.prepare_document(generator.musicxml.load_musicxml(args.song)))
start = time.monotonic()
result = generator.composition.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.write_text(json.dumps(result, indent=2))
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()
+161
View File
@@ -0,0 +1,161 @@
"""Independent, reproducible role generation from source and policy inputs.
Only source documents and production resources enter generate_role. Completed
roles and published assets are downstream values, never composition inputs.
"""
from __future__ import annotations
import copy
import dataclasses
import functools
import hashlib
import json
import pathlib
import time
import generator.accompaniment
import generator.acoustic
import generator.arrange.assembly
import generator.arrange.form
import generator.arrange.generate
import generator.comping
import generator.comping_rhythm
import generator.composition_types
import generator.fretboard
import generator.input_audit
import generator.role_cache
import generator.score
import generator.solo.arrangement
import generator.solo.authored
import generator.solo.composer
import generator.solo.learned
import generator.solo.notation
import generator.solo.report
import generator.source
import generator.validation
import generator.vocabulary
VERSION = 'independent-roles-1'
def dependencies(role, resources, measures=None):
names = ['harmony', 'lead_ins'] if role.endswith('_solo') else []
accompaniment = {}
if role.endswith('_accompaniment'):
policy = generator.vocabulary.accompaniment(role.split('_')[0], resources)
accompaniment = {'accompaniment': generator.comping_rhythm.relevant_policy(policy, measures) if measures is not None else policy}
return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']},
**({'voicing_families': generator.vocabulary.accompaniment('electric', resources)['voicing_families']} if role == 'electric_melody' else {}),
**accompaniment,
**{name: resources[name] for name in names}}
def _resource_key(directory, role):
return {'model': hashlib.sha256((directory / 'models' / 'solo.json').read_bytes()).hexdigest()} if role.endswith('_solo') else {}
@functools.lru_cache(maxsize=4)
def _solo_model(directory, fingerprint):
return generator.solo.learned.Model.load(pathlib.Path(directory) / 'models' / 'solo.json')
def generate_role(source, role, data_dir, settings=None, cache_dir=None) -> generator.composition_types.RoleResult:
started = time.monotonic()
with generator.input_audit.composition(data_dir, role, cache_dir) as reads:
result = _generate_role(source, role, data_dir, settings, cache_dir)
# 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['observed_reads'] = sorted(reads)
result['seconds'] = time.monotonic() - started
return result
def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
if role not in generator.composition_types.ROLES:
raise ValueError('Unknown generation role: ' + role)
data_dir = pathlib.Path(data_dir)
resources = generator.vocabulary.load(data_dir / 'composition')
settings = settings or {}
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 + '/' + generator.solo.learned.VERSION if role.endswith('_solo') else VERSION
inputs = {'version': algorithm, 'source': source.musical_key, 'role': role, 'settings': settings,
'vocabulary': dependencies(role, resources, generator.score.source_staff(source.document())['measures']), 'catalogs': ({'authored': registered['sha256'], 'id': registered['id'], 'pitch_convention': registered['pitch_convention']} if registered else _resource_key(data_dir, role)), 'code': generator.role_cache.code_key()}
# Source notation/lyrics also invalidate cached score representation, never the musical seed.
key = generator.vocabulary.fingerprint({'inputs': inputs, 'notation': source.document_json})
path = pathlib.Path(cache_dir) / role / (key + '.json') if cache_dir else None
cached = generator.role_cache.restore(path, key)
if cached is not None:
return cached
started = time.monotonic()
document = source.document()
part = role.split('_')[0]
plan = generator.arrange.form.FormPlan.read(document, part)
trace = {'source': source.musical_key, 'role': role, 'production_resources': inputs['catalogs'],
'vocabulary': generator.vocabulary.fingerprint(inputs['vocabulary']), 'settings': settings, 'algorithm': algorithm, 'code': inputs['code'], 'cache_key': key}
choice_source = source.accompaniment_key() if role.endswith('_accompaniment') else source.musical_key
choice_data, choice_settings = inputs['vocabulary'], settings
if role.endswith('_accompaniment'):
# Voicing weights invalidate the concrete cache, but must not reshuffle
# rhythm patterns. Removing this new field preserves the pre-policy seed.
choice_data = copy.deepcopy(choice_data)
choice_data['roles']['role'].pop('voicing', None)
for field in ('provenance', 'license', 'research'):
choice_data['accompaniment'].pop(field, None)
choice_settings = {k: v for k, v in settings.items() if k != 'voicing'}
seed = generator.vocabulary.fingerprint({'source': choice_source, 'role': role, 'settings': choice_settings, 'data': choice_data, 'version': algorithm})
details = {}
solo_report = ''
composition_timings = {}
composition = generator.vocabulary.CompositionContext.create(resources, role, settings | {'choice_seed': seed})
if role.endswith('_solo'):
result = generator.arrange.generate._arrange_document(document, part, plan, owned={'slash'}, composition=composition)
si = 3 if part == 'acoustic' else 4
first, count, offset, length = generator.solo.arrangement.regions(document)[si]
# The template/harmony comes directly from source form, not another generated part.
authored = generator.score.source_staff(document)['measures']
section = plan.sections[si]
effective = generator.arrange.generate.section_source(authored, section, plan.sections[si + 1])
template = copy.deepcopy(effective[section.source_measure_index:section.source_measure_index + count])
initial = generator.accompaniment.carried_chords(effective)[section.source_measure_index]
start, end = next((a - offset, b - offset) for a, b, r in plan.windows if r == role)
notation_first = first
if start < 0:
# The preceding arrangement harmony comes from the source form. Do
# not invent harmony from a comparison score's unlabelled lead-in.
template.insert(0, copy.deepcopy(result['staves'][0]['measures'][first - 1]))
notation_first -= 1
initial = generator.accompaniment.carried_chords(result['staves'][0]['measures'])[first - 1]
if registered:
solo = generator.solo.authored.realize(registered, generator.source.analyze(document), template, part, start, end, initial, composition=composition)
else:
solo_model = _solo_model(str(data_dir), inputs['catalogs']['model'])
solo = generator.solo.composer.compose(generator.source.analyze(document), template, part, end, initial=initial, learned_model=solo_model, expressions_enabled=composition.role().get('expressions', True), start=start, composition=composition)
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())
output = generator.solo.notation.measures(result['staves'][0]['measures'][notation_first:first + count], relative, 'solo_' + part)
generator.solo.notation.replace_region(result, notation_first, output)
details = generator.solo.report.structure(solo, composition=composition)
details['allocation'] = {'start': str(start), 'end': str(end), 'lead_in_measures': first - notation_first}
solo_report = generator.solo.report.describe(solo, first, offset, detail=details, composition=composition)
elif role.endswith('_accompaniment'):
canonical = generator.comping.Canonical(document, part, seed, composition=composition, plan=plan)
result = generator.arrange.generate._arrange_document(document, part, plan, owned={'accompaniment'}, accompaniment=canonical, composition=composition)
details = {'canonical_comping': canonical.report, 'guitar_placement': canonical.placement_report, 'accompaniment_rhythm': canonical.rhythm_report, 'groove_cycles': canonical.groove_report, 'rhythm_source': choice_source}
else:
placement_report = {}
result = generator.arrange.generate._arrange_document(document, part, plan, owned={'vocal' if part == 'vocal' else 'melody'}, composition=composition, placement_report=placement_report)
if placement_report:
details['guitar_placement'] = placement_report
allowed = tuple((a, b) for a, b, r in plan.windows if r == role)
generator.arrange.assembly.mask(result, allowed)
if role == "acoustic_accompaniment":
generator.acoustic.validate_comping(result)
staff = result['staves'][0]
for measure in staff['measures']:
measure['id'] = role + '_' + measure['id']
staff['tuplets'] = generator.score.reidentify(staff['measures'], staff.get('tuplets', []))
if part != 'vocal':
result['staves'][0]['tablature'] = True
generator.validation.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}
# Sidecar diagnostics use the same rational-safe representation as reports.
# Identical insertion order after cold generation and cache loading also
# keeps serialized scores and the catalog byte-for-byte reproducible.
value = json.loads(json.dumps(value, default=str, sort_keys=True))
generator.role_cache.store(path, key, value)
return value
+28
View File
@@ -0,0 +1,28 @@
"""Worker requests and role results; score serialization stays unchanged."""
from __future__ import annotations
import dataclasses
import pathlib
import typing
import generator.source
ROLES = ('acoustic_accompaniment', 'electric_accompaniment', 'electric_melody', 'vocal_melody', 'acoustic_solo', 'electric_solo')
@dataclasses.dataclass(frozen=True)
class RoleRequest:
source: generator.source.SourceAnalysis
role: str
directory: pathlib.Path
settings_json: str = '{}'
cache: pathlib.Path | None = None
class RoleResult(typing.TypedDict):
role: str
document: dict
trace: dict
details: dict
report: str
seconds: float
composition_timings: dict
cache_hit: bool
observed_reads: typing.NotRequired[list[str]]
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
import bisect
import copy
import dataclasses
import fractions
import generator.accompaniment
import generator.guitar
import generator.model
@dataclasses.dataclass(frozen=True)
class MeasureOrigin:
onset: fractions.Fraction
role: str
measure: dict
start: fractions.Fraction = fractions.Fraction()
end: fractions.Fraction | None = None
@dataclasses.dataclass(frozen=True)
class VoicingLink:
target: dict
references: tuple[dict, ...]
def link_voicings(source: list[dict], origins: list[MeasureOrigin]) -> list[VoicingLink]:
"""Link accompaniment to melody attacks in the same authored harmony span."""
boundaries, previous, start = [], None, fractions.Fraction()
for measure in source:
for harmony in measure['harmonies']:
if not generator.accompaniment.same_chord(previous, harmony):
boundaries.append(start + generator.model.fraction(harmony['onset']))
previous = harmony
start += generator.model.fraction(measure['duration'])
melody, targets = {}, []
for origin in origins:
for voice in origin.measure['voices']:
for note in voice['notes']:
local = generator.model.fraction(note['onset'])
if '_harmony' not in note or local < origin.start or (origin.end is not None and local >= origin.end):
continue
onset = origin.onset + local
span = bisect.bisect_right(boundaries, onset)
if origin.role == 'melody' and note.get('_harmonize'):
melody.setdefault(span, []).append((onset, note['_harmony'], note))
elif origin.role == 'accompaniment':
targets.append((span, onset, note))
links = []
for span, onset, note in targets:
matches = [(time, reference) for time, harmony, reference in melody.get(span, []) if generator.accompaniment.same_chord(harmony, note['_harmony'])]
# Before the first melody chord, use the first grip in this harmony span.
matches.sort(key=lambda item: (item[0] > onset, abs(item[0] - onset), item[1]['id']))
if matches:
links.append(VoicingLink(note, tuple(reference for _, reference in matches)))
return links
def reuse_voicings(links: list[VoicingLink]) -> None:
"""Copy placed melody grips without changing either part's rhythm or ties."""
for link in links:
for reference in link.references:
strings = tuple(sorted((p['tablature']['string'] for p in reference['pitches']), reverse=True))
if strings not in generator.guitar.ELECTRIC_STRINGS:
continue
note = link.target
note['pitches'] = [copy.deepcopy({**{k: p[k] for k in ('letter', 'alter', 'octave', 'tablature')}, 'id': f'{note["id"]}_s{p["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']}) for p in reference['pitches']]
break
+1
View File
@@ -0,0 +1 @@
"""Score engraving implementation; import the owning modules explicitly."""
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
import fractions
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.types
import generator.model
def staff_attributes(index: generator.engraving.notation.ScoreIndex, state: dict, time: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, space: float, source: str, fields: tuple[str, ...]) -> list[generator.engraving.types.Primitive]:
result = []
x = 0
references = (source,)
clef = state['clef']
lines = state['staff_details']['lines']
if 'clef' in fields and clef['sign'] != 'none':
sign = clef['sign']
name = {'G': 'gClef', 'F': 'fClef', 'C': 'cClef', 'percussion': 'unpitchedPercussionClef1'}[sign]
y = -space * (clef.get('line', 2) - 1) if sign in ('G', 'F', 'C') else -space * (lines - 1) / 2
glyph = fonts.glyph(name, x, y, space, 'clef', references)
result.append(glyph)
if octave := clef.get('octave_change', 0):
label = str(abs(octave) * 7 + 1)
result.append(fonts.text(label, (glyph.box.left + glyph.box.right) / 2, glyph.box.top - 2 if octave > 0 else glyph.box.bottom + 14, 14, 'clef-octave', references, True))
x = generator.engraving.types.bounds(result).right + space
if 'key' in fields and clef['sign'] != 'percussion':
key = state['key']
steps = key.get('steps')
if steps is None:
order = 'fcgdaeb' if key.get('fifths', 0) >= 0 else 'beadgcf'
steps = [{'letter': c, 'alter': key['alterations'][generator.model.LETTERS.index(c)]} for c in order if generator.model.alteration(key['alterations'][generator.model.LETTERS.index(c)])]
cancellation = key.get('cancel', 0)
if cancellation:
order = 'fcgdaeb' if cancellation > 0 else 'beadgcf'
old = generator.model.key_from_fifths(cancellation)['alterations']
steps = [{'letter': c, 'alter': 0} for c in order if generator.model.alteration(old[generator.model.LETTERS.index(c)]) and generator.model.alteration(old[generator.model.LETTERS.index(c)]) != generator.model.alteration(key['alterations'][generator.model.LETTERS.index(c)])] + steps
for step in steps:
pitch = {'letter': step['letter'], 'octave': step.get('octave', 5), 'id': source}
position = index.pitch_position(pitch, time)
if 'octave' not in step:
while position > 9:
position -= 7
while position < 2:
position += 7
items = generator.engraving.notation.accidental(fonts, generator.model.alteration(step['alter']), {'name': step.get('accidental', '')}, space, references)
result.extend(p.moved(x, -position * space / 2) for p in items)
x += generator.engraving.types.bounds(items).right - generator.engraving.types.bounds(items).left + 4
if steps:
x += space / 2
if 'meter' in fields:
meter = state['meter']
if meter.get('unmetered'):
result.append(fonts.text('senza misura', x, -space * 2, 16, 'meter', references))
elif meter.get('symbol') in ('common', 'cut'):
result.append(fonts.glyph('timeSigCommon' if meter['symbol'] == 'common' else 'timeSigCutCommon', x, -space * 2, space, 'meter', references))
else:
for gi, group in enumerate(meter['groups']):
if gi:
plus = fonts.glyph('timeSigPlus', x, -space * 2, space, 'meter', references)
result.append(plus)
x = plus.box.right + 7
numerator = '+'.join(map(str, group['beats']))
denominator = str(group['unit'])
rows = [(numerator, -space * 2)] if meter.get('symbol') == 'single-number' else [(numerator, -space * 3), (denominator, -space)]
groups = []
for value, y in rows:
items = []
cursor = 0
if y == -space and meter.get('symbol') in ('note', 'dotted-note'):
unit = fractions.Fraction(4, group['unit'])
kind = next((name for name, length in generator.model.TYPE_LENGTHS.items() if length == unit), None)
glyph_name = {'whole': 'noteWhole', 'half': 'noteHalfUp', 'quarter': 'noteQuarterUp', 'eighth': 'note8thUp'}.get(kind, 'note' + str(kind) + 'Up')
if kind and glyph_name in fonts.names:
glyph = fonts.glyph(glyph_name, 0, y + space, space * 0.55, 'meter', references)
items.append(glyph)
if meter['symbol'] == 'dotted-note':
items.append(fonts.glyph('augmentationDot', glyph.box.right + 3, y + space / 2, space * 0.55, 'meter', references))
groups.append(items)
continue
for char in value:
glyph = fonts.glyph('timeSigPlus' if char == '+' else 'timeSig' + char, cursor, y, space, 'meter', references)
items.append(glyph)
cursor = glyph.box.right + 1
groups.append(items)
width = max(generator.engraving.types.bounds(items).right for items in groups)
for items in groups:
result.extend(p.moved(x + (width - generator.engraving.types.bounds(items).right) / 2, 0) for p in items)
x += width + 6
if 'staff_details' in fields and state['staff_details'].get('capo'):
result.append(fonts.text(f'Capo {state["staff_details"]["capo"]}', x, -space * (lines + 1), 16, 'staff-details', references))
return result
def barline(style: str, x: float, top: float, source: str) -> list[generator.engraving.types.Primitive]:
if style == 'none':
return []
references = (source,)
if style in ('dashed', 'dotted'):
dash, gap = (5, 4) if style == 'dashed' else (1, 4)
return [generator.engraving.types.line('barline', references, ((x, y), (x, min(y + dash, 0)))) for y in range(round(top), 0, dash + gap)]
if style in ('tick', 'short'):
return [generator.engraving.types.line('barline', references, ((x, top - 4 if style == 'tick' else top * 0.75), (x, top + 5 if style == 'tick' else top * 0.25)))]
weights = {'regular': [1.4], 'heavy': [4], 'light-light': [1.4, 1.4], 'light-heavy': [1.4, 4], 'heavy-light': [4, 1.4], 'heavy-heavy': [4, 4]}[style]
return [generator.engraving.types.line('barline', references, ((x - (len(weights) - 1 - i) * 5, top), (x - (len(weights) - 1 - i) * 5, 0)), width) for i, width in enumerate(weights)]
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
import fractions
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.types
import generator.harmony
import generator.model
def audit_primitives(primitives, width, height, fonts):
for item in primitives:
if not item.box.finite():
raise generator.engraving.types.EngravingError(f'{item.sources}: non-finite or inverted primitive geometry')
if item.box.left < 4 or item.box.top < 4 or item.box.right > width - 4 or item.box.bottom > height - 4:
raise generator.engraving.types.EngravingError(f'{item.sources}, {item.role}: primitive outside page bounds')
if item.kind == 'glyph':
actual = fonts.glyph_box(item.value, item.size).moved(*item.points[0])
if any(abs(a - b) > 0.001 for a, b in zip((actual.left, actual.top, actual.right, actual.bottom), (item.box.left, item.box.top, item.box.right, item.box.bottom))):
raise generator.engraving.types.EngravingError(f'{item.sources}: glyph bounds disagree with its actual outline')
def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocument, fonts: generator.engraving.fonts.Fonts, *, systems_per_page: int | None = 2) -> None:
index = generator.engraving.notation.ScoreIndex(document)
fragments, primitives = {}, {}
previous_end = fractions.Fraction()
scale = layout.pages[0].systems[0].scale
for page in layout.pages:
if not page.systems or (systems_per_page is not None and len(page.systems) != systems_per_page and (page is not layout.pages[-1] or not 1 <= len(page.systems) < systems_per_page)):
raise generator.engraving.types.EngravingError('pages must contain two music systems, except an odd final system')
for first, second in zip(page.systems, page.systems[1:]):
if first.box.bottom + layout.style.system_gap > second.box.top + 0.01:
raise generator.engraving.types.EngravingError('music systems overlap their page rows')
audit_primitives(page.primitives, page.width, page.height, fonts)
header = generator.engraving.types.bounds([p for p in page.primitives if p.role in ('title', 'subtitle')])
footer = generator.engraving.types.bounds([p for p in page.primitives if p.role == 'page_number'])
if page.systems[0].box.top < header.bottom + layout.style.page_clearance - 0.01 or page.systems[-1].box.bottom > footer.top - layout.style.page_clearance + 0.01:
raise generator.engraving.types.EngravingError('music overlaps the page header or footer')
for item in page.primitives:
for source in item.sources:
primitives.setdefault(source, []).append(item)
for system in page.systems:
if system.scale != scale:
raise generator.engraving.types.EngravingError('notation size must remain consistent throughout a part')
for role in ('staff', 'tab_staff'):
lines = [p for p in system.primitives if p.role == role]
if lines:
left = min(x for p in lines for x, _ in p.points)
right = max(x for p in lines for x, _ in p.points)
if abs(left - layout.style.margin_x - 1) > 0.01 or abs(right - (page.width - layout.style.margin_x - 1)) > 0.01:
raise generator.engraving.types.EngravingError('music systems must have consistent rendered staff widths')
if system.start != previous_end or system.end <= system.start:
raise generator.engraving.types.EngravingError('layout systems do not partition the score timeline')
previous_end = system.end
baseline = max(p.points[0][1] for p in system.primitives if p.role == 'staff')
for anchor in system.anchors:
if anchor.fragment.kind == 'tone':
expected = index.pitch_position(index.tones[anchor.source][1], anchor.time)
if abs(anchor.y - (baseline - expected * layout.style.space * system.scale / 2)) > 0.01:
raise generator.engraving.types.EngravingError(f'{anchor.source}: displayed staff position changed pitch')
role = 'head' if anchor.fragment.kind == 'tone' else anchor.fragment.kind
candidates = [p for p in system.primitives if p.role == role and anchor.source in p.sources and abs(p.points[0][0] - anchor.x) < 0.01]
if not candidates:
raise generator.engraving.types.EngravingError(f'{anchor.source}: visible anchor has no corresponding drawing primitive')
if role == 'head' and candidates[0].kind == 'glyph':
event, tone = index.tones[anchor.source]
expected_glyph = generator.engraving.notation.head_glyph({'type': anchor.fragment.written_type}, tone.get('notehead', event.value.get('notehead')))
if candidates[0].value != expected_glyph:
raise generator.engraving.types.EngravingError(f'{anchor.source}: notehead changed written duration or shape')
if index.staff.get('tablature') and anchor.fragment.kind == 'tone':
tone = index.tones[anchor.source][1]
tab = tone['tablature']
tab_baseline = max(p.points[0][1] for p in system.primitives if p.role == 'tab_staff')
expected_y = tab_baseline + (4 - (6 - tab['string']) * layout.style.tab_space) * system.scale
expected_x = min(a.x for a in system.anchors if a.event == anchor.event and a.time == anchor.time)
tabs = [p for p in system.primitives if p.role == 'tab' and anchor.source in p.sources]
if len(tabs) != 1 or tabs[0].value != str(tab['fret']) or abs(tabs[0].points[0][1] - expected_y) > 0.01 or abs(tabs[0].points[0][0] - expected_x) > 0.01:
raise generator.engraving.types.EngravingError(f'{anchor.source}: tablature changed position or rhythmic alignment')
for fragment in system.fragments:
fragments.setdefault(fragment.source, []).append(fragment)
# Staff, ledger, stem, beam and tie contacts are intentional. Distinct
# noteheads, accidentals and text in the same lane must remain separate.
checked = [p for p in system.primitives if p.role in ('head', 'tab', 'accidental', 'harmony', 'lyric', 'scale', 'section')]
for label in (p for p in system.primitives if p.role == 'measure_number'):
if any(label.box.overlaps(p.box, 3 * system.scale) for p in system.primitives if p is not label):
raise generator.engraving.types.EngravingError(f'{label.sources}: measure number overlaps notation')
for i, item in enumerate(checked):
for other in checked[i + 1:]:
if set(item.sources) & set(other.sources) and (item.role != 'tab' or other.role != 'tab'):
continue
if item.role != other.role and 'scale' not in (item.role, other.role) and {item.role, other.role} - {'head', 'tab', 'accidental'}:
continue
if item.box.overlaps(other.box, 0.1 * system.scale):
raise generator.engraving.types.EngravingError(f'{item.sources} and {other.sources}: colliding {item.role}/{other.role}')
expected_scales = [span for span in index.scale_spans if generator.model.fraction(span['start']) < system.end and generator.model.fraction(span['end']) > system.start]
drawn_scales = [(p.sources[0], p.value) for p in system.primitives if p.role == 'scale']
if drawn_scales != [(span['id'], span['label']) for span in expected_scales]:
raise generator.engraving.types.EngravingError('solo scale labels are missing, duplicated, or changed')
if previous_end != index.end:
raise generator.engraving.types.EngravingError('layout omits the end of the score')
for event in index.events:
note = event.value
for source in note['pitches'] or [note]:
identifier = source['id']
pieces = sorted(fragments.get(identifier, []), key=lambda f: f.start)
if not pieces:
raise generator.engraving.types.EngravingError(f'{identifier}: missing notation fragments')
cursor = event.start
for fragment in pieces:
expected_visible = not note.get('hidden') and note['kind'] != 'spacer' and source.get('print', {}).get('print-object') != 'no'
if isinstance(source.get('notehead'), dict) and source['notehead'].get('name') == 'none':
expected_visible = False
if fragment.visible != expected_visible:
raise generator.engraving.types.EngravingError(f'{identifier}: notation visibility changed')
if fragment.start != cursor:
raise generator.engraving.types.EngravingError(f'{identifier}: fragments overlap or omit metric time')
cursor = fragment.end
expected = generator.model.TYPE_LENGTHS[fragment.written_type] * (2 - fractions.Fraction(1, 2 ** fragment.dots)) * fragment.ratio
if not note.get('grace') and not note.get('measure_rest') and fragment.length() != expected:
raise generator.engraving.types.EngravingError(f'{identifier}: written fragment duration is inexact')
if fragment.visible and not any(p.role in ('head', 'tab', 'rest', 'slash') for p in primitives.get(identifier, [])):
raise generator.engraving.types.EngravingError(f'{identifier}: visible notation has no drawing primitive')
if cursor != event.start + generator.model.fraction(source['duration']):
raise generator.engraving.types.EngravingError(f'{identifier}: rendered duration changed')
if any(f.visible for f in pieces) and identifier in index.tones and index.accidentals[identifier]:
if not any(p.role == 'accidental' for p in primitives.get(identifier, [])):
raise generator.engraving.types.EngravingError(f'{identifier}: required accidental was omitted')
if identifier in index.tones and source.get('tie_to') and any(f.visible for f in pieces):
if not any(p.role == 'tie' for p in primitives.get(identifier, [])):
raise generator.engraving.types.EngravingError(f'{identifier}: tie was omitted')
if not note.get('hidden'):
for verse in note.get('lyrics', []):
text = ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision'))
identifier = note['id'] + ':lyric:' + verse['number']
if text and not any(p.role == 'lyric' and p.value == text for p in primitives.get(identifier, [])):
raise generator.engraving.types.EngravingError(f'{identifier}: lyric text was omitted or changed')
for _, _, measure in index.measures:
for value in measure['harmonies']:
label = generator.harmony.symbol(value)
if not any(p.role == 'harmony' and p.value == label for p in primitives.get(value['id'], [])):
raise generator.engraving.types.EngravingError(f'{value["id"]}: harmony was omitted')
for span in index.staff.get('tuplets', []):
if span.get('show_number', 'actual') != 'none' or span.get('bracket', 'auto') != 'no':
if not any(p.role.startswith('tuplet-') for p in primitives.get(span['id'], [])):
raise generator.engraving.types.EngravingError(f'{span["id"]}: tuplet was omitted')
for span in index.staff.get('octave_shifts', []):
if not any(p.role == 'octave-shift' for p in primitives.get(span['id'], [])):
raise generator.engraving.types.EngravingError(f'{span["id"]}: octave shift was omitted')
def audit_playback(pages, playback, style, fonts):
canonical = [system for page in pages for system in page.systems]
if len(playback) != len(canonical):
raise generator.engraving.types.EngravingError('playback must have one view per system')
for i, page in enumerate(playback):
audit_primitives(page.primitives, page.width, page.height, fonts)
expected = canonical[i:i + 2]
if len(page.systems) != len(expected):
raise generator.engraving.types.EngravingError('invalid playback system pairing')
header = generator.engraving.types.bounds([p for p in page.primitives if p.role in ('title', 'subtitle')])
footer = generator.engraving.types.bounds([p for p in page.primitives if p.role == 'page_number'])
if page.systems[0].box.top < header.bottom + style.page_clearance - 0.01 or page.systems[-1].box.bottom > footer.top - style.page_clearance + 0.01:
raise generator.engraving.types.EngravingError('playback overlaps header or footer')
for a, b in zip(page.systems, page.systems[1:]):
if a.box.bottom + style.system_gap > b.box.top + 0.01:
raise generator.engraving.types.EngravingError('playback systems overlap')
for source, actual in zip(expected, page.systems):
if (source.start, source.end, source.scale, source.fragments) != (actual.start, actual.end, actual.scale, actual.fragments):
raise generator.engraving.types.EngravingError('playback changes system content')
dy = actual.box.top - source.box.top
if len(source.primitives) != len(actual.primitives):
raise generator.engraving.types.EngravingError('playback changes primitives')
for a, b in zip(source.primitives, actual.primitives):
if (a.kind, a.role, a.sources, a.value, a.size) != (b.kind, b.role, b.sources, b.value, b.size) or len(a.points) != len(b.points) or any(abs(x - u) > .001 or abs(y + dy - v) > .001 for (x, y), (u, v) in zip(a.points, b.points)):
raise generator.engraving.types.EngravingError('playback changes notation geometry')
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
import dataclasses
import hashlib
import importlib.metadata
import io
import json
import pathlib
import platform
import re
import shutil
import PIL.Image
import PIL.ImageChops
import PIL.features
import generator.cache
import generator.engraving.validation
import generator.theme
def renderer_identity(fonts, style) -> dict:
root = pathlib.Path(__file__).resolve().parents[1]
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()})
sources = generator.cache.source_identity(('generator.engraving.pipeline', 'generator.printing'))
sources['palette.json'] = hashlib.sha256((root / 'palette.json').read_bytes()).hexdigest()
return {'fonts_style': fonts.fingerprint(style), 'python': {'implementation': platform.python_implementation(), 'version': platform.python_version()}, 'dependencies': versions,
'sources': sources}
def renderer_fingerprint(fonts, style) -> str:
return hashlib.sha256(json.dumps(renderer_identity(fonts, style), sort_keys=True).encode()).hexdigest()
def renderer_namespace(cache: pathlib.Path, renderer: str) -> pathlib.Path:
namespace = cache / renderer
if namespace.parent.is_symlink() or namespace.is_symlink():
raise ValueError('engraving cache namespaces must not be symlinks')
return namespace
def score_fingerprint(document: dict, renderer: str) -> str:
music = document
return hashlib.sha256(json.dumps([renderer, music], sort_keys=True, ensure_ascii=False).encode()).hexdigest()
def notation_fingerprint(document: dict, renderer: str) -> str:
return hashlib.sha256(json.dumps([renderer, document['staves']], sort_keys=True, ensure_ascii=False).encode()).hexdigest()
def band_fingerprint(notation: str, page, band) -> str:
context = [notation, page.width, page.height, page.supersampling, dataclasses.asdict(band)]
return hashlib.sha256(json.dumps(context, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
def restore_pages(entry: pathlib.Path, destination: pathlib.Path, validated_pages: set[str] | None = None) -> list[dict] | None:
try:
if generator.cache.has_symlink(entry) or generator.cache.has_symlink(destination):
return None
manifest = entry / 'pages.json'
if manifest.is_symlink():
return None
pages = json.loads(manifest.read_text(encoding='utf-8'))
if not isinstance(pages, list) or not 0 < len(pages) <= 10000:
return None
destination.mkdir(parents=True, exist_ok=True)
for index, page in enumerate(pages, 1):
if not isinstance(page, dict) or set(page) != {'path', 'sha256', 'first_measure', 'measure_count', 'start_beat', 'end_beat', 'systems', 'playback'} or type(page['playback']) is not bool or not isinstance(page['path'], str) or not re.fullmatch(r'(?:playback-)?[0-9]{4,5}\.png', page['path']) or not isinstance(page['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', page['sha256']):
return None
path = entry / page['path']
if path.is_symlink() or not path.is_file():
return None
target = destination / page['path']
if target.is_symlink():
return None
shutil.copyfile(path, target)
generator.engraving.validation.check_page(target, validated_pages, expected_sha256=page['sha256'])
return pages
except (OSError, ValueError, TypeError):
return None
def store_pages(entry: pathlib.Path, destination: pathlib.Path, pages: list[dict]) -> None:
with generator.cache.stage_entry(entry, '.engraving_') as staged:
for page in pages:
shutil.copyfile(destination / page['path'], staged / page['path'])
(staged / 'pages.json').write_text(json.dumps(pages), encoding='utf-8')
# Identical keys have identical pages; publish complete files, then the manifest.
generator.cache.publish_files(entry, staged, list(dict.fromkeys(page['path'] for page in pages)) + ['pages.json'])
def restore_band(entry: pathlib.Path, key: str, width: int, height: int) -> PIL.Image.Image | None:
try:
manifest, asset = entry / 'band.json', entry / 'band.png'
if generator.cache.has_symlink(entry) or manifest.is_symlink() or asset.is_symlink():
return None
metadata = json.loads(manifest.read_text(encoding='utf-8'))
if not isinstance(metadata, dict) or set(metadata) != {'context', 'width', 'height', 'sha256'}:
return None
if metadata['context'] != key or type(metadata['width']) is not int or type(metadata['height']) is not int or (metadata['width'], metadata['height']) != (width, height):
return None
digest = metadata['sha256']
if not isinstance(digest, str) or not re.fullmatch('[0-9a-f]{64}', digest):
return None
data = asset.read_bytes()
if hashlib.sha256(data).hexdigest() != digest:
return None
with PIL.Image.open(io.BytesIO(data)) as image:
if image.format != 'PNG' or image.size != (width, height) or image.mode != 'RGB':
return None
image.load()
ink = PIL.ImageChops.difference(image, PIL.Image.new('RGB', image.size, generator.theme.rgb('background'))).getbbox()
if not ink or ink[0] < 3 or ink[1] < 3 or ink[2] > width - 3 or ink[3] > height - 3:
return None
return image.copy()
except (OSError, ValueError, TypeError):
return None
def store_band(entry: pathlib.Path, key: str, image: PIL.Image.Image) -> None:
with generator.cache.stage_entry(entry, '.engraving_') as staged:
image.save(staged / 'band.png', format='PNG')
metadata = {'context': key, 'width': image.width, 'height': image.height, 'sha256': hashlib.sha256((staged / 'band.png').read_bytes()).hexdigest()}
(staged / 'band.json').write_text(json.dumps(metadata), encoding='utf-8')
generator.cache.publish_files(entry, staged, ('band.png', 'band.json'))
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
import dataclasses
import hashlib
import json
import pathlib
import PIL.ImageFont
import fontTools.pens.boundsPen
import fontTools.pens.recordingPen
import fontTools.ttLib
import generator.engraving.types
class Fonts:
def __init__(self, root: pathlib.Path):
self.root = root
try:
self.music = fontTools.ttLib.TTFont(root / 'Petaluma.otf')
self.script = fontTools.ttLib.TTFont(root / 'PetalumaScript.otf')
self.metadata = json.loads((root / 'petaluma_metadata.json').read_text())
self.names = json.loads((root / 'smufl_glyphnames.json').read_text())
except (OSError, ValueError) as error:
raise generator.engraving.types.EngravingError(f'cannot load bundled engraving fonts: {error}') from error
self.names.update(self.metadata.get('optionalGlyphs', {}))
self.cmap = self.music.getBestCmap()
self.text_cmap = self.script.getBestCmap()
self.glyphs = self.music.getGlyphSet()
self.units = self.music['head'].unitsPerEm / 4
self.metrics = {}
self.outlines = {}
self.text_fonts = {}
def fingerprint(self, style: generator.engraving.types.Style) -> str:
digest = hashlib.sha256(json.dumps(dataclasses.asdict(style), sort_keys=True).encode())
for name in ('Petaluma.otf', 'PetalumaScript.otf', 'petaluma_metadata.json', 'smufl_glyphnames.json'):
digest.update(name.encode())
digest.update((self.root / name).read_bytes())
return digest.hexdigest()
def glyph_name(self, name: str) -> str:
if name not in self.names:
raise generator.engraving.types.EngravingError(f'unknown SMuFL glyph {name!r}')
code = int(self.names[name]['codepoint'].removeprefix('U+'), 16)
if code not in self.cmap:
raise generator.engraving.types.EngravingError(f'Petaluma is missing requested SMuFL glyph {name!r}')
return self.cmap[code]
def glyph_box(self, name: str, size: float) -> generator.engraving.types.Box:
if name not in self.metrics:
pen = fontTools.pens.boundsPen.BoundsPen(self.glyphs)
self.glyphs[self.glyph_name(name)].draw(pen)
if pen.bounds is None:
raise generator.engraving.types.EngravingError(f'empty SMuFL glyph {name!r}')
self.metrics[name] = pen.bounds
left, bottom, right, top = self.metrics[name]
scale = size / self.units
return generator.engraving.types.Box(left * scale, -top * scale, right * scale, -bottom * scale)
def outline(self, name: str):
if name not in self.outlines:
pen = fontTools.pens.recordingPen.DecomposingRecordingPen(self.glyphs)
self.glyphs[self.glyph_name(name)].draw(pen)
self.outlines[name] = pen
return self.outlines[name]
def glyph(self, name: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...]) -> generator.engraving.types.Primitive:
return generator.engraving.types.Primitive('glyph', role, sources, self.glyph_box(name, size).moved(x, y), ((x, y),), name, size)
def anchor(self, name: str, anchor: str, size: float) -> tuple[float, float]:
value = self.metadata.get('glyphsWithAnchors', {}).get(name, {}).get(anchor)
if value is None:
box = self.glyph_box(name, size)
return (box.right, 0) if 'Up' in anchor else (box.left, 0)
return value[0] * size, -value[1] * size
def text_font(self, size: float):
pixels = max(1, round(size))
if pixels not in self.text_fonts:
self.text_fonts[pixels] = PIL.ImageFont.truetype(str(self.root / 'PetalumaScript.otf'), pixels)
return self.text_fonts[pixels]
def text(self, value: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...], center: bool = False) -> generator.engraving.types.Primitive:
missing = [c for c in value if ord(c) not in self.text_cmap and not c.isspace()]
if missing:
raise generator.engraving.types.EngravingError(f'Petaluma Script is missing text glyph {missing[0]!r}')
font = self.text_font(size * 4)
left, top, right, bottom = font.getbbox(value, anchor='ls')
offset = (left + right) / 8 if center else 0
x -= offset
return generator.engraving.types.Primitive('text', role, sources, generator.engraving.types.Box(x + left / 4, y + top / 4, x + right / 4, y + bottom / 4), ((x, y),), value, size)
+287
View File
@@ -0,0 +1,287 @@
from __future__ import annotations
import bisect
import collections
import dataclasses
import fractions
import math
import generator.engraving.attributes
import generator.engraving.audit
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.notes
import generator.engraving.scales
import generator.engraving.spans
import generator.engraving.tablature
import generator.engraving.types
import generator.harmony
import generator.validation
def lyric_text(verse: dict) -> str:
return ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision'))
def column(index: generator.engraving.notation.ScoreIndex, time: fractions.Fraction, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[list[generator.engraving.types.Primitive], list[generator.engraving.types.Anchor], list[generator.engraving.types.Fragment]]:
items, anchors, fragments = [], [], []
events = index.events_at(time, include_sustained=time == start)
grace_width = 0
occupied = []
for event in events:
try:
drawn, placed, pieces = generator.engraving.notes.draw_event(index, event, start, end, fonts, style)
except ValueError as error:
raise generator.engraving.types.EngravingError(f'measure {event.measure["number"]}, voice {event.voice}, {event.value["id"]}: {error}') from error
x = grace_width
collidable = [p for p in drawn if p.role in ('head', 'tab', 'rest', 'slash', 'accidental', 'dot')]
while any(p.box.moved(x, 0).overlaps(q.box, 2) for p in collidable for q in occupied):
x += style.space * 1.4
drawn = [p.moved(x, 0) for p in drawn]
placed = [a.moved(x, 0) for a in placed]
if event.value.get('grace'):
grace_width = max((p.box.right for p in drawn), default=grace_width) + 9
occupied.extend(p for p in drawn if p.role in ('head', 'tab', 'rest', 'slash', 'accidental', 'dot'))
items.extend(drawn)
items.extend(generator.engraving.tablature.column(index, event, placed, fonts, style))
anchors.extend(placed)
fragments.extend(pieces)
if event.start == time and not event.value.get('hidden'):
verses = event.value.get('lyrics', [])
for verse in verses:
lane = index.lyric_lanes.index((event.voice, verse['number']))
text = lyric_text(verse)
if text:
items.append(fonts.text(text, x + style.space / 2, lane * 23, 18, 'lyric', (event.value['id'] + ':lyric:' + verse['number'], event.value['id']), True))
measure_start, _, measure = index.measure_at(time)
for owner, _, item in index.harmonies_by_onset.get(time, []):
if owner is measure:
items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 18 if index.staff.get('tablature') else 21, 'harmony', (item['id'],)))
items.extend(generator.engraving.scales.column(index, time, start, fonts))
if time == measure_start:
if label := measure.get('section_label'):
items.append(fonts.text(label, 0, 0, 16 if index.staff.get('tablature') else 19, 'section', (measure['id'] + ':section',)))
return items, anchors, fragments
def column_metrics(index, time, start, end, fonts, style, columns):
events = index.events_at(time, include_sustained=time == start)
finish = min(end, max((event.end for event in events), default=time))
key = (time, start, finish)
if columns is not None and key in columns:
columns.move_to_end(key)
return columns[key]
drawn, placed, pieces = column(index, time, start, end, fonts, style)
result = drawn, placed, pieces, generator.engraving.types.bounds(drawn)
if columns is not None:
columns[key] = result
if len(columns) > 256:
columns.popitem(last=False)
return result
def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, justify: bool = False, *, columns=None, staff_width: float | None = None) -> generator.engraving.types.System:
items, anchors, fragments = [], [], []
state = index.state(start)
measure = index.measure_at(start)[2]
header = generator.engraving.attributes.staff_attributes(index, state, start, fonts, style.space, measure['id'] + ':initial', ('clef', 'key', 'meter', 'staff_details'))
items.extend(header)
cursor = max((p.box.right for p in header), default=0) + 22
content_left = cursor - 9
times = [start] + index.column_times[bisect.bisect_right(index.column_times, start):bisect.bisect_left(index.column_times, end)]
placements = []
groups = []
for ti, time in enumerate(times):
item_start, anchor_start = len(items), len(anchors)
drawn, placed, pieces, box = column_metrics(index, time, start, end, fonts, style, columns)
leading = []
measure_start, _, m = index.measure_at(time)
if time == measure_start and time > start:
previous = index.measure_at(time, before=True)[2]
leading.extend(generator.engraving.attributes.barline(previous.get('barlines', {}).get('right', 'regular'), 0, -style.space * (index.state(time)['staff_details']['lines'] - 1), previous['id']))
old_state = index.state(time, before=True)
fields = tuple(k for k in ('clef', 'key', 'meter', 'staff_details') if old_state[k] != index.state(time)[k])
leading.extend(p.moved(12, 0) for p in generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + ':initial', fields))
if time > start:
for owner, ci, change in index.attributes_by_onset.get(time, []):
extra = generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, owner['id'] + f':attributes:{ci}', tuple(change))
leading.extend(p.moved(max((p.box.right for p in leading), default=0) + 10, 0) for p in extra)
if time == measure_start:
# Anchor labels to the barline, not to the first note's time column.
bar_x = cursor - generator.engraving.types.bounds(leading).left if leading else 0
items.append(fonts.text(str(m['number']), bar_x, 0, 12, 'measure_number', (m['id'],), True))
if leading:
width = generator.engraving.types.bounds(leading).right - generator.engraving.types.bounds(leading).left + 14
items.extend(p.moved(cursor - generator.engraving.types.bounds(leading).left, 0) for p in leading)
cursor += width
x = cursor - min(0, box.left)
placements.append((time, x))
items.extend(p.moved(x, 0) for p in drawn)
anchors.extend(a.moved(x, 0) for a in placed)
fragments.extend(pieces)
next_time = times[ti + 1] if ti + 1 < len(times) else end
spring = style.column_gap + 13 * math.sqrt(min(4, float(next_time - time)))
cursor = x + max(style.space * 1.4, box.right) + spring
groups.append((item_start, len(items), anchor_start, len(anchors)))
width = cursor + 6
available = staff_width + 1 if staff_width is not None else style.width - style.margin_x * 2
if justify and width < available:
stretch = (available - width) / max(1, len(times))
for i, (first_item, last_item, first_anchor, last_anchor) in enumerate(groups):
items[first_item:last_item] = [p.moved(i * stretch, 0) for p in items[first_item:last_item]]
anchors[first_anchor:last_anchor] = [a.moved(i * stretch, 0) for a in anchors[first_anchor:last_anchor]]
placements = [(time, x + i * stretch) for i, (time, x) in enumerate(placements)]
width = available
if staff_width is not None:
if width > available + 0.01:
raise generator.engraving.types.EngravingError(f'at musical offset {start}: content exceeds the final staff width')
width = available
right = width - 5
last_measure = index.measure_at(end, before=True)[2]
tab_items = [p for p in items if p.role.startswith('tab')]
items = [p for p in items if not p.role.startswith('tab')]
items = generator.engraving.spans.draw_beams(index, anchors, items, start, end, content_left, right, style)
items = generator.engraving.spans.draw_spans(index, anchors, items, start, end, content_left, right, fonts, style)
items = generator.engraving.spans.place_text_lanes(items, fonts, style)
items = generator.engraving.spans.draw_lyric_spans(index, items, anchors, start, end, content_left, right, fonts)
changes = [(0, state['staff_details']['lines'])]
for time, x in placements:
lines = index.state(time)['staff_details']['lines']
if changes[-1][1] != lines:
changes.append((x - 8, lines))
staff_lines = []
for i, (x1, lines) in enumerate(changes):
x2 = changes[i + 1][0] if i + 1 < len(changes) else width - 1
for number in range(lines):
y = -number * style.space
staff_lines.append(generator.engraving.types.line('staff', (), ((x1, y), (x2, y)), style.space * 0.13))
items = staff_lines + items
if end in index.boundaries:
items.extend(generator.engraving.attributes.barline(last_measure.get('barlines', {}).get('right', 'regular'), width - 1, -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), last_measure['id']))
if start in index.boundaries:
items.extend(generator.engraving.attributes.barline(measure.get('barlines', {}).get('left', 'none'), 0, -style.space * (state['staff_details']['lines'] - 1), measure['id']))
else:
items.append(fonts.text(str(measure['number']) + ' (cont.)', 0, min(p.box.top for p in items) - 6, 12, 'continuation', (measure['id'],)))
items = generator.engraving.tablature.staff(index, items + tab_items, start, end, content_left, width - 1, fonts, style)
return generator.engraving.types.System(start, end, tuple(items), tuple(anchors), tuple(fragments), generator.engraving.types.bounds(items))
def fit_part_systems(systems, index, available_height, fonts, style):
def fitting_scale(items):
scale = 1
for first in range(len(items)):
pair = items[first:first + 2]
height = sum(s.box.bottom - s.box.top for s in pair)
scale = min(scale, (available_height - style.system_gap * (len(pair) - 1)) / height)
return scale
scale = fitting_scale(systems)
columns = collections.OrderedDict()
# Fit the entire part together; only spacing, never page-specific glyph sizes, varies.
for _ in range(8):
width = (style.width - 2 * style.margin_x - 2) / scale
fitted = [make_system(index, s.start, s.end, fonts, style, justify=s.end < index.end, columns=columns, staff_width=width) for s in systems]
next_scale = fitting_scale(fitted)
if scale <= next_scale + 0.000001:
return [s.scaled(scale) for s in fitted]
scale = next_scale
raise generator.engraving.types.EngravingError('part spacing did not converge')
def part_header(document, fonts, style):
size = 16
title = document['metadata']['title']
item = fonts.text(title, style.margin_x, style.margin_y + size, size, 'title', ())
while item.box.right > style.width - style.margin_x and size > 12:
size -= 1
item = fonts.text(title, style.margin_x, style.margin_y + size, size, 'title', ())
if item.box.right > style.width - style.margin_x:
raise generator.engraving.types.EngravingError('score title cannot fit the page without clipping')
result = [item]
subtitle = ' - '.join(str(value) for value in (document['metadata'].get('composer'), document['staves'][0].get('name')) if value)
if document['metadata'].get('solos_enabled') is False:
subtitle += ' - Improvisation'
if not subtitle:
return result
credit = fonts.text(subtitle, 0, style.margin_y + size, 12, 'subtitle', ())
width = credit.box.right - credit.box.left
if item.box.right + 20 + width <= style.width - style.margin_x:
result.append(credit.moved(style.width - style.margin_x - credit.box.right, 0))
return result
line = ''
baseline = item.box.bottom + 15
for word in subtitle.split():
text = ' '.join(filter(None, (line, word)))
candidate = fonts.text(text, style.margin_x, baseline, 12, 'subtitle', ())
if candidate.box.right > style.width - style.margin_x and line:
result.append(fonts.text(line, style.margin_x, baseline, 12, 'subtitle', ()))
baseline = result[-1].box.bottom + 15
text = word
candidate = fonts.text(text, style.margin_x, baseline, 12, 'subtitle', ())
if candidate.box.right > style.width - style.margin_x:
raise generator.engraving.types.EngravingError('score credits cannot fit the page without clipping')
line = text
result.append(fonts.text(line, style.margin_x, baseline, 12, 'subtitle', ()))
return result
def page_views(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, *, index=None) -> tuple[tuple[generator.engraving.types.Page, ...], tuple[generator.engraving.types.Page, ...]]:
index = index or generator.engraving.notation.ScoreIndex(document)
header = part_header(document, fonts, style)
top = generator.engraving.types.bounds(header).bottom + style.page_clearance
footers = [fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page_number', (), True) for number in range(1, len(systems) + 1)]
bottom = min(p.box.top for p in footers) - style.page_clearance
if bottom - top <= style.system_gap:
raise generator.engraving.types.EngravingError('page header leaves no room for two music systems')
fitted = fit_part_systems(systems, index, bottom - top, fonts, style)
def place(stride):
pages = []
for first in range(0, len(fitted), stride):
number = len(pages) + 1
pair = fitted[first:first + 2]
slack = max(0, bottom - top - sum(s.box.bottom - s.box.top for s in pair) - style.system_gap * (len(pair) - 1))
spacing = slack / 3 if len(pair) == 2 else 0
cursor = top + spacing
placed = []
for system in pair:
dx, dy = style.margin_x + 1, cursor - system.box.top
placed.append(dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy)))
cursor = placed[-1].box.bottom + style.system_gap + spacing
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footers[number - 1]])))
return tuple(pages)
return place(2), place(1)
def paginate(systems, document, fonts, style, *, index=None):
return page_views(systems, document, fonts, style, index=index)[0]
def layout_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts, *, index=None) -> tuple[generator.engraving.types.System, ...]:
if index is None:
generator.validation.validate_document(document)
index = generator.engraving.notation.ScoreIndex(document)
if not index.measures:
raise generator.engraving.types.EngravingError('score has no measures')
systems = []
start = fractions.Fraction()
available = style.width - style.margin_x * 2 - 2
while start < index.end:
columns = collections.OrderedDict()
candidates = index.boundary_times[bisect.bisect_right(index.boundary_times, start):]
selected = None
# Reserve title/credit and footer bands before fitting complete measures.
height_limit = style.height - style.margin_y * 2 - 110
for end in candidates:
trial = make_system(index, start, end, fonts, style, columns=columns)
if trial.box.right - trial.box.left > available or trial.box.bottom - trial.box.top > height_limit:
break
selected = trial
if selected is None:
measure = index.measure_at(start)[2]
raise generator.engraving.types.EngravingError(f'measure {measure["number"]} ({measure["id"]}): complete measure exceeds the page width or height at readable spacing')
systems.append(selected)
start = selected.end
return tuple(systems)
def layout_score(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> generator.engraving.types.LayoutDocument:
generator.validation.validate_document(document)
style = generator.engraving.types.score_style(document, style)
index = generator.engraving.notation.ScoreIndex(document)
systems = layout_systems(document, style, fonts, index=index)
layout = generator.engraving.types.LayoutDocument(paginate(systems, document, fonts, style, index=index), style, fonts.fingerprint(style))
generator.engraving.audit.audit_layout(document, layout, fonts)
return layout
+200
View File
@@ -0,0 +1,200 @@
from __future__ import annotations
import bisect
import dataclasses
import fractions
import generator.engraving.fonts
import generator.engraving.types
import generator.model
ACCIDENTAL_GLYPHS = {fractions.Fraction(0): 'accidentalNatural', fractions.Fraction(1): 'accidentalSharp', fractions.Fraction(-1): 'accidentalFlat', fractions.Fraction(2): 'accidentalDoubleSharp', fractions.Fraction(-2): 'accidentalDoubleFlat', fractions.Fraction(3): 'accidentalTripleSharp', fractions.Fraction(-3): 'accidentalTripleFlat', fractions.Fraction(1, 2): 'accidentalQuarterToneSharpStein', fractions.Fraction(-1, 2): 'accidentalQuarterToneFlatStein', fractions.Fraction(3, 2): 'accidentalThreeQuarterTonesSharpStein', fractions.Fraction(-3, 2): 'accidentalThreeQuarterTonesFlatZimmermann'}
HEADS = {'x': 'X', 'diamond': 'Diamond', 'triangle': 'TriangleUp', 'inverted triangle': 'TriangleDown', 'circle-x': 'CircleX', 'cross': 'Plus', 'slash': 'Slash', 'normal': '', 'do': 'TriangleUp', 're': 'Moon', 'mi': 'Diamond', 'fa': 'TriangleRight', 'fa up': 'TriangleLeft', 'so': 'Round', 'la': 'Square', 'ti': 'TriangleRoundDown', 'rectangle': 'Square', 'square': 'Square', 'arrow down': 'LargeArrowDown', 'arrow up': 'LargeArrowUp'}
@dataclasses.dataclass(frozen=True)
class Event:
measure: dict
voice: str
lane: int
lanes: int
start: fractions.Fraction
end: fractions.Fraction
value: dict
class ScoreIndex:
def __init__(self, document: dict):
self.staff = document['staves'][0]
self.measures = []
self.events = []
self.events_by_onset = {}
self.attributes_by_onset = {}
self.harmonies_by_onset = {}
self.by_id = {}
self.tones = {}
self.boundaries = {fractions.Fraction()}
self.columns = {fractions.Fraction()}
self._states = {}
self._beam_groups = None
time = fractions.Fraction()
for measure in self.staff['measures']:
end = time + generator.model.fraction(measure['duration'])
self.measures.append((time, end, measure))
self.boundaries.add(end)
self.columns.update((time, end))
for lane, voice in enumerate(measure['voices']):
for note in voice['notes']:
onset = time + generator.model.fraction(note['onset'])
event = Event(measure, voice['id'], lane, len(measure['voices']), onset, onset + generator.model.fraction(note['duration']), note)
self.events.append(event)
self.by_id[note['id']] = event
self.columns.add(onset)
for tone in note['pitches']:
self.tones[tone['id']] = (event, tone)
for field, bucket in (('attributes', self.attributes_by_onset), ('harmonies', self.harmonies_by_onset)):
for index, item in enumerate(measure.get(field, [])):
onset = time + generator.model.fraction(item['onset'])
self.columns.add(onset)
bucket.setdefault(onset, []).append((measure, index, item))
time = end
self.end = time
self.scale_spans = self.staff.get('scale_spans', [])
self.scale_starts = [generator.model.fraction(span['start']) for span in self.scale_spans]
self.columns.update(self.scale_starts)
self.measure_starts = [item[0] for item in self.measures]
self.measure_starts_by_id = {measure['id']: start for start, _, measure in self.measures}
self.events.sort(key=lambda e: (e.start, not bool(e.value.get('grace')), e.lane))
self.event_starts = []
self.event_max_ends = []
for event in self.events:
self.events_by_onset.setdefault(event.start, []).append(event)
self.event_starts.append(event.start)
self.event_max_ends.append(max(event.end, self.event_max_ends[-1] if self.event_max_ends else event.end))
self.onsets = list(self.events_by_onset)
self.column_times = sorted(self.columns)
self.boundary_times = sorted(self.boundaries)
self.lyric_lanes = list(dict.fromkeys((e.voice, verse['number']) for e in self.events for verse in e.value.get('lyrics', [])))
self.accidentals = self.accidental_state()
def measure_at(self, time: fractions.Fraction, before: bool = False) -> tuple:
position = bisect.bisect_left(self.measure_starts, time) if before else bisect.bisect_right(self.measure_starts, time)
return self.measures[max(0, position - 1)]
def events_at(self, time: fractions.Fraction, include_sustained: bool = False) -> list[Event]:
attacks = self.events_by_onset.get(time, [])
if not include_sustained:
return attacks
sustained = []
position = bisect.bisect_left(self.event_starts, time) - 1
# Prefix maxima let system continuations stop searching once every earlier note has ended.
while position >= 0 and self.event_max_ends[position] > time:
event = self.events[position]
if event.end > time:
sustained.append(event)
position -= 1
return list(reversed(sustained)) + attacks
def state(self, time: fractions.Fraction, before: bool = False) -> dict:
if (time, before) in self._states:
return self._states[time, before]
start, _, measure = self.measure_at(time, before)
state = {key: measure[key] for key in ('key', 'meter', 'clef', 'transpose', 'staff_details')}
for change in measure.get('attributes', []):
onset = start + generator.model.fraction(change['onset'])
if onset < time or (onset == time and not before):
state.update({key: value for key, value in change.items() if key != 'onset'})
self._states[time, before] = state
return state
def pitch_position(self, tone: dict, time: fractions.Fraction) -> float:
state = self.state(time)
clef = state['clef']
pitch = tone.get('unpitched', tone)
sign = clef['sign']
reference = {'G': 4 * 7 + 4, 'F': 3 * 7 + 3, 'C': 4 * 7}.get(sign, 4 * 7 + 4)
clef_line = clef.get('line', 2) if sign in ('G', 'F', 'C') else 2
octave = 0 if 'unpitched' in tone else clef.get('octave_change', 0)
shift = sum(s['octaves'] for s in self.staff.get('octave_shifts', []) if generator.model.fraction(s['start']) <= time < generator.model.fraction(s['end'])) if 'unpitched' not in tone else 0
return pitch['octave'] * 7 + generator.model.LETTERS.index(pitch['letter']) + 7 * (shift - octave) - reference + 2 * (clef_line - 1)
def accidental_state(self) -> dict[str, bool]:
result = {}
for start, end, measure in self.measures:
memory = {}
key = None
onsets = self.onsets[bisect.bisect_left(self.onsets, start):bisect.bisect_left(self.onsets, end)]
for onset in onsets:
state = self.state(onset)
if key != state['key']:
key, memory = state['key'], {}
simultaneous = {}
for event in self.events_by_onset[onset]:
for tone in event.value['pitches']:
hidden_head = isinstance(tone.get('notehead'), dict) and tone['notehead'].get('name') == 'none'
if event.value.get('hidden') or tone.get('print', {}).get('print-object') == 'no' or hidden_head:
result[tone['id']] = False
continue
if 'unpitched' in tone:
result[tone['id']] = False
continue
identity = (tone['letter'], tone['octave'])
value = generator.model.alteration(tone['alter'])
signature = fractions.Fraction() if key.get('custom') else generator.model.alteration(key['alterations'][generator.model.LETTERS.index(tone['letter'])])
for step in key.get('steps', []):
if step['letter'] == tone['letter'] and step.get('octave', tone['octave']) == tone['octave']:
signature = generator.model.alteration(step['alter'])
result[tone['id']] = bool(tone.get('accidental_display')) or (not tone.get('tie_from') and memory.get(identity, signature) != value)
simultaneous.setdefault(identity, set()).add(value)
for identity, values in simultaneous.items():
memory[identity] = next(iter(values)) if len(values) == 1 else None
if any(len(values) > 1 for values in simultaneous.values()):
for event in self.events_by_onset[onset]:
for tone in event.value['pitches']:
if 'letter' in tone and len(simultaneous.get((tone['letter'], tone['octave']), ())) > 1:
result[tone['id']] = True
return result
def fragment_written(written: dict, original_start: fractions.Fraction, original_end: fractions.Fraction, start: fractions.Fraction, end: fractions.Fraction) -> dict:
return written if (original_start, original_end) == (start, end) else generator.model.written_for(end - start)
def flags(written: dict) -> int:
return max(0, generator.model.NOTE_TYPES.index(written['type']) - generator.model.NOTE_TYPES.index('quarter'))
def head_glyph(written: dict, head: dict | str | None) -> str:
head = {'name': head} if isinstance(head, str) else head or {'name': 'normal'}
if head.get('smufl'):
return head['smufl']
name = head['name']
if name == 'none':
return ''
kind = written['type']
suffix = 'DoubleWhole' if kind in ('maxima', 'long', 'breve') else 'Whole' if kind == 'whole' else 'Half' if kind == 'half' else 'Black'
if head.get('filled') in ('yes', 'no'):
suffix = 'Black' if head['filled'] == 'yes' else 'Half'
if name == 'slash':
return {'DoubleWhole': 'noteheadSlashWhiteDoubleWhole', 'Whole': 'noteheadSlashWhiteWhole', 'Half': 'noteheadSlashWhiteHalf', 'Black': 'noteheadSlashVerticalEnds'}[suffix]
if name in ('slashed', 'back slashed'):
return 'noteheadSlashed' + suffix + ('1' if name == 'slashed' else '2')
if name in ('circled', 'circle dot'):
return 'noteheadCircled' + suffix
if name not in HEADS:
raise generator.engraving.types.EngravingError(f'no SMuFL mapping for notehead {name!r}; specify its smufl glyph name')
shape = HEADS[name]
if shape in ('Moon', 'Round', 'Square', 'TriangleRight', 'TriangleLeft', 'TriangleRoundDown'):
suffix = 'Black' if suffix == 'Black' else 'White'
return 'notehead' + shape + suffix
def accidental(fonts: generator.engraving.fonts.Fonts, value: fractions.Fraction, display: dict, size: float, sources: tuple[str, ...]) -> list[generator.engraving.types.Primitive]:
glyph = display.get('smufl') or ACCIDENTAL_GLYPHS.get(value)
special = {'natural-sharp': ('accidentalNatural', 'accidentalSharp'), 'natural-flat': ('accidentalNatural', 'accidentalFlat')}
if display.get('name') in special and not display.get('smufl'):
items = []
x = 0
for name in special[display['name']]:
part = fonts.glyph(name, x, 0, size, 'accidental', sources)
items.append(part)
x = part.box.right + 2
elif glyph:
items = [fonts.glyph(glyph, 0, 0, size, 'accidental', sources)]
else:
sign = '+' if value > 0 else ''
items = [fonts.text(f'({sign}{value} st)', 0, 0, size * 1.15, 'accidental', sources)]
if display.get('parentheses') == 'yes' or display.get('bracket') == 'yes':
box = generator.engraving.types.bounds(items)
left, right = ('[', ']') if display.get('bracket') == 'yes' else ('(', ')')
items.extend((fonts.text(left, box.left - 7, size / 2, size * 1.8, 'accidental', sources), fonts.text(right, box.right + 2, size / 2, size * 1.8, 'accidental', sources)))
return items
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
import dataclasses
import fractions
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.types
import generator.model
def dots(fonts: generator.engraving.fonts.Fonts, count: int, x: float, y: float, size: float, sources: tuple[str, ...]) -> list[generator.engraving.types.Primitive]:
result = []
for i in range(count):
result.append(fonts.glyph('augmentationDot', x + i * size * 0.6, y, size, 'dot', sources))
return result
def rest_glyph(kind: str) -> str:
return {'maxima': 'restMaxima', 'long': 'restLonga', 'breve': 'restDoubleWhole', 'whole': 'restWhole', 'half': 'restHalf', 'quarter': 'restQuarter', 'eighth': 'rest8th'}.get(kind, 'rest' + kind)
def time_modification(written: dict, items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, sources: tuple[str, ...]) -> None:
ratio = generator.model.fraction(written.get('ratio', {'numerator': 1, 'denominator': 1}))
if ratio != 1 and items:
box = generator.engraving.types.bounds(items)
items.append(fonts.text(f'{ratio.denominator}:{ratio.numerator}', (box.left + box.right) / 2, box.top - 6, 13, 'time-modification', sources, True))
def draw_event(index: generator.engraving.notation.ScoreIndex, event: generator.engraving.notation.Event, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[list[generator.engraving.types.Primitive], list[generator.engraving.types.Anchor], list[generator.engraving.types.Fragment]]:
note = event.value
onset = max(start, event.start)
finish = min(end, event.end)
visible = not note.get('hidden') and note['kind'] != 'spacer'
references = (note['id'],)
size = style.space * (0.7 if note.get('cue') or note.get('grace') else 1)
items, anchors, fragments = [], [], []
state = index.state(onset)
lines = state['staff_details']['lines']
if not note['pitches']:
written = generator.engraving.notation.fragment_written(note['written'], event.start, event.end, onset, finish)
y = -style.space * (lines - 1) / 2
if position := note.get('rest_position'):
y = -index.pitch_position(position, onset) * style.space / 2
elif event.lanes > 1:
separation = 1 if index.staff.get('tablature') and event.lanes == 2 else 3
y += (-1 if event.lane % 2 == 0 else 1) * (event.lane // 2 + 1) * style.space * separation
kind = 'slash' if note.get('notehead') == 'slash' else 'rest'
fragment = generator.engraving.types.Fragment(note['id'], note['id'], onset, finish, written['type'], written.get('dots', 0), generator.model.fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), visible, kind, None)
fragments.append(fragment)
if visible:
glyph = generator.engraving.notation.head_glyph(written, 'slash') if kind == 'slash' else rest_glyph('whole' if note.get('measure_rest') and onset == event.start and finish == event.end else written['type'])
if written['type'] == 'whole' and not note.get('rest_position') and event.lanes == 1:
y -= style.space
items.append(fonts.glyph(glyph, 0, y, size, kind, references))
items.extend(dots(fonts, written.get('dots', 0), items[0].box.right + 4, y - size / 2, size, references))
if kind == 'slash' and written['type'] not in ('whole', 'breve', 'long', 'maxima'):
stem_x = items[0].box.right
stem_y = y - 3 * size
items.append(generator.engraving.types.line('stem', references, ((stem_x, y), (stem_x, stem_y))))
if generator.engraving.notation.flags(written):
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + 'Up'
items.append(fonts.glyph(flag, stem_x, stem_y, size, 'flag', references))
anchors.append(generator.engraving.types.Anchor(note['id'], note['id'], event.voice, onset, finish, 0, y, 0, y - size * 3, -1, 0, fragment))
if not note.get('tuplet_ids') or written != note['written']:
time_modification(written, items, fonts, references)
return items, anchors, fragments
groups = {}
for tone in note['pitches']:
tone_end = event.start + generator.model.fraction(tone['duration'])
if tone_end <= start and not note.get('grace'):
continue
written = generator.engraving.notation.fragment_written(tone['written'], event.start, tone_end, onset, min(end, tone_end))
stem = tone.get('stem', note.get('stem', 'auto'))
identity = (written['type'], written.get('dots', 0), str(written.get('ratio')), stem)
groups.setdefault(identity, []).append((tone, written, tone_end))
group_x = 0
for members in groups.values():
positioned = sorted(((index.pitch_position(tone, onset), tone, written, tone_end) for tone, written, tone_end in members), key=lambda item: item[0])
stem = members[0][0].get('stem', note.get('stem', 'auto'))
direction = -1 if stem in ('up', 'double') else 1 if stem == 'down' else (-1 if event.lane % 2 == 0 else 1) if event.lanes > 1 else (1 if sum(p[0] for p in positioned) / len(positioned) >= lines - 1 else -1)
if direction == 1:
positioned.reverse()
group_items, group_anchors = [], []
previous = None
displaced = False
accidental_boxes = []
for position, tone, written, tone_end in positioned:
sources = (note['id'], tone['id'])
tone_size = size * (0.7 if tone.get('cue') and not note.get('cue') else 1)
glyph = generator.engraving.notation.head_glyph(written, tone.get('notehead', note.get('notehead')))
displayed = visible and tone.get('print', {}).get('print-object') != 'no' and bool(glyph)
y = -position * style.space / 2
if previous is not None and abs(position - previous) < 2:
displaced = not displaced
else:
displaced = False
x = group_x + (tone_size * 1.2 * -direction if displaced else 0)
previous = position
fragment = generator.engraving.types.Fragment(tone['id'], note['id'], onset, min(end, tone_end), written['type'], written.get('dots', 0), generator.model.fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), displayed, 'tone', position)
fragments.append(fragment)
if not displayed:
continue
if written['type'] in ('long', 'maxima') and tone.get('notehead', {}).get('name', 'normal') == 'normal':
width = tone_size * (2.8 if written['type'] == 'maxima' else 1.4)
head = generator.engraving.types.line('head', sources, ((x, y - tone_size / 2), (x + width, y - tone_size / 2), (x + width, y + tone_size / 2), (x, y + tone_size / 2), (x, y - tone_size / 2)), 2)
else:
head = fonts.glyph(glyph, x, y, tone_size, 'head', sources)
group_items.append(head)
if tone.get('notehead', {}).get('parentheses') == 'yes':
group_items.extend((fonts.text('(', head.box.left - 6, y + 5, 18, 'head_parentheses', sources), fonts.text(')', head.box.right + 2, y + 5, 18, 'head_parentheses', sources)))
ledger = -2
while position <= ledger:
group_items.append(generator.engraving.types.line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
ledger -= 2
ledger = lines * 2
while position >= ledger:
group_items.append(generator.engraving.types.line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
ledger += 2
if 'alter' in tone and index.accidentals[tone['id']] and onset == event.start:
accidental_items = generator.engraving.notation.accidental(fonts, generator.model.alteration(tone['alter']), tone.get('accidental_display', {}), tone_size, sources)
box = generator.engraving.types.bounds(accidental_items)
dx = min(group_x, head.box.left) - 5 - box.right
candidate = box.moved(dx, y)
while any(candidate.overlaps(other, 2) for other in accidental_boxes):
dx -= box.right - box.left + 4
candidate = box.moved(dx, y)
accidental_boxes.append(candidate)
group_items.extend(p.moved(dx, y) for p in accidental_items)
if tone.get('print', {}).get('print-dot') != 'no':
dot_y = y - style.space / 2 if position % 2 == 0 else y
group_items.extend(dots(fonts, written.get('dots', 0), head.box.right + 5, dot_y, tone_size, sources))
if head.kind == 'glyph':
stem_x = x + fonts.anchor(glyph, 'stemUpSE' if direction == -1 else 'stemDownNW', tone_size)[0]
else:
stem_x = head.box.right - 0.6 if direction == -1 else head.box.left + 0.6
stem_y = y + direction * max(3.5 * size, (generator.engraving.notation.flags(written) + 1) * size * 0.75)
group_anchors.append(generator.engraving.types.Anchor(tone['id'], note['id'], event.voice, onset, min(end, tone_end), x, y, stem_x, stem_y, direction, generator.engraving.notation.flags(written), fragment))
if group_anchors:
stem_y = min(a.stem_y for a in group_anchors) if direction == -1 else max(a.stem_y for a in group_anchors)
anchor = min(group_anchors, key=lambda a: a.y) if direction == 1 else max(group_anchors, key=lambda a: a.y)
written = members[0][1]
if stem != 'none' and written['type'] not in ('whole', 'breve'):
group_items.append(generator.engraving.types.line('stem', references, ((anchor.stem_x, anchor.y), (anchor.stem_x, stem_y)), size * 0.12))
if stem == 'double':
other_x = anchor.x if direction == -1 else anchor.x + size * 1.3
other_y = anchor.y - direction * size * 3.5
group_items.append(generator.engraving.types.line('stem', references, ((other_x, anchor.y), (other_x, other_y)), size * 0.12))
if generator.engraving.notation.flags(written):
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + ('Down' if direction == -1 else 'Up')
group_items.append(fonts.glyph(flag, other_x, other_y, size, 'flag', references))
if generator.engraving.notation.flags(written):
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + ('Up' if direction == -1 else 'Down')
group_items.append(fonts.glyph(flag, anchor.stem_x, stem_y, size, 'flag', references))
if note.get('grace', {}).get('slash'):
group_items.append(generator.engraving.types.line('grace_slash', references, ((anchor.stem_x - 5, stem_y + 14), (anchor.stem_x + 9, stem_y + 4)), 1.5))
if note.get('tremolo', {}).get('type') == 'single':
for mark in range(note['tremolo']['marks']):
ty = stem_y - direction * (10 + mark * 5)
group_items.append(generator.engraving.types.line('tremolo', references, ((anchor.stem_x - 6, ty + 2), (anchor.stem_x + 6, ty - 2)), 3))
anchors.extend(dataclasses.replace(a, stem_x=anchor.stem_x, stem_y=stem_y) for a in group_anchors)
if not note.get('tuplet_ids') or members[0][1] != members[0][0]['written']:
time_modification(members[0][1], group_items, fonts, references)
items.extend(group_items)
group_x = max((p.box.right for p in items), default=group_x) + size
return items, anchors, fragments
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
import concurrent.futures
import json
import multiprocessing
import pathlib
import time
import generator.engraving.cache
import generator.engraving.fonts
import generator.engraving.types
import generator.engraving.validation
import generator.engraving.worker
import generator.validation
def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.Path | None = None, jobs: int = 1, progress: bool = False) -> set[str]:
if type(jobs) is not int or not 1 <= jobs <= 16:
raise ValueError('engraving jobs must be between 1 and 16')
if cache_dir and (cache_dir.resolve().is_relative_to(root.resolve()) or root.resolve().is_relative_to(cache_dir.resolve())):
raise ValueError('engraving cache and generated assets must not overlap')
started = time.monotonic()
paths = sorted(root.rglob('*.json'))
validated_pages = set()
if not paths:
return validated_pages
family, style = generator.engraving.fonts.Fonts(fonts), generator.engraving.types.Style()
fingerprint = family.fingerprint(style)
renderer = generator.engraving.cache.renderer_fingerprint(family, style)
if cache_dir:
cache_dir = generator.engraving.cache.renderer_namespace(cache_dir, renderer)
if progress:
state = 'warm namespace' if cache_dir.is_dir() else 'cold namespace'
print(f'Engraving cache enabled: {cache_dir} ({state}; renderer {renderer})', flush=True)
elif progress:
print('Engraving cache disabled', flush=True)
totals = generator.engraving.worker.GroupResult()
missing, rejected, completed = 0, 0, 0
groups = {}
for path in paths:
try:
document = json.loads(path.read_text(encoding='utf-8'))
generator.validation.validate_document(document)
score_key = generator.engraving.cache.score_fingerprint(document, renderer)
destination = path.with_suffix('.pages')
generator.engraving.worker.clear_pages(destination)
entry = cache_dir / 'fullscore' / score_key if cache_dir else None
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
if pages is not None:
try:
generator.engraving.worker.validate_cached_views(document, pages)
except (ValueError, TypeError, KeyError):
pages = None
if pages is None:
rejected += int(present)
missing += int(entry is not None and not present)
notation = generator.engraving.cache.notation_fingerprint(document, renderer)
groups.setdefault(notation, []).append((path, score_key))
else:
generator.engraving.worker.write_bundle(path, document, pages, fingerprint)
totals.fullscore += 1
completed += 1
except ValueError as error:
raise generator.engraving.types.EngravingError(f'{path.relative_to(root)}: {error}') from error
prepare_time = time.monotonic() - started
if progress:
print(f'Engraving grouped: {len(paths)} scores, {len(groups)} notation bodies, {totals.fullscore} fullscore hits', flush=True)
validation_time = 0.0
def finish(result):
nonlocal completed, validation_time
started = time.monotonic()
for path, score_key, pages in result.scores:
destination = path.with_suffix('.pages')
for page in pages:
generator.engraving.validation.check_page(destination / page['path'], validated_pages, expected_sha256=page['sha256'])
validation_time += time.monotonic() - started
if cache_dir:
started = time.monotonic()
stored = set()
for path, score_key, pages in result.scores:
if score_key not in stored:
generator.engraving.cache.store_pages(cache_dir / 'fullscore' / score_key, path.with_suffix('.pages'), pages)
stored.add(score_key)
result.timings['cache'] += time.monotonic() - started
for field in ('fullscore', 'bodyhits', 'bodies', 'renderedpages', 'rejected'):
setattr(totals, field, getattr(totals, field) + getattr(result, field))
for phase, elapsed in result.timings.items():
totals.timings[phase] += elapsed
completed += len(result.scores)
if progress:
print(f'Engraving: {completed}/{len(paths)} scores, fullscore {totals.fullscore}, bodyhits {totals.bodyhits}, bodies {totals.bodies}, renderedpages {totals.renderedpages}', flush=True)
jobs = min(jobs, len(groups))
if jobs <= 1:
for notation, group in groups.items():
finish(generator.engraving.worker.engrave_group(notation, group, root, fonts, cache_dir, fingerprint, progress))
else:
with concurrent.futures.ProcessPoolExecutor(max_workers=jobs, mp_context=multiprocessing.get_context('spawn')) as pool:
futures = [pool.submit(generator.engraving.worker.engrave_group, notation, group, root, fonts, cache_dir, fingerprint, progress) for notation, group in groups.items()]
for future in concurrent.futures.as_completed(futures):
finish(future.result())
if progress:
print(f'Engraving complete: {len(paths)} scores, {totals.fullscore} reused, {missing} missing, {rejected} rejected; fullscore {totals.fullscore}, bodyhits {totals.bodyhits}, bodies {totals.bodies}, renderedpages {totals.renderedpages}, rejected bodies {totals.rejected}', flush=True)
phases = {'prepare': prepare_time, **totals.timings, 'validation': validation_time}
print('Engraving timings: ' + ', '.join(f'{phase} {elapsed:.3f}s' for phase, elapsed in phases.items()) + f'; wall {time.monotonic() - started:.3f}s (worker phases summed)', flush=True)
return validated_pages
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
import dataclasses
import math
import PIL.Image
import PIL.ImageDraw
import aggdraw
import fontTools.pens.basePen
import generator.engraving.fonts
import generator.engraving.types
import generator.theme
class OutlinePen(fontTools.pens.basePen.BasePen):
def __init__(self, glyphs, scale: float, x: float, y: float):
super().__init__(glyphs)
self.path = aggdraw.Path()
self.scale, self.x, self.y = scale, x, y
def point(self, point):
return self.x + point[0] * self.scale, self.y - point[1] * self.scale
def _moveTo(self, point):
self.path.moveto(*self.point(point))
def _lineTo(self, point):
self.path.lineto(*self.point(point))
def _curveToOne(self, first, second, last):
self.path.curveto(*self.point(first), *self.point(second), *self.point(last))
def _closePath(self):
self.path.close()
def _endPath(self):
pass
def rasterize_page(page: generator.engraving.types.Page, fonts: generator.engraving.fonts.Fonts, *, ink=None, background=None, output_scale=1) -> PIL.Image.Image:
return rasterize_band(page, page.primitives, 0, page.height, fonts, ink=ink, background=background, output_scale=output_scale)
def rasterize_band(page: generator.engraving.types.Page, primitives: tuple[generator.engraving.types.Primitive, ...], top: int, bottom: int, fonts: generator.engraving.fonts.Fonts, *, ink=None, background=None, output_scale=1) -> PIL.Image.Image:
ink = generator.theme.rgb('ink') if ink is None else ink
background = generator.theme.rgb('background') if background is None else background
scale = page.supersampling * output_scale
image = PIL.Image.new('RGB', (round(page.width * scale), round((bottom - top) * scale)), background)
context = aggdraw.Draw(image)
for item in primitives:
points = [(x * scale, y * scale - top * scale) for x, y in item.points]
if item.kind == 'text':
continue
if item.kind == 'glyph':
pen = OutlinePen(fonts.glyphs, item.size * scale / fonts.units, *points[0])
fonts.outline(item.value).replay(pen)
path = pen.path
else:
path = aggdraw.Path()
path.moveto(*points[0])
if item.kind == 'curve':
path.curveto(*points[1], *points[2], *points[3])
else:
for point in points[1:]:
path.lineto(*point)
if item.kind == 'polygon':
path.close()
if item.kind in ('glyph', 'polygon'):
context.path(path, None, aggdraw.Brush(ink))
else:
context.path(path, aggdraw.Pen(ink, item.thickness * scale))
context.flush()
drawing = PIL.ImageDraw.Draw(image)
for item in primitives:
if item.kind == 'text':
x, y = item.points[0]
drawing.text((x * scale, y * scale - top * scale), item.value, font=fonts.text_font(item.size * scale), anchor='ls', fill=ink)
result = image.resize((round(page.width * output_scale), round((bottom - top) * output_scale)), PIL.Image.Resampling.LANCZOS)
image.close()
return result
@dataclasses.dataclass(frozen=True)
class Band:
top: int
bottom: int
primitives: tuple[generator.engraving.types.Primitive, ...]
def page_bands(page: generator.engraving.types.Page) -> tuple[Band, Band, Band] | None:
body = tuple(p for system in page.systems for p in system.primitives)
header = tuple(p for p in page.primitives if p.role in ('title', 'subtitle'))
footer = tuple(p for p in page.primitives if p.role == 'page_number')
if not body or not header or not footer or page.primitives != header + body + footer:
return None
bands = []
for primitives in (header, body, footer):
box = generator.engraving.types.bounds(primitives)
if not box.finite() or box.left < 4 or box.right > page.width - 4 or box.top < 4 or box.bottom > page.height - 4:
return None
# Six output pixels cover raster antialiasing and the three-pixel Lanczos
# support. Integer crop origins preserve the full-page sampling phase.
bands.append(Band(max(0, math.floor(box.top) - 6), min(page.height, math.ceil(box.bottom) + 6), primitives))
if bands[0].bottom > bands[1].top or bands[1].bottom > bands[2].top:
return None
return tuple(bands)
def compose_page(page: generator.engraving.types.Page, fonts: generator.engraving.fonts.Fonts, bands: tuple[Band, Band, Band], body: PIL.Image.Image) -> PIL.Image.Image:
image = PIL.Image.new('RGB', (page.width, page.height), generator.theme.rgb('background'))
if body.mode != 'RGB' or body.size != (page.width, bands[1].bottom - bands[1].top):
raise generator.engraving.types.EngravingError('notation band has incompatible geometry')
image.paste(body, (0, bands[1].top))
for band in (bands[0], bands[2]):
with rasterize_band(page, band.primitives, band.top, band.bottom, fonts) as decoration:
image.paste(decoration, (0, band.top))
return image
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
import bisect
import generator.model
def column(index, time, system_start, fonts):
position = bisect.bisect_right(index.scale_starts, time) - 1
if position < 0:
return []
span = index.scale_spans[position]
if time >= generator.model.fraction(span['end']) or time not in (system_start, index.scale_starts[position]):
return []
return [fonts.text(span['label'], 0, 0, 16, 'scale', (span['id'],))]
+270
View File
@@ -0,0 +1,270 @@
from __future__ import annotations
import fractions
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.types
import generator.model
def beam_groups(index: generator.engraving.notation.ScoreIndex) -> tuple[tuple[str, ...], ...]:
if index._beam_groups is not None:
return index._beam_groups
groups = []
for voice in dict.fromkeys(e.voice for e in index.events):
current = []
previous_key = None
for event in (e for e in index.events if e.voice == voice):
note = event.value
primary = next((b['type'] for b in note.get('beams', []) if b['level'] == 1), None)
measure_start = index.measure_starts_by_id[event.measure['id']]
beats = generator.model.beat_lengths(index.state(event.start)['meter'])
offset = event.start - measure_start
beat = 0
edge = fractions.Fraction()
for duration in beats:
edge += duration
if offset < edge:
break
beat += 1
key = (event.measure['id'], beat, tuple(note.get('tuplet_ids', [])), bool(note.get('grace')))
eligible = note['kind'] == 'note' and generator.engraving.notation.flags(note['written']) and note.get('stem') != 'none' and not note.get('hidden')
if not eligible or primary == 'begin' or (primary is None and key != previous_key):
if current:
groups.append(current)
current = []
if eligible:
current.append(note['id'])
if primary == 'end':
groups.append(current)
current = []
previous_key = key
if current:
groups.append(current)
index._beam_groups = tuple(tuple(g) for g in groups if len(g) > 1 or index.by_id[g[0]].value.get('beams'))
return index._beam_groups
def draw_beams(index: generator.engraving.notation.ScoreIndex, anchors: list[generator.engraving.types.Anchor], items: list[generator.engraving.types.Primitive], start: fractions.Fraction, end: fractions.Fraction, left: float, right: float, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
additions = []
removed = set()
for group in beam_groups(index):
selected = []
for identifier in group:
candidates = [a for a in anchors if a.event == identifier and a.flags]
if candidates:
selected.append(candidates[0])
if not selected:
continue
direction = selected[0].direction
if any(a.direction != direction for a in selected):
continue
sources = tuple(a.event for a in selected)
removed.update(sources)
beam_y = min(a.stem_y for a in selected) if direction == -1 else max(a.stem_y for a in selected)
first, last = selected[0], selected[-1]
x1 = left if index.by_id[group[0]].start < start else first.stem_x
x2 = right if index.by_id[group[-1]].start >= end else last.stem_x
for anchor in selected:
additions.append(generator.engraving.types.line('beam_stem', (anchor.event,), ((anchor.stem_x, anchor.stem_y), (anchor.stem_x, beam_y)), 1.4))
for level in range(1, max(a.flags for a in selected) + 1):
y = beam_y - direction * (level - 1) * style.space * 0.75
for i, anchor in enumerate(selected):
if anchor.flags < level:
continue
event = index.by_id[anchor.event].value
explicit = next((b['type'] for b in event.get('beams', []) if b['level'] == level), None)
next_anchor = selected[i + 1] if i + 1 < len(selected) else None
previous = selected[i - 1] if i else None
a = anchor.stem_x
if explicit in ('forward hook', 'backward hook'):
b = a + (8 if explicit == 'forward hook' else -8)
elif next_anchor and next_anchor.flags >= level and explicit != 'end':
b = next_anchor.stem_x
elif i == len(selected) - 1 and x2 > a and explicit != 'end':
b = x2
elif i == 0 and x1 < a and explicit != 'begin':
b = x1
elif not previous or previous.flags < level or explicit == 'begin':
b = a + (8 if next_anchor else -8)
else:
continue
additions.append(generator.engraving.types.polygon('beam', sources, ((a, y), (b, y), (b, y - direction * style.space * 0.5), (a, y - direction * style.space * 0.5))))
if selected and x1 < selected[0].stem_x and selected[0].flags >= level:
additions.append(generator.engraving.types.line('beam', sources, ((x1, y), (selected[0].stem_x, y)), style.space * 0.5))
return [p for p in items if not (p.role == 'flag' and any(s in removed for s in p.sources))] + additions
def draw_spans(index: generator.engraving.notation.ScoreIndex, anchors: list[generator.engraving.types.Anchor], items: list[generator.engraving.types.Primitive], start: fractions.Fraction, end: fractions.Fraction, left: float, right: float, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
result = []
by_tone = {a.source: a for a in anchors}
by_event = {a.event: a for a in anchors}
for identifier, (event, tone) in index.tones.items():
target = tone.get('tie_to')
tone_end = event.start + generator.model.fraction(tone['duration'])
anchor = by_tone.get(identifier)
if anchor and anchor.fragment.end < tone_end:
target = identifier
if target:
target_event = index.tones[target][0]
target_time = tone_end if target == identifier else target_event.start
if event.start >= end or target_time < start:
continue
destination = by_tone.get(target) if target != identifier else None
if anchor is None and destination is None:
continue
reference = anchor or destination
sign = -reference.direction
x1 = anchor.x + style.space * 1.4 if anchor else left
x2 = destination.x - 2 if destination else right
y1 = anchor.y + sign * 8 if anchor else reference.y + sign * 8
y2 = destination.y + sign * 8 if destination else y1
if x2 > x1:
result.append(generator.engraving.types.curve('tie', (identifier, target), ((x1, y1), (x1 + (x2 - x1) / 3, y1 + sign * 9), (x2 - (x2 - x1) / 3, y2 + sign * 9), (x2, y2)), 1.6))
if anchor and anchor.fragment.start > event.start:
y = anchor.y - anchor.direction * 8
if anchor.x - 2 > left:
result.append(generator.engraving.types.curve('tie', (identifier,), ((left, y), (left + 5, y - anchor.direction * 9), (anchor.x - 7, y - anchor.direction * 9), (anchor.x - 2, y)), 1.6))
top = min(-style.space * 5, min((p.box.top for p in items if p.role not in ('harmony', 'lyric', 'section', 'scale', 'tempo', 'measure_number')), default=0))
tuplet_lanes = []
for span in index.staff.get('tuplets', []):
a, b = index.by_id[span['start']], index.by_id[span['end']]
if b.end <= start or a.start >= end:
continue
members = [anchor for anchor in anchors if span['id'] in index.by_id[anchor.event].value.get('tuplet_ids', [])]
if not members:
continue
x1 = left if a.start < start else min(m.stem_x for m in members) - 3
x2 = right if b.end > end else max(m.stem_x for m in members) + style.space
y = top - 16 - span.get('depth', 0) * 23
while any(x1 < b and a < x2 and abs(y - lane) < 22 for a, b, lane in tuplet_lanes):
y -= 23
tuplet_lanes.append((x1, x2, y))
number = span.get('show_number', 'actual')
label = '' if number == 'none' else str(span['note_count'])
if number == 'both':
label += ':' + str(span['space_count'])
if span.get('show_type', 'none') != 'none':
actual = span.get('tuplet_actual', {}).get('type', a.value['written']['type'])
normal = span.get('tuplet_normal', {}).get('type', actual)
label += ' ' + actual + '.' * span.get('tuplet_actual', {}).get('dots', 0)
if span['show_type'] == 'both':
label += ' : ' + normal + '.' * span.get('tuplet_normal', {}).get('dots', 0)
references = (span['id'],)
text = fonts.text(label, (x1 + x2) / 2, y + 4, 15, 'tuplet-number', references, True) if label else None
if text:
result.append(text)
if span.get('bracket', 'auto') != 'no':
intervals = [(x1, text.box.left - 4), (text.box.right + 4, x2)] if text else [(x1, x2)]
for p, q in intervals:
if q <= p:
continue
if span.get('line_shape') == 'curved':
result.append(generator.engraving.types.curve('tuplet-bracket', references, ((p, y + 3), (p + (q - p) / 3, y - 4), (q - (q - p) / 3, y - 4), (q, y + 3)), 1.3))
else:
result.append(generator.engraving.types.line('tuplet-bracket', references, ((p, y), (q, y)), 1.3))
if a.start >= start:
result.append(generator.engraving.types.line('tuplet-bracket', references, ((x1, y), (x1, y + 5)), 1.3))
if b.end <= end:
result.append(generator.engraving.types.line('tuplet-bracket', references, ((x2, y), (x2, y + 5)), 1.3))
outer_top = min(top, generator.engraving.types.bounds(result).top if result else top)
for span in index.staff.get('octave_shifts', []):
a, b = generator.model.fraction(span['start']), generator.model.fraction(span['end'])
if b <= start or a >= end:
continue
members = [n for n in anchors if a <= n.time < b]
x1 = min((n.x for n in members), default=left) if a >= start else left
x2 = max((n.x + style.space * 2 for n in members), default=right) if b <= end else right
above = span['octaves'] < 0
y = outer_top - 18 if above else max(generator.engraving.types.bounds(items + result).bottom, 0) + 24
label = str(abs(span['octaves']) * 7 + 1) + ('va' if above else 'vb')
text = fonts.text(label, x1, y, 16, 'octave-shift', (span['id'],))
result.append(text)
outer_top = min(outer_top, text.box.top - 4)
cursor = text.box.right + 4
while cursor < x2:
result.append(generator.engraving.types.line('octave-shift', (span['id'],), ((cursor, y - 5), (min(cursor + 6, x2), y - 5)), 1))
cursor += 10
if b <= end:
result.append(generator.engraving.types.line('octave-shift', (span['id'],), ((x2, y - 5), (x2, y + (2 if above else -12))), 1))
for event in index.events:
tremolo = event.value.get('tremolo', {})
if tremolo.get('type') != 'start':
continue
partner = index.by_id[tremolo['partner']]
if event.start >= end or partner.end <= start:
continue
a, b = by_event.get(event.value['id']), by_event.get(partner.value['id'])
if not a and not b:
continue
anchor = a or b
x1, x2 = a.stem_x + 4 if a else left, b.stem_x - 4 if b else right
y = anchor.stem_y - anchor.direction * 10
for mark in range(tremolo['marks']):
result.append(generator.engraving.types.line('tremolo', (event.value['id'], partner.value['id']), ((x1, y + mark * 6), (x2, y + mark * 6)), 3.5))
return items + result
def place_text_lanes(items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'scale', 'measure_number')]
box = generator.engraving.types.bounds(music)
result = list(music)
top = box.top - 12
for role in ('measure_number', 'harmony', 'scale', 'section'):
lane = [p for p in items if p.role == role]
if lane:
extent = generator.engraving.types.bounds(lane)
result.extend(p.moved(0, top - extent.bottom) for p in lane)
top -= extent.bottom - extent.top + 10
lyrics = [p for p in items if p.role == 'lyric']
if lyrics:
result.extend(p.moved(0, max(box.bottom, 0) + 20) for p in lyrics)
return result
def draw_lyric_spans(index: generator.engraving.notation.ScoreIndex, items: list[generator.engraving.types.Primitive], anchors: list[generator.engraving.types.Anchor], start: fractions.Fraction, end: fractions.Fraction, left: float, right: float, fonts: generator.engraving.fonts.Fonts) -> list[generator.engraving.types.Primitive]:
result = list(items)
labels = {p.sources[0]: p for p in items if p.role == 'lyric'}
positions = {a.event: a for a in anchors}
lyric_baseline = None
for event in index.events:
for verse in event.value.get('lyrics', []):
label = labels.get(event.value['id'] + ':lyric:' + verse['number'])
if label:
lyric_baseline = label.points[0][1] - index.lyric_lanes.index((event.voice, verse['number'])) * 23
break
if lyric_baseline is not None:
break
if lyric_baseline is None:
lyric_baseline = max(0, generator.engraving.types.bounds(items).bottom) + 20
for voice, number in index.lyric_lanes:
verses = [(e, v) for e in index.events if e.voice == voice and not e.value.get('hidden') for v in e.value.get('lyrics', []) if v['number'] == number]
for i, (event, verse) in enumerate(verses):
following = verses[i + 1] if i + 1 < len(verses) else None
syllabic = next((s['text'] for s in verse['syllables'] if s['kind'] == 'syllabic'), 'single')
extend = next((s['attributes'].get('type', 'start') for s in verse['syllables'] if s['kind'] == 'extend'), None)
if syllabic not in ('begin', 'middle') and extend not in ('start', 'continue'):
continue
if 'extend_to' in verse and extend != 'start':
continue
target = following[0].start if following else max(e.end for e in index.events if e.voice == voice)
if 'extend_to' in verse:
target = generator.model.fraction(verse['extend_to'])
if target <= start or event.start >= end:
continue
identifier = event.value['id'] + ':lyric:' + number
next_id = following[0].value['id'] + ':lyric:' + number if following else ''
a, b = labels.get(identifier), labels.get(next_id)
x1 = a.box.right + 5 if a else left
x2 = b.box.left - 5 if b else right
if b is None and following and following[0].value['id'] in positions:
x2 = positions[following[0].value['id']].x
if 'extend_to' in verse:
endpoints = [a.x for a in anchors if a.time >= target]
x2 = min(endpoints) - 5 if endpoints else right
y = lyric_baseline + index.lyric_lanes.index((voice, number)) * 23
if x2 <= x1:
continue
if extend in ('start', 'continue'):
result.append(generator.engraving.types.line('lyric_extension', (identifier,), ((x1, y + 3), (x2, y + 3)), 1))
elif following:
count = max(1, int((x2 - x1) / 90))
for dash in range(count):
result.append(fonts.text('-', x1 + (x2 - x1) * (dash + 1) / (count + 1), y, 18, 'lyric_hyphen', (identifier,), True))
return result
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
import generator.engraving.types
import generator.model
def column(index, event, anchors, fonts, style):
if not index.staff.get('tablature') or not anchors:
return []
x = min(a.x for a in anchors)
sources = (event.value['id'],)
if not event.value['pitches']:
anchor = anchors[0]
return [fonts.glyph('noteheadSlashHorizontalEnds', x, -2 * style.tab_space, style.space, 'tab_slash', sources)] if anchor.fragment.kind == 'slash' else []
result = []
for anchor in anchors:
tone = index.tones[anchor.source][1]
tab = tone['tablature']
y = -(6 - tab['string']) * style.tab_space
result.append(fonts.text(str(tab['fret']), x, y + 4, style.tab_font_size, 'tab', (event.value['id'], tone['id'])))
return result
def staff(index, items, start, end, left, right, fonts, style):
notation = [p for p in items if not p.role.startswith('tab')]
numbers = [p for p in items if p.role.startswith('tab')]
if not index.staff.get('tablature'):
return items
offset = generator.engraving.types.bounds(notation).bottom + style.tab_gap + 5 * style.tab_space
numbers = [p.moved(0, offset) for p in numbers]
drawing = list(numbers)
drawing.append(fonts.text('TAB', 0, offset - 2 * style.tab_space, style.tab_font_size, 'tab_label', ()))
for string in range(1, 7):
y = offset - (6 - string) * style.tab_space
cuts = sorted((max(0, p.box.left - 2), min(right, p.box.right + 2)) for p in numbers if p.box.top <= y <= p.box.bottom)
cursor = 0
for a, b in cuts + [(right, right)]:
if a > cursor:
drawing.append(generator.engraving.types.line('tab_staff', (), ((cursor, y), (a, y)), style.space * 0.13))
cursor = max(cursor, b)
for bar in (p for p in notation if p.role == 'barline'):
drawing.append(generator.engraving.types.line('tab_barline', bar.sources, tuple((x, offset + y * 5 * style.tab_space / (4 * style.space)) for x, y in bar.points), bar.thickness))
by_source = {source: p for p in numbers if p.role == 'tab' for source in p.sources[1:]}
def tie(identifier, tone, x1, x2):
y = offset - (6 - tone['tablature']['string']) * style.tab_space + 6
if x2 > x1:
drawing.append(generator.engraving.types.curve('tab_tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y + 5), (x2 - (x2 - x1) / 3, y + 5), (x2, y))))
for identifier, (event, tone) in index.tones.items():
first = by_source.get(identifier)
finish = event.start + generator.model.fraction(tone['duration'])
if first and event.start < start < finish:
tie(identifier, tone, left, first.box.left - 2)
if first and finish > end:
tie(identifier, tone, first.box.right + 2, right)
elif target := tone.get('tie_to'):
destination = index.tones[target][0].start
if event.start >= end or destination < start:
continue
last = by_source.get(target)
if first or last:
tie(identifier, tone, first.box.right + 2 if first else left, last.box.left - 2 if last else right)
return notation + drawing
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
import dataclasses
import fractions
import math
class EngravingError(ValueError):
pass
@dataclasses.dataclass(frozen=True)
class Style:
width: int = 976
height: int = 484
space: float = 12
margin_x: float = 28
margin_y: float = 18
page_clearance: float = 14
supersampling: int = 4
column_gap: float = 10
system_gap: float = 26
guitar_space: float = 10
tab_space: float = 10.5
tab_gap: float = 12
tab_font_size: float = 12
def score_style(document: dict, style: Style) -> Style:
return dataclasses.replace(style, space=style.guitar_space, system_gap=14) if document['staves'][0].get('tablature') else style
@dataclasses.dataclass(frozen=True)
class Box:
left: float
top: float
right: float
bottom: float
def moved(self, x: float, y: float) -> Box:
return Box(self.left + x, self.top + y, self.right + x, self.bottom + y)
def scaled(self, factor: float) -> Box:
return Box(self.left * factor, self.top * factor, self.right * factor, self.bottom * factor)
def overlaps(self, other: Box, clearance: float = 0) -> bool:
return self.left < other.right + clearance and other.left < self.right + clearance and self.top < other.bottom + clearance and other.top < self.bottom + clearance
def finite(self) -> bool:
return all(math.isfinite(v) for v in (self.left, self.top, self.right, self.bottom)) and self.left <= self.right and self.top <= self.bottom
@dataclasses.dataclass(frozen=True)
class Primitive:
kind: str
role: str
sources: tuple[str, ...]
box: Box
points: tuple[tuple[float, float], ...]
value: str = ''
size: float = 0
thickness: float = 0
def moved(self, x: float, y: float) -> Primitive:
return dataclasses.replace(self, box=self.box.moved(x, y), points=tuple((a + x, b + y) for a, b in self.points))
def scaled(self, factor: float) -> Primitive:
return dataclasses.replace(self, box=self.box.scaled(factor), points=tuple((x * factor, y * factor) for x, y in self.points), size=self.size * factor, thickness=self.thickness * factor)
@dataclasses.dataclass(frozen=True)
class Fragment:
source: str
event: str
start: fractions.Fraction
end: fractions.Fraction
written_type: str
dots: int
ratio: fractions.Fraction
visible: bool
kind: str
position: float | None
def length(self) -> fractions.Fraction:
return self.end - self.start
@dataclasses.dataclass(frozen=True)
class Anchor:
source: str
event: str
voice: str
time: fractions.Fraction
end: fractions.Fraction
x: float
y: float
stem_x: float
stem_y: float
direction: int
flags: int
fragment: Fragment
def moved(self, x: float, y: float) -> Anchor:
return dataclasses.replace(self, x=self.x + x, y=self.y + y, stem_x=self.stem_x + x, stem_y=self.stem_y + y)
def scaled(self, factor: float) -> Anchor:
return dataclasses.replace(self, x=self.x * factor, y=self.y * factor, stem_x=self.stem_x * factor, stem_y=self.stem_y * factor)
@dataclasses.dataclass(frozen=True)
class System:
start: fractions.Fraction
end: fractions.Fraction
primitives: tuple[Primitive, ...]
anchors: tuple[Anchor, ...]
fragments: tuple[Fragment, ...]
box: Box
scale: float = 1
def scaled(self, factor: float) -> System:
return dataclasses.replace(self, primitives=tuple(p.scaled(factor) for p in self.primitives), anchors=tuple(a.scaled(factor) for a in self.anchors), box=self.box.scaled(factor), scale=self.scale * factor)
@dataclasses.dataclass(frozen=True)
class Page:
number: int
width: int
height: int
supersampling: int
systems: tuple[System, ...]
primitives: tuple[Primitive, ...]
@dataclasses.dataclass(frozen=True)
class LayoutDocument:
pages: tuple[Page, ...]
style: Style
fingerprint: str
def bounds(items: list[Primitive] | tuple[Primitive, ...]) -> Box:
if not items:
return Box(0, 0, 0, 0)
return Box(min(p.box.left for p in items), min(p.box.top for p in items), max(p.box.right for p in items), max(p.box.bottom for p in items))
def line(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...], thickness: float = 1.4) -> Primitive:
pad = thickness / 2
box = Box(min(x for x, _ in points) - pad, min(y for _, y in points) - pad, max(x for x, _ in points) + pad, max(y for _, y in points) + pad)
return Primitive('line', role, sources, box, points, thickness=thickness)
def curve(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...], thickness: float = 1.6) -> Primitive:
return dataclasses.replace(line(role, sources, points, thickness), kind='curve')
def polygon(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...]) -> Primitive:
return dataclasses.replace(line(role, sources, points, 0), kind='polygon')
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import hashlib
import io
import pathlib
import re
import PIL.Image
import PIL.ImageChops
import generator.engraving.types
import generator.theme
ENGINE_NAME = 'Vamp Python'
PAGE_WIDTH = 976
PAGE_HEIGHT = 484
def check_content(data: bytes, name: str) -> None:
with PIL.Image.open(io.BytesIO(data)) as image:
if image.format != 'PNG' or image.size != (PAGE_WIDTH, PAGE_HEIGHT):
raise generator.engraving.types.EngravingError(f'{name}: expected a {PAGE_WIDTH}x{PAGE_HEIGHT} PNG page')
image.load()
background = PIL.Image.new('RGB', image.size, generator.theme.rgb('background'))
composited = background.convert('RGBA')
composited.alpha_composite(image.convert('RGBA'))
ink = PIL.ImageChops.difference(composited.convert('RGB'), background).convert('L').point(lambda x: 255 if x > 32 else 0)
bounds = ink.getbbox()
if not bounds:
raise generator.engraving.types.EngravingError(f'{name}: engraver produced a blank page')
if bounds[0] < 4 or bounds[1] < 4 or bounds[2] > PAGE_WIDTH - 4 or bounds[3] > PAGE_HEIGHT - 4:
raise generator.engraving.types.EngravingError(f'{name}: notation reaches the page edge; refusing potentially clipped output')
def check_page(path: pathlib.Path, validated_pages: set[str] | None = None, *, expected_sha256: str | None = None) -> str:
# Trust content, never a filename or a caller-supplied checksum alone.
if path.is_symlink() or not path.is_file():
raise generator.engraving.types.EngravingError(f'{path.name}: expected a regular PNG file')
data = path.read_bytes()
digest = hashlib.sha256(data).hexdigest()
if expected_sha256 is not None and (not isinstance(expected_sha256, str) or not re.fullmatch('[0-9a-f]{64}', expected_sha256) or digest != expected_sha256):
raise generator.engraving.types.EngravingError(f'{path.name}: score page checksum mismatch')
if validated_pages is None or digest not in validated_pages:
check_content(data, path.name)
if validated_pages is not None:
validated_pages.add(digest)
return digest
+190
View File
@@ -0,0 +1,190 @@
from __future__ import annotations
import collections
import dataclasses
import hashlib
import json
import pathlib
import shutil
import tempfile
import time
import PIL.Image
import generator.engraving.audit
import generator.engraving.cache
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.notation
import generator.engraving.raster
import generator.engraving.types
import generator.engraving.validation
import generator.performance
import generator.validation
@dataclasses.dataclass
class GroupResult:
scores: list = dataclasses.field(default_factory=list)
fullscore: int = 0
bodyhits: int = 0
bodies: int = 0
renderedpages: int = 0
rejected: int = 0
timings: dict = dataclasses.field(default_factory=lambda: dict.fromkeys(('layout', 'pagination', 'raster', 'cache'), 0.0))
class BodyBands:
def __init__(self, root, persistent, result, limit=16 * 1024 * 1024):
self.root, self.persistent, self.result, self.limit = root, persistent, result, limit
self.images = collections.OrderedDict()
self.size = 0
self.known = {}
self.root.mkdir(parents=True, exist_ok=True)
def get(self, notation, page, band, fonts):
key = generator.engraving.cache.band_fingerprint(notation, page, band)
if key in self.images:
self.images.move_to_end(key)
self.result.bodyhits += 1
return self.images[key]
started = time.monotonic()
entry = self.root / (key + '.rgb')
image = None
if key in self.known:
if entry.is_symlink() or entry.stat().st_size != page.width * (band.bottom - band.top) * 3:
raise generator.engraving.types.EngravingError('temporary notation band was corrupted')
data = entry.read_bytes()
if hashlib.sha256(data).hexdigest() != self.known[key]:
raise generator.engraving.types.EngravingError('temporary notation band was corrupted')
image = PIL.Image.frombytes('RGB', (page.width, band.bottom - band.top), data)
elif self.persistent:
cached = self.persistent / 'notation' / key
image = generator.engraving.cache.restore_band(cached, key, page.width, band.bottom - band.top)
self.result.rejected += int(image is None and (cached.exists() or cached.is_symlink()))
self.result.timings['cache'] += time.monotonic() - started
if image is None:
started = time.monotonic()
image = generator.engraving.raster.rasterize_band(page, band.primitives, band.top, band.bottom, fonts)
self.result.timings['raster'] += time.monotonic() - started
self.result.bodies += 1
if self.persistent:
started = time.monotonic()
generator.engraving.cache.store_band(self.persistent / 'notation' / key, key, image)
self.result.timings['cache'] += time.monotonic() - started
else:
self.result.bodyhits += 1
if key not in self.known:
started = time.monotonic()
# Spill finalized pixels, so eviction never repeats rasterization or PNG decoding.
data = image.tobytes()
entry.write_bytes(data)
self.result.timings['cache'] += time.monotonic() - started
self.known[key] = hashlib.sha256(data).hexdigest()
size = image.width * image.height * 3
while self.images and self.size + size > self.limit:
_, old = self.images.popitem(last=False)
self.size -= old.width * old.height * 3
old.close()
self.images[key] = image
self.size += size
return image
def close(self):
for image in self.images.values():
image.close()
self.images.clear()
self.size = 0
def clear_pages(destination):
if destination.is_symlink() or destination.is_file():
destination.unlink()
elif destination.exists():
shutil.rmtree(destination)
def write_bundle(path, document, pages, fingerprint):
entries = [{k: v for k, v in {**page, 'path': path.with_suffix('.pages').name + '/' + page['path']}.items() if k != 'playback'} for page in pages]
normal = [entry for entry, page in zip(entries, pages) if not page['playback']]
playback = [entry for entry, page in zip(entries, pages) if page['playback']]
music = document
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': normal, 'playback_pages': playback}}
validate_cached_views(document, pages)
bundle['performance'] = generator.performance.timing(document)
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def validate_cached_views(document, pages):
generator.performance.validate_views(document, [p for p in pages if not p['playback']], [p for p in pages if p['playback']])
def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, progress=False):
family = generator.engraving.fonts.Fonts(font_path)
style = generator.engraving.types.Style()
systems = None
index = None
scores = {}
audited = set()
result = GroupResult()
with tempfile.TemporaryDirectory(prefix='vamp_notation_') as temporary:
bodies = BodyBands(pathlib.Path(temporary), cache_dir, result)
try:
for path, score_key in paths:
try:
document = json.loads(path.read_text(encoding='utf-8'))
style = generator.engraving.types.score_style(document, generator.engraving.types.Style())
destination = path.with_suffix('.pages')
clear_pages(destination)
destination.mkdir()
if score_key in scores:
source, pages = scores[score_key]
for page in pages:
shutil.copyfile(source / page['path'], destination / page['path'])
result.fullscore += 1
else:
if systems is None:
started = time.monotonic()
generator.validation.validate_document(document)
index = generator.engraving.notation.ScoreIndex(document)
systems = generator.engraving.layout.layout_systems(document, style, family, index=index)
result.timings['layout'] += time.monotonic() - started
started = time.monotonic()
pages_layout, playback_layout = generator.engraving.layout.page_views(systems, document, family, style, index=index)
placement = tuple((page.width, page.height, tuple((system.start, system.end, system.box) for system in page.systems)) for page in pages_layout)
if placement not in audited:
layout = generator.engraving.types.LayoutDocument(pages_layout, style, fingerprint)
generator.engraving.audit.audit_layout(document, layout, family)
generator.engraving.audit.audit_playback(pages_layout, playback_layout, style, family)
audited.add(placement)
else:
for page in pages_layout + playback_layout:
decoration = tuple(p for p in page.primitives if p.role in ('title', 'subtitle', 'page_number'))
generator.engraving.audit.audit_primitives(decoration, page.width, page.height, family)
result.timings['pagination'] += time.monotonic() - started
pages = []
rendered = {}
for is_playback, page in [(False, p) for p in pages_layout] + [(True, p) for p in playback_layout]:
if page.primitives in rendered:
name, digest = rendered[page.primitives]
pages.append(dict(path=name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
continue
bands = generator.engraving.raster.page_bands(page)
if bands is None:
started = time.monotonic()
image = generator.engraving.raster.rasterize_page(page, family)
else:
body = bodies.get(notation, page, bands[1], family)
started = time.monotonic()
image = generator.engraving.raster.compose_page(page, family, bands, body)
target = destination / (('playback-' if is_playback else '') + f'{page.number:04}.png')
with image:
image.save(target, format='PNG')
result.timings['raster'] += time.monotonic() - started
result.renderedpages += 1
digest = hashlib.sha256(target.read_bytes()).hexdigest()
rendered[page.primitives] = target.name, digest
pages.append(dict(path=target.name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
scores[score_key] = (destination, pages)
write_bundle(path, document, pages, fingerprint)
result.scores.append((path, score_key, pages))
if progress and len(result.scores) % 50 == 0:
print(f'Engraving body {notation[:12]}: {len(result.scores)}/{len(paths)} scores, fullscore {result.fullscore}, bodyhits {result.bodyhits}, bodies {result.bodies}, renderedpages {result.renderedpages}', flush=True)
except ValueError as error:
raise generator.engraving.types.EngravingError(f'{path.relative_to(root)}: {error}') from error
finally:
bodies.close()
return result
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
import dataclasses
import fractions
import typing
import generator.harmony
import generator.model
import generator.pitch
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
MIN_FRET = 1
MAX_FRET = 20
MAX_SINGLE_FRET = 12
PREFERRED_FRET_SPAN = 3
MAX_FRET_SPAN = 4
MAX_ANCHOR_FRET = 12
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
class TabPosition(typing.NamedTuple):
string_number: int
fret: int
midi: int
def fret_span(positions) -> int:
"""Inclusive occupied positions: frets 7-9 span three, 7-10 span four."""
frets = [position.fret for position in positions]
return max(frets) - min(frets) + 1 if frets else 0
def span_penalty(positions) -> int:
"""Two tiers for legal grips; all one-to-three-fret shapes share tier zero."""
return int(fret_span(positions) > PREFERRED_FRET_SPAN)
def within_fret_span(positions) -> bool:
return fret_span(positions) <= MAX_FRET_SPAN
def has_fret_anchor(positions) -> bool:
return any(MIN_FRET <= position.fret <= MAX_ANCHOR_FRET for position in positions)
def positions_for_pitch(midi: fractions.Fraction) -> tuple[TabPosition, ...]:
if midi.denominator != 1:
return ()
return tuple(TabPosition(string, int(midi - open_midi), int(midi)) for string, open_midi in STANDARD_TUNING if MIN_FRET <= midi - open_midi <= MAX_FRET)
def single_positions_for_pitch(midi: fractions.Fraction) -> tuple[TabPosition, ...]:
return tuple(p for p in positions_for_pitch(midi) if p.fret <= MAX_SINGLE_FRET)
def single_register(register: tuple[int, int]) -> tuple[int, int]:
return max(register[0], min(p + MIN_FRET for _, p in STANDARD_TUNING)), min(register[1], max(p + MAX_SINGLE_FRET for _, p in STANDARD_TUNING))
def playable_attack(tones) -> bool:
"""Check a played note/chord, including tails after shorter tones release.
Each item is a (position, sounding duration) pair. Independent parts and
unsounded members of an underlying grip cannot supply the chord's anchor.
"""
tones = tuple(tones)
for time in {0, *(duration for _, duration in tones if duration > 0)}:
positions = tuple(p for p, duration in tones if duration > time or time == duration == 0)
if not positions:
continue
if any(p not in positions_for_pitch(fractions.Fraction(p.midi)) for p in positions) or len({p.string_number for p in positions}) != len(positions) or not within_fret_span(positions):
return False
if len(positions) == 1:
if positions[0].fret > MAX_SINGLE_FRET:
return False
elif not has_fret_anchor(positions):
return False
return True
def validate_document(document: dict) -> None:
"""Validate generated guitar policy separately from the score wire format."""
for staff in document['staves']:
if not staff.get('tablature'):
continue
for measure in staff['measures']:
sounding = []
for voice in measure['voices']:
for note in voice['notes']:
tones = []
for pitch in note['pitches']:
tab = pitch['tablature']
position = TabPosition(tab['string'], tab['fret'], dict(STANDARD_TUNING)[tab['string']] + tab['fret'])
tones.append((position, generator.model.fraction(pitch['duration'])))
start = generator.model.fraction(note['onset'])
sounding.append((start, start + generator.model.fraction(pitch['duration']), position))
if not playable_attack(tones):
raise ValueError(f'{measure["id"]}, event {note["id"]}: single notes require frets {MIN_FRET}-{MAX_SINGLE_FRET}; each sounding chord requires an anchor in {MIN_FRET}-{MAX_ANCHOR_FRET} and at most {MAX_FRET_SPAN} consecutive fret positions, including held/tied tails')
if len(measure['voices']) > 1:
for time in sorted({t for a, b, _ in sounding for t in (a, b)}):
held = [(p, b - time) for a, b, p in sounding if a <= time < b]
if not playable_attack(held):
raise ValueError(f'{measure["id"]}, beat {time + 1}: overlapping guitar voices conflict in string, fret span or sounding anchor')
def validate_tuning(state: dict, location: str) -> None:
details = state['staff_details']
if details.get('capo', 0):
raise ValueError(f'{location}: guitar generation does not support capo configurations')
tunings = details.get('tunings', [])
expected = {7 - string: midi for string, midi in STANDARD_TUNING}
if tunings and (len(tunings) != 6 or {t['line']: generator.model.pitch_number(t) for t in tunings} != expected):
raise ValueError(f'{location}: guitar generation requires standard six-string tuning')
@dataclasses.dataclass(frozen=True)
class ChordRequirements:
required: tuple[int, ...] = ()
allowed: tuple[int, ...] = ()
foundation: tuple[int, ...] = ()
basses: tuple[int, ...] = ()
def covers(self, classes: set[int]) -> bool:
return set(self.required) <= classes and bool(set(self.foundation) & classes)
def chord_requirements(definition: dict, *, respect_bass: bool = True) -> ChordRequirements:
degrees = generator.harmony.degrees(definition)
root = definition['root']
root_pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
explicit = {d['value'] for d in definition.get('degrees', []) if d['type'] != 'subtract'}
optional = {d for d, a in degrees.items() if not a and d not in explicit and d in (9, 11) and d < max(degrees)}
classes = {d: (root_pc + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
required = {value for d, value in classes.items() if d not in optional and (d not in (1, 5) or degrees[d])}
allowed = set(classes.values())
foundation = {value for d, value in classes.items() if d in (1, 5)}
if not foundation:
raise ValueError('generated guitar chords require a root or fifth in the harmony')
bass = definition.get('bass')
bass_pc = (generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12 if bass else None
if bass_pc is None and 'inversion' in definition:
ordered = sorted(degrees)
inversion = definition['inversion']
if not 0 <= inversion < len(ordered):
raise ValueError('harmony inversion exceeds its chord degrees')
bass_pc = classes[ordered[inversion]]
if not respect_bass:
bass_pc = None
if bass_pc is not None:
allowed.add(bass_pc)
required.add(bass_pc)
basses = {bass_pc} if bass_pc is not None else set()
return ChordRequirements(tuple(sorted(required)), tuple(sorted(allowed)), tuple(sorted(foundation)), tuple(sorted(basses)))
def chord_pitches(definition: dict, positions: tuple[TabPosition, ...]) -> list[dict]:
spellings = {}
for degree, alter in generator.harmony.degrees(definition).items():
value = generator.pitch.shift_pitch(definition['root'], degree - 1, generator.harmony.degree_semitones(degree, alter))
spellings[(generator.model.NATURAL[value['letter']] + generator.model.alteration(value['alter'])) % 12] = value
if bass := definition.get('bass'):
spellings[(generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12] = bass
result = []
for position in sorted(positions, key=lambda p: p.midi):
value = spellings[position.midi % 12].copy()
value['octave'] = int((position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET - generator.model.NATURAL[value['letter']] - generator.model.alteration(value['alter'])) // 12 - 1)
value['tablature'] = {'string': position.string_number, 'fret': position.fret}
result.append(value)
return result
+368
View File
@@ -0,0 +1,368 @@
from __future__ import annotations
import dataclasses
import fractions
import functools
import itertools
import logging
import typing
import generator.chord_families
import generator.fretboard
import generator.model
import generator.hand_position
import generator.pitch
import generator.vocabulary
ELECTRIC_STRINGS = ((3, 2, 1), (4, 3, 2, 1))
class VoicingCost(typing.NamedTuple):
extended_grips: int = 0
melody_string: int = 0
covered_melody: int = 0
covering_tones: int = 0
string_shortfall: int = 0
family_penalty: int = 0
travel: int = 0
peak_position: int = 0
effort: fractions.Fraction = fractions.Fraction()
missing_tones: int = 0
register: fractions.Fraction = fractions.Fraction()
def plus(self, other: VoicingCost) -> VoicingCost:
return VoicingCost(*(max(a, b) if field == "peak_position" else a + b for field, a, b in zip(self._fields, self, other)))
@functools.lru_cache(maxsize=16384)
def moved(self, distance: int, ergonomics: int = 0) -> VoicingCost:
return self._replace(travel=self.travel + distance, effort=self.effort + ergonomics)
def voicing_preference(role, center=None, *, composition=None):
"""Immutable role settings for both selection and scored-retrieval caches."""
composition = composition or generator.vocabulary.current()
settings = composition.role(role)
policy = settings.get('voicing', {})
center = settings.get('center', 53 if role.startswith('acoustic') else 60) if center is None else center
if set(policy) - {'register_weight'}:
raise ValueError('unknown voicing preference; hand position is ranked across the passage')
result = tuple(fractions.Fraction(str(v)) for v in (center, policy.get('register_weight', 1)))
if result[1] < 0:
raise ValueError('voicing preference requires a nonnegative register tie weight')
return result
@functools.lru_cache(maxsize=8192)
def voicing_preferences(positions, preference):
if not positions or not preference:
return fractions.Fraction()
center, register_weight = preference
register = abs(fractions.Fraction(sum(p.midi for p in positions), len(positions)) - center)
return register_weight * register
def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), melody: tuple[int, ...] = (), *, composition=None) -> VoicingCost:
composition = composition or generator.vocabulary.current()
families = tuple(composition.accompaniment('electric')['voicing_families']['rank'].items()) if profile == 'electric' else ()
preference = voicing_preference('electric_melody', composition=composition) if profile == 'electric' and melody else ()
return _voicing_cost(positions, profile, allowed, melody, families, preference)
@functools.lru_cache(maxsize=8192)
def _voicing_cost(positions, profile, allowed, melody, families, preference):
frets = {p.fret for p in positions}
classes = {p.midi % 12 for p in positions}
above = sum(p.midi > max(melody) for p in positions) if melody else 0
span = compactness(positions)
if profile == 'electric':
effort = 2 * max(0, len(frets) - 1) + 3 * span
else:
effort = span
family_penalty = dict(families).get(generator.chord_families.electric(positions), 0) if positions and families else 0
melody_string = min((p.string_number - 1 for p in positions if melody and p.midi == max(melody)), default=0)
return VoicingCost(extended_grips=generator.fretboard.span_penalty(positions), melody_string=melody_string, string_shortfall=max(0, 4 - len(positions)) if profile == 'electric' else 0, covered_melody=int(bool(above)), covering_tones=above, family_penalty=family_penalty, effort=effort, missing_tones=len(set(allowed) - classes), register=voicing_preferences(positions, preference))
def chord_positions(requirements: generator.fretboard.ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = (), *, composition=None) -> tuple[tuple[generator.fretboard.TabPosition, ...], ...]:
composition = composition or generator.vocabulary.current()
families = tuple(composition.accompaniment('electric')['voicing_families']['rank'].items()) if profile == 'electric' else ()
preference = voicing_preference('electric_melody', composition=composition) if profile == 'electric' and melody else ()
return _chord_positions(requirements, prune, profile, melody, families, preference)
@functools.lru_cache(maxsize=256)
def _chord_positions(requirements, prune, profile, melody, families, preference):
result = []
options = [tuple(generator.fretboard.TabPosition(string, fret, open_midi + fret) for fret in range(generator.fretboard.MIN_FRET, generator.fretboard.MAX_FRET + 1) if (open_midi + fret) % 12 in requirements.allowed or open_midi + fret in melody) for string, open_midi in generator.fretboard.STANDARD_TUNING]
def visit(index, selected, covered):
if len(set(requirements.required) - covered) > len(options) - index:
return
if index == len(options):
if selected and generator.fretboard.has_fret_anchor(selected) and requirements.covers(covered) and (not requirements.basses or min(selected, key=lambda p: p.midi).midi % 12 in requirements.basses):
result.append(tuple(selected))
return
visit(index + 1, selected, covered)
for position in options[index]:
if position.midi % 12 not in covered and generator.fretboard.within_fret_span((*selected, position)):
visit(index + 1, (*selected, position), covered | {position.midi % 12})
if profile:
by_string = {string: options[i] for i, (string, _) in enumerate(generator.fretboard.STANDARD_TUNING)}
# Every electric chord includes string 1, even with a lower melody inside.
for strings in ELECTRIC_STRINGS:
for selected in itertools.product(*(by_string[s] for s in strings)):
if not generator.fretboard.within_fret_span(selected) or not generator.fretboard.has_fret_anchor(selected) or not requirements.covers({p.midi % 12 for p in selected}) or not set(melody) <= {p.midi for p in selected}:
continue
if requirements.basses and (selected[0].midi % 12 not in requirements.basses or selected[0].midi != min(p.midi for p in selected)):
continue
result.append(selected)
else:
visit(0, (), set())
# Equal centers no longer imply equal future movement. Keep concrete grips;
# only identical positions can be merged before contextual placement.
return tuple(sorted(set(result)))
@functools.lru_cache(maxsize=16384)
def movement(previous, current, old_hand, hand) -> int:
return generator.hand_position.motion(previous, current, old_hand, hand).shift
def compactness(positions) -> int:
frets = [p.fret for p in positions]
return max(frets) - min(frets) if frets else 0
def extend_positions(held, pending):
if not pending:
yield tuple(sorted(held.items()))
return
identifier, options = pending[0]
occupied = {p.string_number for p in held.values()}
for position in options:
if position.string_number not in occupied and generator.fretboard.within_fret_span((*held.values(), position)):
yield from extend_positions({**held, identifier: position}, pending[1:])
@dataclasses.dataclass
class Placement:
cost: VoicingCost
active: tuple
previous: Placement | None
choices: tuple
hand: int = 0
@dataclasses.dataclass(frozen=True)
class GuitarTone:
pitch: dict
end: fractions.Fraction
positions: tuple[generator.fretboard.TabPosition, ...]
@dataclasses.dataclass(frozen=True)
class AttackCandidate:
active: tuple
choices: tuple
shape: tuple[generator.fretboard.TabPosition, ...]
cost: VoicingCost
@dataclasses.dataclass(frozen=True)
class EventPlacement:
note: dict
exact: tuple
added: tuple[generator.fretboard.TabPosition, ...] = ()
def playable_choices(choices) -> bool:
return all(generator.fretboard.playable_attack((*((position, generator.model.fraction(tone['duration'])) for tone, position in choice.exact), *((position, generator.model.fraction(choice.note['duration'])) for position in choice.added))) for choice in choices)
def continued_sources(notes):
result = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p}
result.update(f'{n["_continue_from"]}_s{s}' for n in notes if '_continue_from' in n for s, _ in generator.fretboard.STANDARD_TUNING)
return result
def has_attack(notes: list[dict]) -> bool:
return any('_harmony' in n and '_continue_from' not in n or any('tie_from' not in p for p in n['pitches']) for n in notes)
def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: dict, held: dict, preserve_strings: bool, fallback: bool = False, *, composition=None):
composition = composition or generator.vocabulary.current()
pending = []
chords = [n for n in notes if '_harmony' in n]
chord = chords[0] if chords else None
profile = chord.get('_guitar_profile', '') if chord else ''
if len(chords) > 1:
raise ValueError('simultaneous generated chords require acoustic harmony-span placement')
for note in notes:
for tone in note['pitches']:
options = tones[tone['id']].positions
if source := tone.get('tie_from'):
options = tuple(p for p in options if p == active.get(source))
pending.append((tone['id'], options))
if fallback and chord and chord.get('_harmonize'):
chords, chord, profile = [], None, ''
requirements = generator.fretboard.chord_requirements(chord['_harmony'], respect_bass=profile != 'electric') if chord else generator.fretboard.ChordRequirements()
melody = tuple(sorted({p.midi for _, options in pending for p in options})) if profile == 'electric' else ()
fresh = has_attack(notes)
candidates = chord_positions(requirements, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody, composition=composition) if chord else [()]
for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))):
exact_positions = dict(exact)
for positions in candidates:
if profile:
if any(p not in positions for p in exact_positions.values()):
continue
assignment = dict(exact)
choices = []
compatible = True
for note in notes:
added = tuple(p for p in positions if p not in exact_positions.values() and note in chords)
if any(p.midi % 12 not in requirements.allowed for p in added):
compatible = False
break
if '_continue_from' in note:
previous = {p for i, p in active.items() if i.startswith(note['_continue_from'] + '_s')}
if previous != set(added):
compatible = False
break
assignment.update((f'{note["id"]}_s{p.string_number}', p) for p in added)
choices.append(EventPlacement(note, tuple((p, exact_positions[p['id']]) for p in note['pitches']), added))
if not compatible or not playable_choices(choices):
continue
cost = voicing_cost(positions, profile, requirements.allowed, melody, composition=composition) if fresh else VoicingCost()
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost)
else:
shape = tuple(p for _, p in exact) + positions
if len({p.string_number for p in shape}) != len(shape) or not generator.fretboard.within_fret_span(shape):
continue
assignment = tuple(sorted((*exact, *((f'{chord["id"]}_s{p.string_number}', p) for p in positions)))) if chord else exact
choices = tuple(EventPlacement(n, tuple((p, exact_positions[p['id']]) for p in n['pitches']), positions if n is chord else ()) for n in notes)
if not playable_choices(choices):
continue
cost = voicing_cost(shape, profile, requirements.allowed, melody, composition=composition) if fresh else VoicingCost()
yield AttackCandidate(assignment, choices, shape, cost)
def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
events, tones = [], {}
absolute = fractions.Fraction()
previous = {}
for measure in document['staves'][0]['measures']:
if measure.get('section_label'):
previous.clear()
changes = sorted(measure.get('attributes', []), key=lambda a: generator.model.fraction(a['onset']))
state = {k: measure[k] for k in ('transpose', 'staff_details')}
for change in [state, *changes]:
state.update({k: change[k] for k in state if k in change})
generator.fretboard.validate_tuning(state, measure['id'])
for voice in measure['voices']:
for order, note in enumerate(voice['notes']):
if note.get('_rhythm_continuation'):
source = previous.get(voice['id'])
if source and '_harmony' in source:
note['_continue_from'] = source['id']
for string, _ in generator.fretboard.STANDARD_TUNING:
tones[f'{source["id"]}_s{string}'].pitch['tie_to'] = f'{note["id"]}_s{string}'
elif not note['pitches']:
note.pop('_harmony', None)
note['kind'] = 'rest'
previous[voice['id']] = note
onset = generator.model.fraction(note['onset'])
state = {k: measure[k] for k in ('transpose', 'staff_details')}
for change in changes:
if generator.model.fraction(change['onset']) <= onset:
state.update({k: change[k] for k in state if k in change})
for tone in note['pitches']:
sounding = generator.pitch.sounding_pitch(tone, state)
options = generator.fretboard.positions_for_pitch(sounding)
if tab := tone.get('tablature'):
options = tuple(p for p in options if (p.string_number, p.fret) == (tab['string'], tab['fret']))
if not options:
raise ValueError(f'{measure["id"]}, event {note["id"]}: sounding pitch {sounding} has no string/fret in {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET}')
tones[tone['id']] = GuitarTone(tone, absolute + onset + generator.model.fraction(tone['duration']), options)
if '_harmony' in note:
for string, _ in generator.fretboard.STANDARD_TUNING:
tones[f'{note["id"]}_s{string}'] = GuitarTone({}, absolute + onset + generator.model.fraction(note['duration']), ())
if note['pitches'] or '_harmony' in note:
events.append((absolute + onset, not bool(note.get('grace')), order if note.get('grace') else -1, note))
absolute += generator.model.fraction(measure['duration'])
return sorted(events, key=lambda e: e[:3]), tones
def retain_tone(tone: GuitarTone, onset: fractions.Fraction) -> bool:
return tone.end > onset or tone.end == onset and bool(tone.pitch.get('tie_to'))
def keep_position(result, candidate, previous, fresh=True):
old_shape = tuple(p for _, p in previous.active)
hands = [previous.hand]
if fresh:
hands = generator.hand_position.regions(candidate.shape)
for hand in hands:
if candidate.shape and not all(hand <= p.fret <= hand + 3 for p in candidate.shape):
continue
motion = generator.hand_position.motion(old_shape, candidate.shape, previous.hand, hand) if fresh else generator.hand_position.Motion()
local = candidate.cost._replace(peak_position=hand)
cost = previous.cost.plus(local).moved(motion.shift, motion.ergonomics)
key = (candidate.active, hand, cost.peak_position)
value = Placement(cost, candidate.active, previous, candidate.choices, hand)
if key not in result or cost < result[key].cost:
result[key] = value
def reduce_positions(result):
# Future effort depends on the concrete held strings and hand, not on the
# earlier route. A strictly worse primary cost is dominated even if lower.
best = {}
for (active, hand, _), state in result.items():
key = (active, hand)
best[key] = min(best.get(key, state.cost[:7]), state.cost[:7])
return {key: state for key, state in result.items() if state.cost[:7] == best[key[:2]]}
def advance_attacks(candidates, states: dict) -> dict:
result = {}
for candidate in candidates:
for previous in states.values():
keep_position(result, candidate, previous)
return reduce_positions(result)
def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False, *, composition=None) -> dict:
composition = composition or generator.vocabulary.current()
continued = continued_sources(notes)
fresh = has_attack(notes)
held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)}
result = {}
if not held_ids and not continued:
result = advance_attacks(attack_candidates(notes, tones, {}, {}, preserve_strings, fallback, composition=composition), states)
else:
for previous in states.values():
active = dict(previous.active)
held = {i: p for i, p in previous.active if i not in continued and i in held_ids}
for candidate in attack_candidates(notes, tones, active, held, preserve_strings, fallback, composition=composition):
keep_position(result, candidate, previous, fresh)
if not result:
if not fallback and any(n.get('_harmonize') for n in notes):
return advance_positions(notes, onset, tones, states, preserve_strings, True, composition=composition)
raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement; single notes need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_SINGLE_FRET}; chords need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET}, an anchor <= {generator.fretboard.MAX_ANCHOR_FRET}, and at most {generator.fretboard.MAX_FRET_SPAN} consecutive fret positions; held/tied strings must remain fixed')
return reduce_positions(result)
def place_guitar(document: dict, *, composition=None) -> dict:
"""Choose electric voicings or exact/held melody placements."""
composition = composition or generator.vocabulary.current()
events, tones = guitar_events(document)
states = {(): Placement(VoicingCost(), (), None, ())}
groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])]
for index, (onset, notes) in enumerate(groups):
next_onset = groups[index + 1][0] if index + 1 < len(groups) else None
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes)
states = advance_positions(notes, onset, tones, states, preserve_strings, composition=composition)
selected = min(states.values(), key=lambda s: (s.cost, s.active))
trace = []
final_cost = selected.cost
skipped = []
while selected.previous is not None:
fresh = has_attack([c.note for c in selected.choices])
motion = generator.hand_position.motion(tuple(p for _, p in selected.previous.active), tuple(p for _, p in selected.active), selected.previous.hand, selected.hand) if fresh else generator.hand_position.Motion()
trace.append({'hand_position': selected.hand, 'events': [c.note['id'] for c in selected.choices],
'hand_shift': motion.shift, 'finger_adjustment': motion.fingers, 'string_changes': motion.strings,
'effort': str(selected.cost.effort - selected.previous.cost.effort)})
for choice in selected.choices:
note = choice.note
for tone, position in choice.exact:
tone['tablature'] = {'string': position.string_number, 'fret': position.fret}
if definition := note.pop('_harmony', None):
for pitch in generator.fretboard.chord_pitches(definition, choice.added):
note['pitches'].append({**pitch, 'id': f'{note["id"]}_s{pitch["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']})
if note.get('_harmonize') and not choice.added and not note.get('_rhythm_continuation'):
skipped.append(note['id'])
selected = selected.previous
for measure in document['staves'][0]['measures']:
for voice in measure['voices']:
for note in voice['notes']:
for field in ('_guitar_profile', '_guitar_lane', '_harmonize', '_continue_from'):
note.pop(field, None)
if skipped:
logging.getLogger(__name__).warning('%s: %s melody harmonizations kept as single notes; first event %s', document['metadata']['title'], len(skipped), skipped[-1])
document['staves'][0]['tablature'] = True
generator.fretboard.validate_document(document)
return {'version': generator.hand_position.VERSION, **generator.hand_position.summary(r['hand_shift'] for r in trace), 'peak_position': final_cost.peak_position, 'cost': {k: str(v) for k, v in final_cost._asdict().items()}, 'positions': list(reversed(trace))}
+52
View File
@@ -0,0 +1,52 @@
"""Four-fret hand windows shared by chord and single-note placement.
This describes playable regions, not an anatomical fingering solver. Chord
and single tones may occupy any frets within the window. Only shifting the
window is movement. Actual sounded-note limits remain in fretboard.py.
"""
import functools
import typing
import generator.fretboard
VERSION = 'hand-window-2'
class Motion(typing.NamedTuple):
shift: int = 0
fingers: int = 0
strings: int = 0
@property
def ergonomics(self):
return self.fingers + self.strings
def base(shape):
return min((p.fret for p in shape), default=0)
@functools.lru_cache(maxsize=4096)
def regions(shape):
if not shape:
return tuple([0])
return tuple(range(max(1, max(p.fret for p in shape) - 3), min(base(shape), generator.fretboard.MAX_ANCHOR_FRET) + 1))
@functools.lru_cache(maxsize=32768)
def motion(previous, current, old_hand, hand, acoustic=False):
if not previous or not current:
return Motion()
if len(previous) == len(current) == 1:
pairs = ((previous[0], current[0]),)
unmatched = 0
else:
def voices(shape):
return {('bass' if acoustic and p.string_number in (5, 6) else p.string_number): p for p in shape}
a, b = voices(previous), voices(current)
pairs = tuple((a[s], b[s]) for s in a.keys() & b.keys())
unmatched = len(a.keys() ^ b.keys())
return Motion(abs(hand - old_hand),
unmatched + sum(abs((a.fret - old_hand) - (b.fret - hand)) for a, b in pairs),
sum(abs(a.string_number - b.string_number) for a, b in pairs))
def summary(shifts):
shifts = tuple(shifts)
return {'total_travel': sum(shifts), 'shift_count': sum(s > 0 for s in shifts), 'largest_shift': max(shifts, default=0)}
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import dataclasses
import fractions
import generator.model
import generator.score
def solo_source(staff):
"""Source-form pickup context, shared by training and arranged choruses.
The arrangement repeats the source form before each solo. Only an unlabelled
pickup inherits its preceding source-form harmony. Scores without a pickup
retain their ordinary initial-harmony checks. Explicit pickup harmony
(including N.C.) still takes precedence in read().
"""
measures = staff['measures']
initial = None
if generator.score.body_start_measure_index(measures):
initial = next((h for m in reversed(measures) for h in reversed(m['harmonies'])), None)
return read(staff, initial=initial)
@dataclasses.dataclass(frozen=True)
class ChordSpan:
start: fractions.Fraction
end: fractions.Fraction
measure: int
source_measure: str
beat: fractions.Fraction
key: dict
chord: dict | None
def read(staff: dict, *, initial: dict | None = None) -> tuple[ChordSpan, ...]:
result = []
absolute = fractions.Fraction()
active = initial
for number, measure in enumerate(staff['measures'], 1):
length = generator.model.fraction(measure['duration'])
harmonies = {generator.model.fraction(h['onset']): h for h in measure['harmonies']}
changes = {generator.model.fraction(a['onset']): a['key'] for a in measure.get('attributes', []) if 'key' in a}
boundaries = sorted({fractions.Fraction(), length} | set(harmonies) | set(changes))
key = measure['key']
for start, end in zip(boundaries, boundaries[1:]):
key = changes.get(start, key)
active = harmonies.get(start, active)
result.append(ChordSpan(absolute + start, absolute + end, number, str(measure['number']), start + 1, key, active if active and active['kind'] != 'none' else None))
absolute += length
return tuple(result)
+174
View File
@@ -0,0 +1,174 @@
from __future__ import annotations
import re
import generator.model
# Semantic degrees are independent of guitar voicing omissions.
KINDS = {
'major': {1: 0, 3: 0, 5: 0}, 'minor': {1: 0, 3: -1, 5: 0},
'augmented': {1: 0, 3: 0, 5: 1}, 'diminished': {1: 0, 3: -1, 5: -1},
'dominant': {1: 0, 3: 0, 5: 0, 7: -1}, 'major-seventh': {1: 0, 3: 0, 5: 0, 7: 0},
'minor-seventh': {1: 0, 3: -1, 5: 0, 7: -1}, 'diminished-seventh': {1: 0, 3: -1, 5: -1, 7: -2},
'augmented-seventh': {1: 0, 3: 0, 5: 1, 7: -1}, 'augmented-major-seventh': {1: 0, 3: 0, 5: 1, 7: 0},
'half-diminished': {1: 0, 3: -1, 5: -1, 7: -1}, 'major-minor': {1: 0, 3: -1, 5: 0, 7: 0},
'major-sixth': {1: 0, 3: 0, 5: 0, 6: 0}, 'minor-sixth': {1: 0, 3: -1, 5: 0, 6: 0},
'suspended-second': {1: 0, 2: 0, 5: 0}, 'suspended-fourth': {1: 0, 4: 0, 5: 0},
'power': {1: 0, 5: 0}, 'pedal': {1: 0}, 'other': {}, 'none': {},
'Neapolitan': {1: 0, 3: 0, 5: 0}, 'Italian': {1: 0, 3: 0, 6: 1},
'French': {1: 0, 3: 0, 4: 1, 6: 1}, 'German': {1: 0, 3: 0, 5: 0, 6: 1},
}
for family, base in (('dominant', 'dominant'), ('major', 'major-seventh'), ('minor', 'minor-seventh')):
for suffix, highest in (('ninth', 9), ('11th', 11), ('13th', 13)):
KINDS[f'{family}-{suffix}'] = {**KINDS[base], **{i: 0 for i in range(9, highest + 1, 2)}}
SUFFIXES = {'major': 'M', 'minor': 'm', 'augmented': 'aug', 'diminished': 'dim', 'dominant': '7', 'major-seventh': 'M7', 'minor-seventh': 'm7', 'diminished-seventh': 'dim7', 'augmented-seventh': 'aug7', 'augmented-major-seventh': 'augM7', 'half-diminished': 'm7b5', 'major-minor': 'mM7', 'major-sixth': 'M6', 'minor-sixth': 'm6', 'suspended-second': 'sus2', 'suspended-fourth': 'sus', 'power': '5', 'pedal': 'pedal', 'other': ''}
for family, prefix in (('dominant', ''), ('major', 'M'), ('minor', 'm')):
for suffix, number in (('ninth', '9'), ('11th', '11'), ('13th', '13')):
SUFFIXES[f'{family}-{suffix}'] = prefix + number
SPELLINGS = {suffix: kind for kind, suffix in SUFFIXES.items() if kind != 'other'}
SPELLINGS.update({'': 'major', 'maj': 'major', 'min': 'minor', '-': 'minor', '+': 'augmented', 'o': 'diminished', 'o7': 'diminished-seventh', '6': 'major-sixth', 'sus4': 'suspended-fourth', 'mM': 'major-minor', 'mMaj7': 'major-minor', 'mmaj7': 'major-minor', '-maj7': 'major-minor', '-7b5': 'half-diminished', '+7': 'augmented-seventh', '+maj7': 'augmented-major-seventh'})
for kind, suffix in SUFFIXES.items():
if suffix.startswith('M'):
SPELLINGS['maj' + suffix[1:]] = kind
if suffix.startswith('m') and not suffix.startswith('mM'):
SPELLINGS['-' + suffix[1:]] = kind
SPELLINGS['min' + suffix[1:]] = kind
EXTENSIONS = {f'mM{number}': ('major-minor', number) for number in (9, 11, 13)}
EXTENSIONS.update({f'{number}sus{suffix}': (kind, number) for number in (7, 9, 11, 13) for suffix, kind in (('', 'suspended-fourth'), ('2', 'suspended-second'), ('4', 'suspended-fourth'))})
def extended_chord(kind: str, highest: int) -> dict:
additions = [{'value': degree, 'alter': -1 if degree == 7 else 0, 'type': 'add'} for degree in range(7, highest + 1, 2) if degree not in KINDS[kind]]
return {'kind': kind, 'degrees': additions}
def degrees(harmony: dict) -> dict[int, int]:
kind = harmony['kind']
if kind not in KINDS:
raise ValueError(f'unsupported harmony kind: {kind}')
result = KINDS[kind].copy()
for change in harmony.get('degrees', []):
degree, alter, operation = change['value'], generator.model.alteration(change['alter']), change['type']
if type(degree) is not int or degree < 1:
raise ValueError('invalid harmony degree')
if operation == 'subtract':
if degree not in result:
raise ValueError(f'cannot subtract absent chord degree {degree}')
del result[degree]
elif operation == 'alter':
if degree not in result:
raise ValueError(f'cannot alter absent chord degree {degree}; use add')
result[degree] = alter
elif operation == 'add':
if degree in result:
raise ValueError(f'duplicate added chord degree {degree}')
result[degree] = alter
else:
raise ValueError(f'unsupported degree operation: {operation}')
if kind != 'none' and not result:
if kind == 'other':
raise ValueError("harmony kind 'other' requires explicit degrees or a recognized chord symbol")
raise ValueError('harmony contains no pitch information')
return result
def is_triad(harmony: dict) -> bool:
return set(degrees(harmony)) == {1, 3, 5}
def degree_semitones(degree: int, alter: int) -> int:
return 12 * ((degree - 1) // 7) + (0, 2, 4, 5, 7, 9, 11)[(degree - 1) % 7] + alter
def pitch_classes(harmony: dict) -> tuple[int, ...]:
if harmony['kind'] == 'none':
return ()
root = harmony['root']
root_pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
values = [(root_pc + degree_semitones(d, a)) % 12 for d, a in degrees(harmony).items()]
if bass := harmony.get('bass'):
values.append((generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12)
return tuple(dict.fromkeys(values))
def chord_suffix(harmony: dict) -> str:
values = degrees(harmony)
kind = harmony['kind']
if values == KINDS[kind]:
return SUFFIXES.get(kind, kind)
for name, suffix in SUFFIXES.items():
if name != 'other' and values == KINDS[name]:
return suffix
for suffix, (name, highest) in EXTENSIONS.items():
if values == degrees(extended_chord(name, highest)):
return suffix
suffix = SUFFIXES.get(kind, kind)
for change in sorted(harmony.get('degrees', []), key=lambda change: change['value']):
amount = generator.model.alteration(change['alter'])
sign = '#' * max(int(amount), 0) + 'b' * max(int(-amount), 0) if amount.denominator == 1 else f'({amount})'
suffix += ('no' if change['type'] == 'subtract' else ('add' if change['type'] == 'add' and not sign else '') + sign) + str(change['value'])
return suffix
def symbol(harmony: dict) -> str:
if harmony['kind'] == 'none':
return 'N.C.'
suffix = chord_suffix(harmony)
result = generator.model.pitch_name(harmony['root']) + suffix
if bass := harmony.get('bass'):
result += '/' + generator.model.pitch_name(bass)
return '(' + result + ')' if harmony.get('parenthesized') else result
def parse_symbol(text: str) -> dict:
text = text.strip()
parenthesized = text.startswith('(') and text.endswith(')')
content = text[1:-1] if parenthesized else text
match = re.fullmatch(r'([A-Ga-g][#b]*)([^/()]*)(?:/([A-Ga-g][#b]*))?', content)
if not match:
raise ValueError(f'invalid chord symbol: {text!r}')
root, suffix, bass = match.groups()
core = next(core for core in sorted(SPELLINGS.keys() | EXTENSIONS.keys(), key=lambda value: (-len(value), value)) if suffix.startswith(core))
result = extended_chord(*EXTENSIONS[core]) if core in EXTENSIONS else {'kind': SPELLINGS[core], 'degrees': []}
remainder = suffix[len(core):]
changed = set()
while remainder:
modifier = re.match(r'(add|no)?(b+|#+)?([1-9][0-9]*)', remainder)
if not modifier or not (modifier[1] or modifier[2]):
raise ValueError(f'unsupported chord spelling: {text!r}')
operation, accidental, value = modifier.groups()
accidental = accidental or ''
degree = int(value)
if degree in changed or operation == 'no' and accidental:
raise ValueError(f'contradictory chord degrees: {text!r}')
changed.add(degree)
operation = 'subtract' if operation == 'no' else 'alter' if degree in degrees(result) and operation != 'add' else 'add'
result['degrees'].append({'value': degree, 'alter': accidental.count('#') - accidental.count('b'), 'type': operation})
remainder = remainder[modifier.end():]
result['root'] = generator.model.parse_pitch_name(root)
for change in result['degrees']:
change['alter'] = generator.model.rational(change['alter'])
if bass:
result['bass'] = generator.model.parse_pitch_name(bass)
if parenthesized:
result['parenthesized'] = True
degrees(result)
return result
def normalize_text(harmony: dict, text: str) -> None:
text = text.strip()
parenthesized = text.startswith('(') and text.endswith(')')
content = text[1:-1] if parenthesized else text
try:
displayed = parse_symbol(generator.model.pitch_name(harmony['root']) + content)
except ValueError:
displayed = parse_symbol(content)
text_only = harmony['kind'] == 'other' and not harmony['degrees']
if displayed['root'] != harmony['root']:
if not text_only or harmony['root'] != generator.model.parse_pitch_name('C'):
raise ValueError(f'contradictory harmony root in {text!r}')
if 'bass' in displayed and 'bass' in harmony and displayed['bass'] != harmony['bass']:
raise ValueError(f'contradictory harmony bass in {text!r}')
if text_only:
harmony.update(displayed)
elif degrees(displayed) not in (degrees(harmony), KINDS[harmony['kind']]):
raise ValueError(f'contradictory harmony text {text!r} and structured chord degrees')
if 'bass' in displayed:
harmony['bass'] = displayed['bass']
if parenthesized:
harmony.update(parenthesized=True, chord_optional=True)
+41
View File
@@ -0,0 +1,41 @@
"""Composition read boundary, independent of optional audition/reference tools."""
from __future__ import annotations
import contextlib
import contextvars
import os
import pathlib
import sys
ACTIVE = contextvars.ContextVar('composition_reads', default=None)
def _read(event, args):
scope = ACTIVE.get()
if scope is None or event != 'open':
return
name, mode, flags = args
if not isinstance(name, (str, bytes, os.PathLike)):
return
# r+ and w+ can read; they must obey the same boundary as a plain read.
if flags & os.O_WRONLY:
return
path = pathlib.Path(os.fsdecode(name)).resolve()
data, code, cache, reads = scope
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
if cache and path.is_relative_to(cache):
allowed = True
if not allowed:
raise ValueError('Forbidden composition input: ' + str(path))
reads.add(str(path))
sys.addaudithook(_read)
@contextlib.contextmanager
def composition(data, role, cache=None):
reads = set()
code = pathlib.Path(__file__).resolve().parent
token = ACTIVE.set((pathlib.Path(data).resolve(), code, pathlib.Path(cache).resolve() / role if cache else None, reads))
try:
yield reads
finally:
ACTIVE.reset(token)
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
import bisect
import copy
import fractions
import generator.accompaniment
import generator.model
def harmonize(document: dict) -> None:
"""Split long melody runs at harmony changes before choosing guitar positions."""
staff = document['staves'][0]
tones, events, changes = {}, [], []
absolute = fractions.Fraction()
previous = None
for measure in staff['measures']:
for chord in measure['harmonies']:
value = chord if chord['kind'] != 'none' else None
if value != previous and not generator.accompaniment.same_chord(value, previous):
time = absolute + generator.model.fraction(chord['onset'])
if changes and changes[-1][0] == time:
changes[-1] = (time, value)
else:
changes.append((time, value))
previous = value
for voice in measure['voices']:
for note in voice['notes']:
start = absolute + generator.model.fraction(note['onset'])
events.append((note, start))
for tone in note['pitches']:
tones[tone['id']] = tone
absolute += generator.model.fraction(measure['duration'])
eligible = set()
visited = set()
for note, _ in events:
if not note.get('_melody') or note.get('grace') or note.get('cue') or len(note['pitches']) != 1 or note['pitches'][0].get('cue'):
continue
tone = note['pitches'][0]
if tone['id'] in visited:
continue
while tone.get('tie_from'):
tone = tones[tone['tie_from']]
chain = []
while True:
chain.append(tone)
if not tone.get('tie_to'):
break
tone = tones[tone['tie_to']]
visited.update(p['id'] for p in chain)
if sum((generator.model.fraction(p['duration']) for p in chain), fractions.Fraction()) > 1:
eligible.update(p['id'] for p in chain)
times = [time for time, _ in changes]
reattacks = {time for time, chord in changes if chord}
replacements, tone_pieces = {}, {}
for note, start in events:
melodic = note.pop('_melody', False)
if not melodic or not note['pitches'] or note['pitches'][0]['id'] not in eligible:
continue
end = start + generator.model.fraction(note['duration'])
cuts = [start, *times[bisect.bisect_right(times, start):bisect.bisect_left(times, end)], end]
pieces = []
for index, (a, b) in enumerate(zip(cuts, cuts[1:])):
fragment = copy.deepcopy(note)
fragment['id'] = note['id'] if index == 0 else f'{note["id"]}_h{index}'
fragment['onset'] = generator.model.rational(generator.model.fraction(note['onset']) + a - start)
fragment['duration'] = generator.model.rational(b - a)
if len(cuts) > 2:
fragment['written'] = generator.model.written_for(b - a)
fragment.pop('beams', None)
for tone in fragment['pitches']:
original = tone['id']
tone['id'] = original if index == 0 else f'{original}_h{index}'
tone['duration'], tone['written'] = fragment['duration'], copy.deepcopy(fragment['written'])
tone.pop('tie_from', None)
tone.pop('tie_to', None)
tone_pieces.setdefault(original, []).append((tone, a))
if index:
fragment.pop('lyrics', None)
current = bisect.bisect_right(times, a) - 1
chord = changes[current][1] if current >= 0 else None
if chord:
fragment.update(_harmony=chord, _guitar_profile='electric', _harmonize=True)
pieces.append(fragment)
replacements[note['id']] = pieces
for identifier, pieces in tone_pieces.items():
original = tones[identifier]
links = list(zip(pieces, pieces[1:]))
if source := original.get('tie_from'):
previous_piece = tone_pieces[source][-1] if source in tone_pieces else (tones[source], None)
links.insert(0, (previous_piece, pieces[0]))
previous_piece[0].pop('tie_to', None)
if target := original.get('tie_to'):
if target not in tone_pieces:
pieces[-1][0]['tie_to'] = target
tones[target]['tie_from'] = pieces[-1][0]['id']
for (first, _), (second, start) in links:
if start not in reattacks:
first['tie_to'], second['tie_from'] = second['id'], first['id']
by_tone = {p['id']: note for original, _ in events for note in replacements.get(original['id'], [original]) for p in note['pitches']}
for measure in staff['measures']:
for voice in measure['voices']:
voice['notes'] = [piece for note in voice['notes'] for piece in replacements.get(note['id'], [note])]
for note in voice['notes']:
if note.get('_harmonize') and (source := note['pitches'][0].get('tie_from')):
earlier = by_tone[source]
if generator.accompaniment.same_chord(earlier.get('_harmony'), note['_harmony']):
note['_rhythm_continuation'] = True
generator.accompaniment.normalize_beams(voice['notes'])
for tuplet in staff.get('tuplets', []):
if tuplet['end'] in replacements:
tuplet['end'] = replacements[tuplet['end']][-1]['id']
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
import copy
import fractions
import typing
MAX_INTEGER = (1 << 63) - 1
NOTE_TYPES = ('maxima', 'long', 'breve', 'whole', 'half', 'quarter', 'eighth', '16th', '32nd', '64th', '128th', '256th', '512th', '1024th')
TYPE_LENGTHS = {name: fractions.Fraction(32, 2 ** index) for index, name in enumerate(NOTE_TYPES)}
NATURAL = {'c': 0, 'd': 2, 'e': 4, 'f': 5, 'g': 7, 'a': 9, 'b': 11}
LETTERS = 'cdefgab'
MODE_STEPS = {'major': 0, 'ionian': 0, 'dorian': 1, 'phrygian': 2, 'lydian': 3, 'mixolydian': 4, 'minor': 5, 'aeolian': 5, 'locrian': 6}
ACCIDENTALS = {'natural': fractions.Fraction(0), 'sharp': fractions.Fraction(1), 'flat': fractions.Fraction(-1), 'double-sharp': fractions.Fraction(2), 'sharp-sharp': fractions.Fraction(2), 'flat-flat': fractions.Fraction(-2), 'triple-sharp': fractions.Fraction(3), 'triple-flat': fractions.Fraction(-3), 'natural-sharp': fractions.Fraction(1), 'natural-flat': fractions.Fraction(-1), 'quarter-sharp': fractions.Fraction(1, 2), 'quarter-flat': fractions.Fraction(-1, 2), 'three-quarters-sharp': fractions.Fraction(3, 2), 'three-quarters-flat': fractions.Fraction(-3, 2)}
def accidental_name(value: fractions.Fraction) -> str:
for name, amount in ACCIDENTALS.items():
if amount == value:
return name
return 'other'
def rational(value: fractions.Fraction | int) -> dict[str, int]:
value = fractions.Fraction(value)
if abs(value.numerator) > MAX_INTEGER or value.denominator > MAX_INTEGER:
raise ValueError('musical time exceeds the supported 64-bit rational range')
return {'numerator': value.numerator, 'denominator': value.denominator}
def fraction(value: typing.Any) -> fractions.Fraction:
if not isinstance(value, dict) or set(value) != {'numerator', 'denominator'}:
raise ValueError('musical time must be a numerator/denominator object')
n, d = value['numerator'], value['denominator']
if type(n) is not int or type(d) is not int or d <= 0 or abs(n) > MAX_INTEGER or d > MAX_INTEGER:
raise ValueError('invalid musical time')
result = fractions.Fraction(n, d)
if rational(result) != value:
raise ValueError('musical time must be reduced')
return result
def written_length(written: dict) -> fractions.Fraction:
kind, dots = written['type'], written.get('dots', 0)
if kind not in TYPE_LENGTHS or type(dots) is not int or not 0 <= dots <= 16:
raise ValueError('invalid written duration or augmentation-dot count (maximum 16)')
return TYPE_LENGTHS[kind] * (2 - fractions.Fraction(1, 2 ** dots))
def written_for(length: fractions.Fraction) -> dict:
for name, base in TYPE_LENGTHS.items():
for dots in range(17):
if length == base * (2 - fractions.Fraction(1, 2 ** dots)):
return {'type': name, 'dots': dots}
# Generated rational spans need an explicit time modification, not rounded ticks.
base = next((name for name, size in TYPE_LENGTHS.items() if size <= length), '1024th')
return {'type': base, 'dots': 0, 'ratio': rational(length / TYPE_LENGTHS[base])}
def metric_length(written: dict) -> fractions.Fraction:
ratio = fraction(written.get('ratio', rational(1)))
if ratio <= 0:
raise ValueError('written duration ratio must be positive')
if 'normal' in written:
written_length(written['normal'])
return written_length(written) * ratio
def alteration(value: int | dict) -> fractions.Fraction:
return fractions.Fraction(value) if type(value) is int else fraction(value)
def pitch_number(pitch: dict) -> fractions.Fraction:
letter = pitch['letter'].lower()
alter, octave = alteration(pitch.get('alter', 0)), pitch['octave']
if letter not in NATURAL or type(octave) is not int or not -4 <= octave <= 16:
raise ValueError('invalid spelled pitch')
return 12 * (octave + 1) + NATURAL[letter] + alter
def pitch_name(pitch: dict) -> str:
alter = alteration(pitch.get('alter', 0))
suffix = ('#' * int(alter) if alter > 0 else 'b' * int(-alter)) if alter.denominator == 1 else f'({alter:+} semitones)'
return pitch['letter'].upper() + suffix
def parse_pitch_name(name: str) -> dict:
if not name or name[0].lower() not in NATURAL or any(c not in '#b' for c in name[1:]):
raise ValueError(f'invalid pitch name: {name!r}')
return {'letter': name[0].lower(), 'alter': rational(name[1:].count('#') - name[1:].count('b'))}
def key_from_fifths(fifths: int, mode: str = 'major') -> dict:
if mode not in MODE_STEPS and mode != 'none':
raise ValueError(f'unsupported mode: {mode}')
if not -14 <= fifths <= 14:
raise ValueError('key signatures support up to fourteen fifths in either direction')
degree = fifths * 4 + MODE_STEPS.get(mode, 0)
letter = LETTERS[degree % 7]
signature = [0] * 7
order = 'fcgdaeb' if fifths > 0 else 'beadgcf'
for i in range(abs(fifths)):
signature[LETTERS.index(order[i % 7])] += 1 if fifths > 0 else -1
tonic = {'letter': letter, 'alter': rational(signature[LETTERS.index(letter)])}
return {'fifths': fifths, 'mode': mode, 'tonic': tonic if mode != 'none' else None, 'alterations': [rational(v) for v in signature]}
def key_label(key: dict) -> str:
if not key.get('tonic'):
return 'No key'
label = pitch_name(key['tonic'])
return label if key['mode'] in ('major', 'ionian') else label + ' ' + key['mode']
def meter_length(meter: dict) -> fractions.Fraction:
return sum((fractions.Fraction(sum(group['beats']) * 4, group['unit']) for group in meter.get('groups', [])), fractions.Fraction())
def meter_label(meter: dict) -> str:
if meter.get('unmetered'):
return 'senza misura'
return '+'.join('+'.join(map(str, group['beats'])) + '/' + str(group['unit']) for group in meter['groups'])
def beat_lengths(meter: dict) -> list[fractions.Fraction]:
result = []
for group in meter.get('groups', []):
beats, unit = group['beats'], group['unit']
if len(beats) > 1:
result.extend(fractions.Fraction(b * 4, unit) for b in beats)
elif beats[0] > 3 and beats[0] % 3 == 0 and unit >= 8:
result.extend([fractions.Fraction(12, unit)] * (beats[0] // 3))
else:
result.extend([fractions.Fraction(4, unit)] * beats[0])
return result
def validate_meter(meter: dict) -> None:
groups = meter.get('groups', [])
if not groups and not meter.get('unmetered'):
raise ValueError('meter requires beat groups or senza misura')
for group in groups:
if type(group['unit']) is not int or not 0 < group['unit'] <= 4096 or not group['beats']:
raise ValueError('meter unit must be between 1 and 4096 with nonempty beats')
if any(type(b) is not int or not 0 < b <= 1024 for b in group['beats']):
raise ValueError('meter count must be between 1 and 1024')
def make_event(identifier: str, onset: fractions.Fraction, duration: fractions.Fraction, kind: str, pitches: list[dict] | None = None, written: dict | None = None) -> dict:
if duration <= 0:
raise ValueError('generated event duration must be positive')
notation = written or written_for(duration)
event = {'id': identifier, 'kind': kind, 'onset': rational(onset), 'duration': rational(duration), 'written': notation, 'hidden': False, 'pitches': []}
for i, p in enumerate(pitches or []):
event['pitches'].append({**copy.deepcopy(p), 'id': f'{identifier}_p{i + 1}', 'duration': rational(duration), 'written': copy.deepcopy(notation)})
if 'alter' in event['pitches'][-1]:
event['pitches'][-1]['alter'] = rational(alteration(p['alter']))
return event
+635
View File
@@ -0,0 +1,635 @@
from __future__ import annotations
import copy
import fractions
import pathlib
import defusedxml.ElementTree
import generator.harmony
import generator.model
import generator.validation
class MusicXMLError(ValueError):
pass
# Ignoring a container never authorizes ignoring its musical descendants.
LAYOUT = {'default-x', 'default-y', 'relative-x', 'relative-y', 'color', 'font-family', 'font-size', 'font-style', 'font-weight', 'halign', 'valign', 'justify', 'placement', 'id'}
IGNORED_TOP = {'defaults', 'credit', 'work', 'movement-number', 'movement-title', 'identification', 'part-list'}
class Importer:
def __init__(self, path: pathlib.Path) -> None:
self.path = path
self.location = str(path)
self.divisions = fractions.Fraction(1)
self.key = generator.model.key_from_fifths(0)
self.meter = {'groups': [{'beats': [4], 'unit': 4}], 'symbol': 'normal'}
self.clef = {'sign': 'G', 'line': 2, 'octave_change': 0}
self.transpose = {'diatonic': 0, 'chromatic': 0, 'octave_change': 0}
self.sequence = 0
self.harmony_sequence = 0
self.tuplet_sequence = 0
self.active_tuplets: dict[str, dict] = {}
self.tuplets: list[dict] = []
self.open_ties: dict[int, dict] = {}
self.open_octaves: dict[str, dict] = {}
self.octaves: list[dict] = []
self.voice_id = '1'
self.staff_details = {'lines': 5, 'tunings': []}
self.absolute = fractions.Fraction()
self.tremolo_starts = {}
def fail(self, text: str):
raise MusicXMLError(f'{self.location}: {text}')
def children(self, node, allowed: set[str]) -> None:
for item in node:
if item.tag not in allowed:
self.fail(f'unsupported musical element <{item.tag}> in <{node.tag}>')
def attrs(self, node, allowed=()) -> None:
for name in node.attrib:
if name not in LAYOUT and name not in allowed:
self.fail(f'unsupported {node.tag} attribute {name!r}')
def number(self, value: str | None, *, integer: bool = False, positive: bool = False) -> fractions.Fraction | int:
try:
result = fractions.Fraction(value or '')
if integer and result.denominator != 1 or positive and result <= 0:
raise ValueError()
generator.model.rational(result)
return int(result) if integer else result
except (ValueError, ZeroDivisionError, OverflowError):
self.fail(f'invalid {"integer" if integer else "number"}: {value!r}')
def offset(self, node, cursor: fractions.Fraction) -> fractions.Fraction:
return cursor + self.number(node.findtext('offset', '0')) / self.divisions
def identity(self, node) -> None:
voice = node.findtext('voice', '1' if node.tag in ('note', 'forward') else self.voice_id)
staff = node.findtext('staff', '1')
if staff != '1':
self.fail('multiple staves are not supported')
if voice is None:
return
if not voice.strip():
self.fail('empty voice identifier')
self.voice_id = voice
def attributes(self, node, onset: fractions.Fraction) -> dict:
self.children(node, {'divisions', 'key', 'time', 'clef', 'staves', 'transpose', 'instruments', 'staff-details'})
result = {'onset': generator.model.rational(onset)}
for item in node:
if item.get('number', '1') != '1':
self.fail('attributes for multiple staves are not supported')
if item.tag == 'divisions':
self.divisions = self.number(item.text, positive=True)
elif item.tag in ('staves', 'instruments'):
if item.text != '1':
self.fail(f'multiple {item.tag} are not supported')
elif item.tag == 'key':
self.attrs(item, {'number', 'print-object'})
self.children(item, {'cancel', 'fifths', 'mode', 'key-step', 'key-alter', 'key-accidental', 'key-octave'})
if item.find('fifths') is not None:
self.key = generator.model.key_from_fifths(self.number(item.findtext('fifths'), integer=True), item.findtext('mode', 'major'))
else:
self.key = {'fifths': 0, 'mode': 'none', 'tonic': None, 'alterations': [generator.model.rational(0) for _ in range(7)], 'custom': True, 'steps': []}
pending = None
for field in item:
if field.tag == 'key-step':
pending = (field.text or '').lower()
if pending not in 'cdefgab' or len(pending) != 1:
self.fail('invalid custom key step')
elif field.tag == 'key-alter':
if pending is None:
self.fail('key alteration without a step')
amount = generator.model.rational(self.number(field.text))
self.key['alterations']['cdefgab'.index(pending)] = amount
self.key['steps'].append({'letter': pending, 'alter': amount})
pending = None
elif field.tag == 'key-accidental':
if not self.key['steps']:
self.fail('key accidental without a preceding step')
self.key['steps'][-1]['accidental'] = field.text
elif field.tag == 'key-octave':
number = self.number(field.get('number'), integer=True, positive=True)
if number > len(self.key['steps']):
self.fail('key octave refers to an absent step')
self.key['steps'][number - 1]['octave'] = self.number(field.text, integer=True)
if pending is not None:
self.fail('custom key step without alteration')
if item.find('cancel') is not None:
self.key['cancel'] = self.number(item.findtext('cancel'), integer=True)
result['key'] = copy.deepcopy(self.key)
elif item.tag == 'time':
self.attrs(item, {'number', 'symbol', 'separator', 'print-object'})
self.children(item, {'beats', 'beat-type', 'senza-misura'})
self.meter = {'groups': [], 'symbol': item.get('symbol', 'normal')}
if self.meter['symbol'] not in ('normal', 'common', 'cut', 'single-number', 'note', 'dotted-note'):
self.fail('unsupported meter symbol')
if item.find('senza-misura') is not None:
self.meter['unmetered'] = True
else:
pending = None
for field in item:
if field.tag == 'beats':
if pending is not None:
self.fail('beats without beat-type')
pending = [self.number(v, integer=True, positive=True) for v in (field.text or '').split('+')]
elif field.tag == 'beat-type':
if not pending:
self.fail('beat-type without beats')
self.meter['groups'].append({'beats': pending, 'unit': self.number(field.text, integer=True, positive=True)})
pending = None
if pending is not None or not self.meter['groups']:
self.fail('incomplete meter')
result['meter'] = copy.deepcopy(self.meter)
elif item.tag == 'clef':
self.attrs(item, {'number', 'additional', 'size', 'after-barline', 'print-object'})
self.children(item, {'sign', 'line', 'clef-octave-change'})
sign = item.findtext('sign')
if sign == 'TAB':
self.fail('authored TAB is unsupported; guitar tablature is generated from pitches')
if sign not in ('G', 'F', 'C', 'percussion', 'TAB', 'none'):
self.fail(f'unsupported clef {sign!r}')
line = self.number(item.findtext('line', {'G': '2', 'F': '4'}.get(sign, '3')), integer=True)
if not 1 <= line <= 6:
self.fail('clef line must be between 1 and 6')
self.clef = {'sign': sign, 'line': line, 'octave_change': self.number(item.findtext('clef-octave-change', '0'), integer=True)}
result['clef'] = copy.deepcopy(self.clef)
elif item.tag == 'staff-details':
self.children(item, {'staff-lines', 'staff-tuning', 'capo'})
self.staff_details = {'lines': self.number(item.findtext('staff-lines', '5'), integer=True, positive=True), 'tunings': [], 'capo': self.number(item.findtext('capo', '0'), integer=True)}
for tuning in item.findall('staff-tuning'):
self.children(tuning, {'tuning-step', 'tuning-alter', 'tuning-octave'})
self.staff_details['tunings'].append({'line': self.number(tuning.get('line'), integer=True, positive=True), 'letter': tuning.findtext('tuning-step', '').lower(), 'alter': generator.model.rational(self.number(tuning.findtext('tuning-alter', '0'))), 'octave': self.number(tuning.findtext('tuning-octave'), integer=True)})
result['staff_details'] = copy.deepcopy(self.staff_details)
elif item.tag == 'transpose':
self.children(item, {'diatonic', 'chromatic', 'octave-change'})
self.transpose = {key: self.number(item.findtext(tag, '0'), integer=True) for key, tag in (('diatonic', 'diatonic'), ('chromatic', 'chromatic'), ('octave_change', 'octave-change'))}
self.transpose['explicit'] = True
if item.find('chromatic') is None:
self.fail('instrument transposition requires chromatic semitones')
if item.find('diatonic') is None:
semitones = self.transpose['chromatic']
magnitude = abs(semitones)
self.transpose['diatonic'] = (1 if semitones >= 0 else -1) * (7 * (magnitude // 12) + (0, 0, 1, 2, 2, 3, 3, 4, 5, 5, 6, 6)[magnitude % 12])
result['transpose'] = copy.deepcopy(self.transpose)
return result
def read_harmony(self, node, cursor: fractions.Fraction) -> dict:
self.attrs(node, {'print-object', 'print-frame', 'type'})
self.children(node, {'root', 'kind', 'degree', 'bass', 'inversion', 'offset', 'staff'})
if len(node.findall('kind')) != 1 or len(node.findall('root')) > 1:
self.fail('stacked harmonies require separate harmony events')
self.identity(node)
kind = node.find('kind')
self.harmony_sequence += 1
result = {'id': f'harmony_{self.harmony_sequence}', 'onset': generator.model.rational(self.offset(node, cursor)), 'kind': kind.text, 'degrees': []}
self.attrs(kind, {'text', 'use-symbols', 'stack-degrees', 'parentheses-degrees', 'bracket-degrees'})
for tag in ('root', 'bass'):
item = node.find(tag)
if item is None:
if tag == 'root' and kind.text != 'none':
self.fail('pitched harmony requires a root')
continue
self.children(item, {tag + '-step', tag + '-alter'})
step = item.findtext(tag + '-step', '').lower()
if len(step) != 1 or step not in 'cdefgab':
self.fail('invalid harmony root/bass')
result[tag] = {'letter': step, 'alter': generator.model.rational(self.number(item.findtext(tag + '-alter', '0')))}
for degree in node.findall('degree'):
self.children(degree, {'degree-value', 'degree-alter', 'degree-type'})
value = self.number(degree.findtext('degree-value'), integer=True, positive=True)
degree_alter = self.number(degree.findtext('degree-alter', '0'))
operation = degree.findtext('degree-type')
# MusicXML alterations are relative to the kind; additions use dominant intervals.
if operation == 'alter':
degree_alter += generator.harmony.KINDS.get(result['kind'], {}).get(value, 0)
elif operation == 'add' and (value - 1) % 7 == 6:
degree_alter -= 1
elif operation == 'subtract' and degree_alter:
self.fail('cannot subtract a degree different from the one in the chord kind')
result['degrees'].append({'value': value, 'alter': generator.model.rational(degree_alter), 'type': operation})
if kind.get('text') and result['kind'] != 'none':
generator.harmony.normalize_text(result, kind.get('text'))
chord_degrees = generator.harmony.degrees(result)
if node.find('inversion') is not None:
inversion = self.number(node.findtext('inversion'), integer=True)
result['inversion'] = inversion
if not 0 <= inversion < len(chord_degrees):
self.fail('invalid chord inversion')
if inversion and not result.get('bass'):
degree = sorted(chord_degrees)[inversion]
root = result['root']
letter_index = 'cdefgab'.index(root['letter']) + degree - 1
bass_letter = 'cdefgab'[letter_index % 7]
target = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter']) + generator.harmony.degree_semitones(degree, chord_degrees[degree])
result['bass'] = {'letter': bass_letter, 'alter': generator.model.rational(target - (12 * (letter_index // 7) + generator.model.NATURAL[bass_letter]))}
result['symbol'] = generator.harmony.symbol(result)
return result
def direction(self, node, cursor: fractions.Fraction, measure: dict, optional: list[fractions.Fraction]) -> None:
self.attrs(node, {'print-object', 'directive', 'system'})
self.children(node, {'direction-type', 'offset', 'staff', 'voice', 'sound'})
onset = self.offset(node, cursor)
for group in node.findall('direction-type'):
if group.find('segno') is not None or group.find('coda') is not None:
self.fail('navigation jumps are unsupported; write the music out in reading order')
self.children(group, {'words', 'octave-shift', 'rehearsal', 'metronome'})
for item in group:
if item.tag == 'words':
value = ''.join(item.itertext()).strip()
if value == 'vamp:optional-chord':
optional.append(onset)
elif value:
self.fail(f'unsupported staff text {value!r}; only Vamp directives are accepted')
elif item.tag == 'rehearsal':
if onset != 0:
self.fail('section labels must be at measure starts')
measure['section_label'] = ''.join(item.itertext())
elif item.tag == 'octave-shift':
number, action = item.get('number', '1'), item.get('type')
size = self.number(item.get('size', '8'), integer=True)
if size not in (8, 15, 22):
self.fail('octave shifts support 8, 15, or 22')
position = generator.model.rational(self.absolute + onset)
if action in ('up', 'down'):
if number in self.open_octaves:
self.fail('duplicate octave-shift start')
span = {'id': f'octave_{len(self.octaves) + 1}', 'start': position, 'octaves': (size - 1) // 7 * (1 if action == 'up' else -1)}
self.open_octaves[number] = span
self.octaves.append(span)
elif action == 'stop':
if number not in self.open_octaves:
self.fail('octave-shift stop without start')
self.open_octaves.pop(number)['end'] = position
elif action != 'continue' or number not in self.open_octaves:
self.fail('invalid octave-shift continuation')
elif item.tag == 'metronome':
self.children(item, {'beat-unit', 'beat-unit-dot', 'per-minute'})
written = {'type': item.findtext('beat-unit'), 'dots': len(item.findall('beat-unit-dot'))}
generator.model.written_length(written)
bpm = self.number(item.findtext('per-minute'), positive=True)
measure.setdefault('tempos', []).append({'onset': generator.model.rational(onset), 'written': written, 'bpm': generator.model.rational(bpm)})
sound = node.find('sound')
if sound is not None:
self.sound(sound, onset, measure)
def sound(self, node, onset: fractions.Fraction, measure: dict) -> None:
if {'dacapo', 'dalsegno', 'segno', 'coda', 'tocoda', 'fine', 'forward-repeat', 'time-only'} & node.attrib.keys():
self.fail('navigation jumps are unsupported; write the music out in reading order')
self.attrs(node, {'tempo'})
self.children(node, set())
if node.get('tempo') and not any(generator.model.fraction(t['onset']) == onset for t in measure.get('tempos', [])):
measure.setdefault('tempos', []).append({'onset': generator.model.rational(onset), 'written': {'type': 'quarter', 'dots': 0}, 'bpm': generator.model.rational(self.number(node.get('tempo'), positive=True))})
def read_note(self, node, cursor: fractions.Fraction, previous: dict | None) -> tuple[dict, fractions.Fraction, bool]:
self.attrs(node, {'print-object', 'print-dot', 'print-spacing', 'default-x', 'dynamics', 'end-dynamics'})
if node.get('dynamics') or node.get('end-dynamics'):
self.fail('note dynamics are not supported')
self.children(node, {'pitch', 'unpitched', 'instrument', 'rest', 'chord', 'duration', 'voice', 'type', 'dot', 'accidental', 'time-modification', 'stem', 'staff', 'beam', 'notations', 'lyric', 'tie', 'grace', 'cue', 'notehead'})
for tag in ('pitch', 'rest', 'chord', 'duration', 'voice', 'type', 'accidental', 'time-modification', 'stem', 'staff', 'grace', 'cue', 'notehead'):
if len(node.findall(tag)) > 1:
self.fail(f'duplicate <{tag}> in <note>')
if node.find('rest') is not None and node.find('pitch') is not None:
self.fail('note cannot contain both pitch and rest')
self.identity(node)
voice = self.voice_id
is_chord = node.find('chord') is not None
grace = node.find('grace')
duration = fractions.Fraction() if grace is not None else self.number(node.findtext('duration'), positive=True) / self.divisions
onset = generator.model.fraction(previous['onset']) if is_chord and previous else cursor
if is_chord and (previous is None or previous['voice'] != voice or previous['kind'] != 'note' or duration > generator.model.fraction(previous['duration'])):
self.fail('invalid chord tone or chord tone longer than its anchor')
if is_chord and (grace is not None) != ('grace' in previous):
self.fail('chord tones must share grace/regular timing')
written = {'type': node.findtext('type'), 'dots': len(node.findall('dot'))}
if written['type'] is None:
written = {'type': 'whole', 'dots': 0} if node.find('rest') is not None and node.find('rest').get('measure') == 'yes' else generator.model.written_for(duration)
generator.model.written_length(written)
modulation = node.find('time-modification')
if modulation is not None:
self.children(modulation, {'actual-notes', 'normal-notes', 'normal-type', 'normal-dot'})
actual = self.number(modulation.findtext('actual-notes'), integer=True, positive=True)
normal = self.number(modulation.findtext('normal-notes'), integer=True, positive=True)
written['ratio'] = generator.model.rational(fractions.Fraction(normal, actual))
if modulation.find('normal-type') is not None:
written['normal'] = {'type': modulation.findtext('normal-type'), 'dots': len(modulation.findall('normal-dot'))}
generator.model.written_length(written['normal'])
elif modulation.findall('normal-dot'):
self.fail('<normal-dot> requires <normal-type>')
measure_rest = node.find('rest') is not None and node.find('rest').get('measure') == 'yes'
if grace is None and not measure_rest:
expected = generator.model.written_length(written) * generator.model.fraction(written.get('ratio', generator.model.rational(1)))
if duration != expected:
self.fail(f'<note> duration {duration} disagrees with written duration/time-modification {expected}')
self.sequence += 1
note = {'id': f'note_{self.sequence}', 'voice': voice, 'kind': 'rest' if node.find('rest') is not None else 'note', 'onset': generator.model.rational(onset), 'duration': generator.model.rational(duration), 'written': written, 'hidden': node.get('print-object') == 'no', 'pitches': []}
if is_chord:
note = previous
if grace is not None and not is_chord:
self.attrs(grace, {'slash', 'steal-time-previous', 'steal-time-following', 'make-time'})
note['grace'] = {'slash': grace.get('slash') == 'yes'}
for name in ('steal-time-previous', 'steal-time-following', 'make-time'):
if name in grace.attrib:
value = self.number(grace.get(name), positive=True)
note['grace'][name] = generator.model.rational(value / self.divisions if name == 'make-time' else value)
if node.find('cue') is not None and not is_chord:
note['cue'] = True
if node.find('rest') is not None:
self.children(node.find('rest'), {'display-step', 'display-octave'})
if node.find('rest').get('measure') == 'yes':
note['measure_rest'] = True
if node.find('rest/display-step') is not None:
note['rest_position'] = {'letter': node.findtext('rest/display-step').lower(), 'octave': self.number(node.findtext('rest/display-octave'), integer=True)}
else:
pitch_node = node.find('pitch')
p = {'id': f'{note["id"]}_p{len(note["pitches"]) + 1}', 'duration': generator.model.rational(duration), 'written': written}
if pitch_node is not None:
self.children(pitch_node, {'step', 'alter', 'octave'})
p.update({'letter': pitch_node.findtext('step', '').lower(), 'alter': generator.model.rational(self.number(pitch_node.findtext('alter', '0'))), 'octave': self.number(pitch_node.findtext('octave'), integer=True)})
generator.model.pitch_number(p)
value = (voice, p['letter'], generator.model.alteration(p['alter']), p['octave'])
elif node.find('unpitched') is not None:
self.children(node.find('unpitched'), {'display-step', 'display-octave'})
p['unpitched'] = {'letter': node.findtext('unpitched/display-step', 'B').lower(), 'octave': self.number(node.findtext('unpitched/display-octave', '4'), integer=True)}
p['instrument'] = node.find('instrument').get('id') if node.find('instrument') is not None else None
value = (voice, p['instrument'], p['unpitched']['letter'], p['unpitched']['octave'])
else:
self.fail('note has neither pitch, unpitched position, nor rest')
if node.find('instrument') is not None:
self.attrs(node.find('instrument'), {'id'})
p['instrument'] = node.find('instrument').get('id')
for name in ('print-object', 'print-dot', 'print-spacing'):
if name in node.attrib:
p.setdefault('print', {})[name] = node.get(name)
if node.find('cue') is not None:
p['cue'] = True
if node.find('stem') is not None:
p['stem'] = node.findtext('stem')
p['_absolute'] = self.absolute + onset
accidental = node.find('accidental')
if accidental is not None:
self.attrs(accidental, {'cautionary', 'editorial', 'parentheses', 'bracket', 'size', 'smufl'})
if 'unpitched' in p or accidental.text in generator.model.ACCIDENTALS and generator.model.ACCIDENTALS[accidental.text] != generator.model.alteration(p['alter']):
self.fail('contradictory accidental')
p['accidental_display'] = {'name': accidental.text, **{k: v for k, v in accidental.attrib.items() if k not in LAYOUT}}
if node.find('notehead') is not None:
p['notehead'] = {'name': node.findtext('notehead'), **dict(node.find('notehead').attrib)}
ties = {t.get('type') for t in node.findall('tie')}
for tie in node.findall('tie'):
self.attrs(tie, {'type', 'time-only'})
if 'time-only' in tie.attrib:
self.fail('pass-specific ties are unsupported; write the music out in reading order')
notations = node.find('notations')
visual_ties = set()
if notations is not None:
visual_ties = {t.get('type') for t in notations.findall('tied')}
if 'continue' in visual_ties:
visual_ties.remove('continue')
visual_ties.update({'start', 'stop'})
ties |= visual_ties
if ties - {'start', 'stop'}:
self.fail('unsupported tie notation')
if 'stop' in ties:
source = self.open_ties.pop(value, None)
if source is None or source['_absolute'] + generator.model.fraction(source['duration']) != p['_absolute']:
self.fail('tie stop has no contiguous matching start')
source['tie_to'], p['tie_from'] = p['id'], source['id']
if 'start' in ties:
if value in self.open_ties:
self.fail('ambiguous duplicate tie at the same pitch')
self.open_ties[value] = p
note['pitches'].append(p)
if not is_chord:
if node.find('stem') is not None:
stem = node.findtext('stem')
if stem not in ('up', 'down', 'none', 'double'):
self.fail('unsupported stem')
note['stem'] = stem
head = node.findtext('notehead')
if head is not None:
note['notehead'] = head
beams = []
for beam in node.findall('beam'):
self.attrs(beam, {'number', 'repeater'})
if beam.get('repeater') == 'yes':
self.fail('repeater beams are not supported')
level = self.number(beam.get('number', '1'), integer=True)
if not 1 <= level <= 8 or beam.text not in ('begin', 'continue', 'end', 'forward hook', 'backward hook'):
self.fail('invalid beam')
beams.append({'level': level, 'type': beam.text})
if beams:
note['beams'] = beams
for lyric in node.findall('lyric'):
self.children(lyric, {'syllabic', 'text', 'elision', 'extend', 'end-line', 'end-paragraph'})
number = lyric.get('number', '1')
if any(v['number'] == number for v in note.get('lyrics', [])):
self.fail('duplicate lyric verse on one event')
pieces = []
for item in lyric:
if item.tag in ('text', 'elision'):
pieces.append(item.text or (' ' if item.tag == 'elision' else ''))
syllabic = lyric.findtext('syllabic', 'single')
if syllabic not in ('single', 'begin', 'middle', 'end'):
self.fail('invalid lyric syllabification')
verse = {'number': number, 'syllables': [{'kind': child.tag, 'text': child.text or '', 'attributes': dict(child.attrib)} for child in lyric]}
note.setdefault('lyrics', []).append(verse)
if len(note['lyrics']) == 1:
note['lyric'] = ''.join(pieces) + ('-' if syllabic in ('begin', 'middle') else '')
note['lyric_notation'] = {'text': ''.join(pieces), 'syllabic': syllabic}
if lyric.find('extend') is not None:
note['lyric_notation']['extend'] = lyric.find('extend').get('type', 'start')
stops = []
for notations in node.findall('notations'):
self.children(notations, {'tied', 'tuplet', 'ornaments', 'technical'})
for item in notations:
if item.tag == 'technical':
self.fail('authored TAB and finger instructions are unsupported; guitar positions are generated')
if item.tag == 'tuplet' and not is_chord:
number, action = (voice, item.get('number', '1')), item.get('type')
self.attrs(item, {'number', 'type', 'bracket', 'show-number', 'show-type', 'line-shape'})
self.children(item, {'tuplet-actual', 'tuplet-normal'})
if action == 'start':
if number in self.active_tuplets:
self.fail('duplicate tuplet start')
self.tuplet_sequence += 1
actual = item.findtext('tuplet-actual/tuplet-number', modulation.findtext('actual-notes') if modulation is not None else '')
normal = item.findtext('tuplet-normal/tuplet-number', modulation.findtext('normal-notes') if modulation is not None else '')
active = [t for (v, _), t in self.active_tuplets.items() if v == voice]
if active and (item.find('tuplet-actual') is None or item.find('tuplet-normal') is None):
self.fail('nested tuplets require explicit tuplet-actual and tuplet-normal numbers')
span = {'id': f'tuplet_{self.tuplet_sequence}', 'voice': voice, 'start': note['id'], 'note_count': self.number(actual, integer=True, positive=True), 'space_count': self.number(normal, integer=True, positive=True), 'depth': len(active), 'bracket': item.get('bracket', 'auto'), 'show_number': item.get('show-number', 'actual'), 'show_type': item.get('show-type', 'none'), 'line_shape': item.get('line-shape', 'straight')}
for tag in ('tuplet-actual', 'tuplet-normal'):
detail = item.find(tag)
if detail is not None:
self.attrs(detail)
self.children(detail, {'tuplet-number', 'tuplet-type', 'tuplet-dot'})
span[tag.replace('-', '_')] = {'number': self.number(detail.findtext('tuplet-number'), integer=True, positive=True), 'type': detail.findtext('tuplet-type'), 'dots': len(detail.findall('tuplet-dot'))}
self.active_tuplets[number] = span
self.tuplets.append(span)
elif action == 'stop':
stops.append(number)
else:
self.fail('invalid tuplet type')
elif item.tag == 'ornaments':
self.children(item, {'tremolo'})
for tremolo in item:
marks = self.number(tremolo.text, integer=True)
if not 0 <= marks <= 8 or tremolo.get('type', 'single') == 'unmeasured':
self.fail('only measured tremolos are supported')
action = tremolo.get('type', 'single')
note['tremolo'] = {'marks': marks, 'type': action}
if action == 'start':
if voice in self.tremolo_starts:
self.fail('nested tremolo')
self.tremolo_starts[voice] = note
elif action == 'stop':
if voice not in self.tremolo_starts:
self.fail('tremolo stop without start')
partner = self.tremolo_starts.pop(voice)
note['tremolo']['partner'] = partner['id']
partner['tremolo']['partner'] = note['id']
elif action != 'single':
self.fail('unsupported tremolo type')
if not is_chord:
note['tuplet_ids'] = [t['id'] for (v, _), t in self.active_tuplets.items() if v == voice]
for number in stops:
if number not in self.active_tuplets:
self.fail('tuplet stop without start')
self.active_tuplets.pop(number)['end'] = note['id']
return note, cursor if is_chord else cursor + duration, is_chord
def read(self) -> dict:
root = defusedxml.ElementTree.parse(self.path, forbid_entities=True, forbid_external=True).getroot()
if root.tag != 'score-partwise' or root.get('version') not in ('3.1', '4.0'):
self.fail('expected uncompressed score-partwise MusicXML 3.1 or 4.0')
self.children(root, IGNORED_TOP | {'part'})
parts = root.findall('part')
definitions = root.findall('part-list/score-part')
if len(parts) != 1 or len(definitions) != 1 or parts[0].get('id') != definitions[0].get('id'):
self.fail('exactly one matching score part is required')
instruments = []
for item in definitions[0].findall('score-instrument'):
self.attrs(item, {'id'})
self.children(item, {'instrument-name', 'instrument-abbreviation', 'instrument-sound', 'solo', 'ensemble', 'virtual-instrument'})
instrument = {'id': item.get('id'), 'name': item.findtext('instrument-name', ''), 'abbreviation': item.findtext('instrument-abbreviation', ''), 'sound': item.findtext('instrument-sound', '')}
if item.find('solo') is not None:
instrument['solo'] = True
if item.find('ensemble') is not None:
instrument['ensemble'] = item.findtext('ensemble')
if item.find('virtual-instrument') is not None:
virtual = item.find('virtual-instrument')
self.children(virtual, {'virtual-library', 'virtual-name'})
instrument['virtual'] = {child.tag: child.text or '' for child in virtual}
midi = next((m for m in definitions[0].findall('midi-instrument') if m.get('id') == instrument['id']), None)
if midi is not None:
self.children(midi, {'midi-channel', 'midi-name', 'midi-bank', 'midi-program', 'midi-unpitched', 'volume', 'pan', 'elevation'})
instrument['midi'] = {child.tag: child.text for child in midi}
instruments.append(instrument)
if any(item.get('id') not in {i['id'] for i in instruments} for item in definitions[0].findall('midi-instrument')):
self.fail('MIDI definition refers to an unknown score instrument')
for item in root.findall('identification/miscellaneous/miscellaneous-field'):
name = item.get('name', '')
if name.startswith('vamp:'):
self.fail(f'unsupported Vamp property {name!r}')
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 = []
self.children(parts[0], {'measure'})
for index, bar in enumerate(parts[0]):
self.location = f'{self.path}: part {parts[0].get("id")}, measure {bar.get("number", str(index + 1))}'
self.children(bar, {'attributes', 'note', 'direction', 'sound', 'harmony', 'forward', 'backup', 'barline', 'print'})
self.attrs(bar, {'number', 'implicit', 'non-controlling', 'width', 'text'})
cursor, extent = fractions.Fraction(), fractions.Fraction()
voices = {}
previous = None
optional = []
measure = {'id': f'measure_{index + 1}', 'number': bar.get('number', str(index + 1)), 'implicit': bar.get('implicit') == 'yes', 'harmonies': [], 'attributes': [], 'key': copy.deepcopy(self.key), 'meter': copy.deepcopy(self.meter), 'clef': copy.deepcopy(self.clef), 'transpose': copy.deepcopy(self.transpose), 'staff_details': copy.deepcopy(self.staff_details)}
for item in bar:
if item.tag == 'attributes':
state = self.attributes(item, cursor)
if cursor == 0:
for k in ('key', 'meter', 'clef', 'transpose', 'staff_details'):
measure[k] = copy.deepcopy(getattr(self, k))
elif len(state) > 1:
measure['attributes'].append(state)
elif item.tag == 'note':
previous, cursor, is_chord = self.read_note(item, cursor, previous)
if not is_chord:
voices.setdefault(self.voice_id, []).append(previous)
extent = max(extent, cursor)
elif item.tag == 'harmony':
measure['harmonies'].append(self.read_harmony(item, cursor))
elif item.tag == 'direction':
self.direction(item, cursor, measure, optional)
elif item.tag == 'sound':
self.sound(item, cursor, measure)
elif item.tag == 'backup':
self.children(item, {'duration'})
cursor -= self.number(item.findtext('duration'), positive=True) / self.divisions
if cursor < 0:
self.fail('backup moves before the start of the measure')
previous = None
elif item.tag == 'forward':
self.children(item, {'duration', 'voice', 'staff'})
self.identity(item)
duration = self.number(item.findtext('duration'), positive=True) / self.divisions
if item.find('voice') is not None:
self.sequence += 1
voices.setdefault(self.voice_id, []).append(generator.model.make_event(f'spacer_{self.sequence}', cursor, duration, 'spacer') | {'voice': self.voice_id})
cursor += duration
extent = max(extent, cursor)
previous = None
elif item.tag == 'barline':
if item.find('repeat') is not None or item.find('ending') is not None:
self.fail('repeats and numbered endings are unsupported; write the music out in reading order')
self.attrs(item, {'location'})
self.children(item, {'bar-style'})
side = item.get('location', 'right')
if side not in ('left', 'right'):
self.fail('barlines must be at measure boundaries')
if side in measure.get('barlines', {}):
self.fail(f'duplicate {side} barline')
if side == 'left' and cursor != 0 or side == 'right' and cursor != extent:
self.fail('barline location disagrees with its musical position')
style = item.findtext('bar-style', 'regular')
if style not in ('regular', 'light-light', 'light-heavy', 'heavy-light', 'heavy-heavy', 'heavy', 'dashed', 'dotted', 'none', 'tick', 'short'):
self.fail('unsupported barline')
measure.setdefault('barlines', {})[side] = style
if side == 'right':
measure['_right_barline_onset'] = cursor
if not voices or extent <= 0:
self.fail('empty or zero-duration measure')
cursor = extent
if measure.pop('_right_barline_onset', cursor) != cursor:
self.fail('right barline must occur at the end of its measure')
expected = generator.model.meter_length(measure['meter'])
meter_changed = any('meter' in a for a in measure['attributes'])
if expected and cursor != expected and not measure['implicit'] and not meter_changed:
self.fail(f'measure has {cursor} quarter notes, expected {expected}; mark a partial measure implicit')
measure['duration'] = generator.model.rational(cursor)
measure['key_signature'], measure['time_signature'] = generator.model.key_label(measure['key']), generator.model.meter_label(measure['meter'])
measure['voices'] = [{'id': voice, 'notes': sorted(events, key=lambda n: generator.model.fraction(n['onset']))} for voice, events in voices.items()]
measure['harmonies'].sort(key=lambda h: generator.model.fraction(h['onset']))
used = set()
for onset in optional:
matches = [h for h in measure['harmonies'] if generator.model.fraction(h['onset']) == onset]
if len(matches) != 1 or onset in used:
self.fail('optional-chord marker must identify exactly one harmony and occur only once')
matches[0]['chord_optional'] = True
matches[0]['parenthesized'] = True
matches[0]['symbol'] = generator.harmony.symbol(matches[0])
used.add(onset)
measures.append(measure)
self.absolute += cursor
if self.open_ties or self.active_tuplets or self.open_octaves or self.tremolo_starts:
self.fail('unterminated tie, tuplet, octave shift, or tremolo')
if not measures:
self.fail('score has no measures')
for measure in measures:
for n in (n for v in measure['voices'] for n in v['notes']):
for p in n['pitches']:
p.pop('_absolute', None)
metadata.update({'key_signature': measures[0]['key_signature'], 'time_signature': measures[0]['time_signature'], 'key': copy.deepcopy(measures[0]['key'])})
result = {'metadata': metadata, 'staves': [{'id': 'staff_source', 'name': definitions[0].findtext('part-name', 'Source'), 'role': 'vocal', 'clef': copy.deepcopy(measures[0]['clef']), 'tuplets': self.tuplets, 'octave_shifts': self.octaves, 'measures': measures}]}
result['staves'][0]['instruments'] = instruments
generator.validation.validate_document(result)
return result
def load_musicxml(path: pathlib.Path) -> dict:
importer = Importer(pathlib.Path(path))
try:
return importer.read()
except MusicXMLError:
raise
except Exception as error:
raise MusicXMLError(f'{importer.location}: {error}') from error
+316
View File
@@ -0,0 +1,316 @@
from __future__ import annotations
import fractions
import math
import pathlib
import xml.etree.ElementTree
import generator.harmony
import generator.model
import generator.validation
def element(parent, tag, content=None, **attributes):
node = xml.etree.ElementTree.SubElement(parent, tag, {k.replace('_', '-'): str(v) for k, v in attributes.items() if v is not None})
if content is not None:
node.text = str(content)
return node
def decimal(value) -> str:
value = generator.model.alteration(value) if isinstance(value, dict) else fractions.Fraction(value)
denominator = value.denominator
for prime in (2, 5):
while denominator % prime == 0:
denominator //= prime
if denominator != 1:
raise ValueError(f'MusicXML requires a terminating decimal here, not {value}')
digits, remainder = [], abs(value.numerator) % value.denominator
while remainder:
digit, remainder = divmod(remainder * 10, value.denominator)
digits.append(str(digit))
return ('-' if value < 0 else '') + str(abs(value.numerator) // value.denominator) + ('.' + ''.join(digits) if digits else '')
def write_attributes(parent, state: dict, divisions=None) -> None:
node = element(parent, 'attributes')
if divisions is not None:
element(node, 'divisions', divisions)
if key := state.get('key'):
item = element(node, 'key')
if 'cancel' in key:
element(item, 'cancel', key['cancel'])
if key.get('custom'):
steps = key.get('steps', [{'letter': letter, 'alter': amount} for letter, amount in zip('cdefgab', key['alterations']) if generator.model.alteration(amount)])
for step in steps:
element(item, 'key-step', step['letter'].upper())
element(item, 'key-alter', decimal(step['alter']))
if 'accidental' in step:
element(item, 'key-accidental', step['accidental'])
for index, step in enumerate(steps):
if 'octave' in step:
element(item, 'key-octave', step['octave'], number=index + 1)
else:
element(item, 'fifths', key['fifths'])
element(item, 'mode', key['mode'])
if meter := state.get('meter'):
item = element(node, 'time', symbol=meter.get('symbol', 'normal'))
if meter.get('unmetered'):
element(item, 'senza-misura')
else:
for group in meter['groups']:
element(item, 'beats', '+'.join(map(str, group['beats'])))
element(item, 'beat-type', group['unit'])
if clef := state.get('clef'):
item = element(node, 'clef')
element(item, 'sign', clef['sign'])
element(item, 'line', clef['line'])
if clef['octave_change']:
element(item, 'clef-octave-change', clef['octave_change'])
if details := state.get('staff_details'):
item = element(node, 'staff-details')
element(item, 'staff-lines', details['lines'])
for tuning in details.get('tunings', []):
t = element(item, 'staff-tuning', line=tuning['line'])
element(t, 'tuning-step', tuning['letter'].upper())
element(t, 'tuning-alter', decimal(tuning['alter']))
element(t, 'tuning-octave', tuning['octave'])
if details.get('capo'):
element(item, 'capo', details['capo'])
transpose = state.get('transpose')
if transpose and (transpose.get('explicit') or any(transpose[field] for field in ('diatonic', 'chromatic', 'octave_change'))):
item = element(node, 'transpose')
for field in ('diatonic', 'chromatic', 'octave_change'):
element(item, field.replace('_', '-'), transpose[field])
def write_harmony(parent, value: dict, divisions: int) -> None:
node = element(parent, 'harmony')
if root := value.get('root'):
item = element(node, 'root')
element(item, 'root-step', root['letter'].upper())
element(item, 'root-alter', decimal(root['alter']))
element(node, 'kind', value['kind'], text=generator.harmony.symbol(value) if value.get('parenthesized') else None)
if 'inversion' in value:
element(node, 'inversion', value['inversion'])
if 'bass' in value:
item = element(node, 'bass')
element(item, 'bass-step', value['bass']['letter'].upper())
element(item, 'bass-alter', decimal(value['bass']['alter']))
for degree in value['degrees']:
item = element(node, 'degree')
amount = generator.model.alteration(degree['alter'])
if degree['type'] == 'alter':
amount -= generator.harmony.KINDS[value['kind']].get(degree['value'], 0)
elif degree['type'] == 'add' and (degree['value'] - 1) % 7 == 6:
amount += 1
element(item, 'degree-value', degree['value'])
element(item, 'degree-alter', decimal(amount))
element(item, 'degree-type', degree['type'])
element(node, 'offset', int(generator.model.fraction(value['onset']) * divisions))
def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[dict], tablature=False) -> None:
if event['kind'] == 'spacer':
item = element(parent, 'forward')
element(item, 'duration', int(generator.model.fraction(event['duration']) * divisions))
element(item, 'voice', voice)
return
pitches = event['pitches'] or [None]
for index, pitch in enumerate(pitches):
printing = {'print-object': 'no'} if event.get('hidden') else {}
if pitch:
printing.update(pitch.get('print', {}))
node = element(parent, 'note', **printing)
if grace := event.get('grace'):
element(node, 'grace', **{k: ('yes' if v else 'no') if isinstance(v, bool) else decimal(generator.model.fraction(v) * divisions if k == 'make-time' else v) for k, v in grace.items()})
if (pitch or event).get('cue', event.get('cue')):
element(node, 'cue')
if index:
element(node, 'chord')
if event['kind'] == 'rest':
rest = element(node, 'rest', measure='yes' if event.get('measure_rest') else None)
if position := event.get('rest_position'):
element(rest, 'display-step', position['letter'].upper())
element(rest, 'display-octave', position['octave'])
elif pitch and 'unpitched' in pitch:
unpitched = element(node, 'unpitched')
element(unpitched, 'display-step', pitch['unpitched']['letter'].upper())
element(unpitched, 'display-octave', pitch['unpitched']['octave'])
else:
item = element(node, 'pitch')
element(item, 'step', pitch['letter'].upper() if pitch else 'B')
element(item, 'alter', decimal(pitch['alter']) if pitch else '0')
element(item, 'octave', pitch['octave'] if pitch else 4)
if not event.get('grace'):
element(node, 'duration', int(generator.model.fraction(pitch['duration'] if pitch else event['duration']) * divisions))
for field, action in (('tie_from', 'stop'), ('tie_to', 'start')):
if pitch and pitch.get(field):
element(node, 'tie', type=action)
if pitch and pitch.get('instrument'):
element(node, 'instrument', id=pitch['instrument'])
element(node, 'voice', voice)
written = pitch['written'] if pitch else event['written']
element(node, 'type', written['type'])
for _ in range(written.get('dots', 0)):
element(node, 'dot')
if pitch and (display := pitch.get('accidental_display')):
element(node, 'accidental', display['name'], **{k: v for k, v in display.items() if k != 'name'})
if 'ratio' in written:
ratio = generator.model.fraction(written['ratio'])
item = element(node, 'time-modification')
element(item, 'actual-notes', ratio.denominator)
element(item, 'normal-notes', ratio.numerator)
if normal := written.get('normal'):
element(item, 'normal-type', normal['type'])
for _ in range(normal.get('dots', 0)):
element(item, 'normal-dot')
if stem := (pitch or event).get('stem', event.get('stem')):
element(node, 'stem', stem)
head = pitch.get('notehead') if pitch else None
if head:
element(node, 'notehead', head['name'], **{k: v for k, v in head.items() if k != 'name'})
elif event.get('notehead'):
element(node, 'notehead', event['notehead'])
if index == 0:
for beam in event.get('beams', []):
element(node, 'beam', beam['type'], number=beam['level'])
notations = element(node, 'notations')
if tablature and pitch and pitch.get('tablature'):
technical = element(notations, 'technical')
element(technical, 'string', pitch['tablature']['string'])
element(technical, 'fret', pitch['tablature']['fret'])
for field, action in (('tie_from', 'stop'), ('tie_to', 'start')):
if pitch and pitch.get(field):
element(notations, 'tied', type=action)
if index == 0:
for span in tuplets:
for endpoint, action in (('start', 'start'), ('end', 'stop')):
if span[endpoint] != event['id']:
continue
item = element(notations, 'tuplet', type=action, number=span['depth'] + 1, bracket=None if span['bracket'] == 'auto' else span['bracket'], show_number=span['show_number'], show_type=span.get('show_type', 'none'), line_shape=span.get('line_shape', 'straight'))
if action == 'start':
for tag, field in (('tuplet-actual', 'note_count'), ('tuplet-normal', 'space_count')):
detail = span.get(tag.replace('-', '_'), {})
group = element(item, tag)
element(group, 'tuplet-number', span[field])
if detail.get('type'):
element(group, 'tuplet-type', detail['type'])
for _ in range(detail.get('dots', 0)):
element(group, 'tuplet-dot')
if tremolo := event.get('tremolo'):
element(element(notations, 'ornaments'), 'tremolo', tremolo['marks'], type=tremolo['type'])
if not len(notations):
node.remove(notations)
if index == 0:
for verse in event.get('lyrics', []):
lyric = element(node, 'lyric', number=verse['number'])
for syllable in verse['syllables']:
element(lyric, syllable['kind'], syllable['text'], **syllable['attributes'])
def write_barline(bar, measure, location):
if location not in measure.get('barlines', {}):
return
node = element(bar, 'barline', location=location)
element(node, 'bar-style', measure.get('barlines', {}).get(location, 'regular'))
def musicxml_tree(document: dict, *, tablature=False) -> xml.etree.ElementTree.ElementTree:
generator.validation.validate_document(document)
root = xml.etree.ElementTree.Element('score-partwise', {'version': '4.0'})
metadata, staff = document['metadata'], document['staves'][0]
element(element(root, 'work'), 'work-title', metadata['title'])
identification = element(root, 'identification')
element(identification, 'creator', metadata.get('composer', ''), type='composer')
defaults = element(root, 'defaults')
element(defaults, 'music-font', font_family='Petaluma')
element(defaults, 'word-font', font_family='Petaluma Script')
element(defaults, 'lyric-font', font_family='Petaluma Script')
definition = element(element(root, 'part-list'), 'score-part', id='P1')
element(definition, 'part-name', staff['name'])
for instrument in staff.get('instruments', []):
item = element(definition, 'score-instrument', id=instrument['id'])
element(item, 'instrument-name', instrument['name'])
if instrument['abbreviation']:
element(item, 'instrument-abbreviation', instrument['abbreviation'])
if instrument['sound']:
element(item, 'instrument-sound', instrument['sound'])
if instrument.get('solo'):
element(item, 'solo')
if 'ensemble' in instrument:
element(item, 'ensemble', instrument['ensemble'])
if 'virtual' in instrument:
virtual = element(item, 'virtual-instrument')
for tag, value in instrument['virtual'].items():
element(virtual, tag, value)
for instrument in staff.get('instruments', []):
if instrument.get('midi'):
item = element(definition, 'midi-instrument', id=instrument['id'])
for tag, value in instrument['midi'].items():
element(item, tag, value)
part = element(root, 'part', id='P1')
absolute = fractions.Fraction()
previous_state = {}
for index, measure in enumerate(staff['measures']):
bar = element(part, 'measure', number=measure['number'], implicit='yes' if measure['implicit'] else 'no')
write_barline(bar, measure, 'left')
times = [generator.model.fraction(measure['duration'])]
times.extend(generator.model.fraction(n[field]) for v in measure['voices'] for n in v['notes'] for field in ('onset', 'duration'))
times.extend(generator.model.fraction(p['duration']) for v in measure['voices'] for n in v['notes'] for p in n['pitches'])
times.extend(generator.model.fraction(item['onset']) for field in ('attributes', 'harmonies', 'tempos') for item in measure.get(field, []))
times.extend(generator.model.fraction(s[field]) - absolute for s in staff.get('octave_shifts', []) for field in ('start', 'end'))
divisions = math.lcm(*(t.denominator for t in times))
state = {k: measure[k] for k in ('key', 'meter', 'clef', 'transpose', 'staff_details') if k in measure}
write_attributes(bar, {k: v for k, v in state.items() if previous_state.get(k) != v}, divisions)
previous_state.update(state)
cursor = fractions.Fraction()
for change in measure.get('attributes', []):
onset = generator.model.fraction(change['onset'])
if onset != cursor:
element(element(bar, 'forward' if onset > cursor else 'backup'), 'duration', int(abs(onset - cursor) * divisions))
write_attributes(bar, change)
previous_state.update({k: v for k, v in change.items() if k != 'onset'})
cursor = onset
if cursor:
element(element(bar, 'backup'), 'duration', int(cursor * divisions))
for value in measure['harmonies']:
write_harmony(bar, value, divisions)
if value.get('chord_optional'):
direction = element(bar, 'direction', print_object='no')
element(element(direction, 'direction-type'), 'words', 'vamp:optional-chord')
element(direction, 'offset', int(generator.model.fraction(value['onset']) * divisions))
if measure.get('section_label'):
element(element(element(bar, 'direction'), 'direction-type'), 'rehearsal', measure['section_label'], halign='left')
for tempo in measure.get('tempos', []):
direction = element(bar, 'direction')
metronome = element(element(direction, 'direction-type'), 'metronome')
element(metronome, 'beat-unit', tempo['written']['type'])
for _ in range(tempo['written'].get('dots', 0)):
element(metronome, 'beat-unit-dot')
element(metronome, 'per-minute', decimal(tempo['bpm']))
element(direction, 'offset', int(generator.model.fraction(tempo['onset']) * divisions))
for si, span in enumerate(staff.get('octave_shifts', [])):
for field in ('start', 'end'):
onset = generator.model.fraction(span[field]) - absolute
inside = 0 <= onset < generator.model.fraction(measure['duration']) if field == 'start' else 0 < onset <= generator.model.fraction(measure['duration'])
if not inside:
continue
direction = element(bar, 'direction')
element(element(direction, 'direction-type'), 'octave-shift', type='stop' if field == 'end' else 'up' if span['octaves'] > 0 else 'down', size=abs(span['octaves']) * 7 + 1, number=si + 1)
element(direction, 'offset', int(onset * divisions))
cursor = fractions.Fraction()
for voice in measure['voices']:
if cursor:
element(element(bar, 'backup'), 'duration', int(cursor * divisions))
cursor = fractions.Fraction()
for event in voice['notes']:
onset = generator.model.fraction(event['onset'])
if onset > cursor:
element(element(bar, 'forward'), 'duration', int((onset - cursor) * divisions))
write_note(bar, event, voice['id'], divisions, staff.get('tuplets', []), tablature)
cursor = onset + generator.model.fraction(event['duration'])
if cursor < generator.model.fraction(measure['duration']):
element(element(bar, 'forward'), 'duration', int((generator.model.fraction(measure['duration']) - cursor) * divisions))
cursor = generator.model.fraction(measure['duration'])
write_barline(bar, measure, 'right')
absolute += generator.model.fraction(measure['duration'])
return xml.etree.ElementTree.ElementTree(root)
def write_musicxml(path: pathlib.Path, document: dict, *, tablature=False) -> None:
tree = musicxml_tree(document, tablature=tablature)
xml.etree.ElementTree.indent(tree, space=' ')
tree.write(path, encoding='utf-8', xml_declaration=True)
+13
View File
@@ -0,0 +1,13 @@
{
"background": "#111513",
"panel": "#1c221f",
"ink": "#dce5df",
"muted": "#a5b3aa",
"rule": "#48574f",
"accent": "#80c9b5",
"selection": "#293d34",
"connecting": "#9fb9cc",
"retrying": "#d6b879",
"connected": "#80c9b5",
"danger": "#e79891"
}
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
import fractions
import pathlib
import generator.fretboard
import generator.model
import generator.pitch
import generator.score
PART_TYPES = ('acoustic', 'electric', 'vocal')
def source_paths(directory: pathlib.Path) -> list[pathlib.Path]:
if not directory.is_dir():
raise ValueError(f'performance source directory not found: {directory}')
paths = sorted(directory.rglob('*.xml'))
identities = {}
for path in paths:
if path.parent != directory:
raise ValueError(f'{path}: performance sources belong directly under data/songs')
identity = generator.score.slugify(path.stem)
if identity in identities:
raise ValueError(f'{identities[identity]} and {path}: conflicting output path {identity}_acoustic.json')
identities[identity] = path
return paths
def prepare_document(document: dict) -> dict:
validate_source(document)
return generator.pitch.concert_document(document, octave_transposing=True)
def generated_voice(measure: dict, role: str) -> list[dict]:
length = generator.model.fraction(measure['duration'])
if role == 'rest':
return [generator.model.make_event('rest', fractions.Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'measure_rest': True}]
if role != 'slash':
raise ValueError(f'{role} requires a preplanned rhythm')
events = []
states = [(fractions.Fraction(), measure['meter'])]
states.extend((generator.model.fraction(a['onset']), a['meter']) for a in measure.get('attributes', []) if 'meter' in a)
for index, (onset, meter) in enumerate(states):
end = states[index + 1][0] if index + 1 < len(states) else length
beats = generator.model.beat_lengths(meter)
beat_index = 0
while onset < end:
duration = min(beats[beat_index % len(beats)] if beats else end - onset, end - onset)
event = generator.model.make_event(f'slash_{len(events)}', onset, duration, 'note')
event['notehead'] = 'slash'
events.append(event)
onset += duration
beat_index += 1
return events
def validate_source(document: dict) -> None:
for measure in generator.score.source_staff(document)['measures']:
for state in [measure, *measure.get('attributes', [])]:
if state.get('clef', {}).get('sign') in ('TAB', 'percussion'):
raise ValueError(f'{measure["id"]}: parts require a pitched, non-TAB source')
if 'staff_details' in state:
generator.fretboard.validate_tuning(state, measure['id'])
for voice in measure['voices']:
for note in voice['notes']:
if any('tablature' in p or 'unpitched' in p for p in note['pitches']):
raise ValueError(f'{measure["id"]}: authored TAB and percussion are unsupported')
def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '') -> dict:
voices = generator.score.copy_source_voices(source, strip_lyrics=role == 'melody') if role in ('vocal', 'melody') else None
if voices:
notes = [note for voice in voices for note in voice['notes']]
else:
notes = generated_voice(source, role)
if role in ('melody', 'vocal'):
for note in notes:
octaves = 2 if role == 'melody' else 1
note['pitches'] = [generator.pitch.shift_pitch(p, 7 * octaves, 12 * octaves) for p in note['pitches']]
if role == 'melody':
note['_melody'] = True
result = generator.score.make_measure(identifier, source, [] if voices else notes, section_label=label)
if voices:
result['voices'] = voices
result['transpose'] = {'diatonic': 0, 'chromatic': 0, 'octave_change': -1, 'explicit': True}
result['clef']['octave_change'] = 0
for change in result.get('attributes', []):
change.pop('transpose', None)
if 'clef' in change:
change['clef']['octave_change'] = 0
if part != 'vocal':
result['clef'] = {'sign': 'G', 'line': 2, 'octave_change': 0}
result['staff_details'] = {'lines': 5, 'tunings': [], 'capo': 0}
for change in result.get('attributes', []):
change.pop('clef', None)
change.pop('staff_details', None)
return result
+113
View File
@@ -0,0 +1,113 @@
"""Pure page coverage and performance timing."""
from __future__ import annotations
import fractions
import math
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)
measures = document['staves'][0]['measures']
systems = []
for system in page.systems:
a, b = times.index(system.start), times.index(system.end)
labels = {p.sources[0]: p for p in system.primitives if p.role == 'measure_number'}
boxes = []
for i in range(a, b):
box = labels[measures[i]['id']].box
boxes.append(dict(measure=i + 1, bounds=[box.left, box.top, box.right, box.bottom]))
systems.append(dict(first_measure=a + 1, measure_count=b - a, start_beat=generator.model.rational(system.start), end_beat=generator.model.rational(system.end), labels=boxes))
return dict(first_measure=first + 1, measure_count=last - first, start_beat=generator.model.rational(start), end_beat=generator.model.rational(end), systems=systems)
def validate_views(document, pages, playback_pages):
validate_pages(document, pages)
times = boundaries(document)
canonical = []
def system_range(system):
a, count = system['first_measure'], system['measure_count']
if type(a) is not int or type(count) is not int or a < 1 or count < 1 or a + count > len(times):
raise ValueError('invalid system measure coverage')
if generator.model.fraction(system['start_beat']) != times[a - 1] or generator.model.fraction(system['end_beat']) != times[a + count - 1]:
raise ValueError('invalid system timing')
if [label['measure'] for label in system['labels']] != list(range(a, a + count)):
raise ValueError('invalid measure label coverage')
for label in system['labels']:
box = label['bounds']
if not isinstance(box, list) or len(box) != 4 or any(type(v) not in (int, float) or not math.isfinite(v) for v in box) or not (4 <= box[0] < box[2] <= 972 and 4 <= box[1] < box[3] <= 480):
raise ValueError('invalid measure label bounds')
return a, count
for page in pages:
systems = page.get('systems', [])
if len(systems) != 2 and (page is not pages[-1] or len(systems) != 1):
raise ValueError('invalid page systems')
cursor = page['first_measure']
for system in systems:
a, count = system_range(system)
if a != cursor:
raise ValueError('system gap or overlap')
cursor += count
canonical.append(system)
if cursor != page['first_measure'] + page['measure_count']:
raise ValueError('systems disagree with page coverage')
if not isinstance(playback_pages, list) or len(playback_pages) != len(canonical):
raise ValueError('missing playback pages; regenerate score assets')
for i, page in enumerate(playback_pages):
expected = canonical[i:i + 2]
systems = page.get('systems', [])
if len(systems) != len(expected):
raise ValueError('invalid playback systems')
for actual, source in zip(systems, expected):
system_range(actual)
if any(actual[k] != source[k] for k in ('first_measure', 'measure_count', 'start_beat', 'end_beat')):
raise ValueError('playback changes system boundaries')
if any(page[k] != expected[0][k] for k in ('first_measure', 'start_beat')) or page['end_beat'] != expected[-1]['end_beat'] or page['measure_count'] != sum(s['measure_count'] for s in expected):
raise ValueError('invalid playback page timing')
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']
ending = [i for i, m in enumerate(measures) if m.get('ending_sustain')]
if ending and ending != list(range(ending[0], len(measures))):
raise ValueError('held ending must be a contiguous terminal section')
sustain_start = times[ending[0]] if ending else times[-1]
# The written held ending participates in page time, not metronome time.
clicks = []
for start, measure in zip(times, measures):
if start >= sustain_start:
break
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]),
sustain_start=generator.model.rational(sustain_start), count_in_beats=4, clicks=clicks)
+172
View File
@@ -0,0 +1,172 @@
"""Build-only PetalumaScript sprites and content-addressed Pico title images."""
from __future__ import annotations
import argparse
import hashlib
import json
import pathlib
import struct
import PIL.Image
import PIL.ImageDraw
import PIL.ImageFont
import PIL.features
import fontTools.ttLib
TITLE_WIDTH = 296
TITLE_HEIGHT = 64
TITLE_HEADER = struct.pack('<4sHH', b'VPTI', TITLE_WIDTH, TITLE_HEIGHT)
TITLE_BYTES = len(TITLE_HEADER) + TITLE_WIDTH * TITLE_HEIGHT // 2
MAX_TITLES = 8192
LABELS = ('VAMP', 'CONNECTING', 'OFFLINE', 'BUSY', 'READY', 'LOADING', 'WIFI', 'ERROR', 'PAGE', 'Acoustic', 'Electric', 'Vocal', 'UNAVAILABLE',
'Main Menu', 'Performance', 'Settings', 'Backing', 'Metronome', 'Solos', 'Tempo', 'Back', 'On', 'Off', 'BPM', 'Manual', 'Preparing', 'Count-in', 'Playing', 'Sustain', 'Hold to exit')
SPRITES = LABELS + tuple('0123456789/')
DIGIT_WIDTH = 22
DEFAULT_FONT_ROOT = pathlib.Path(__file__).resolve().parents[1] / 'package/assets/fonts/petaluma'
class Renderer:
def __init__(self, font_path: pathlib.Path):
if not PIL.features.check_feature('raqm'):
raise RuntimeError('Pico artwork requires Pillow with Raqm support')
self.font_path = font_path
with fontTools.ttLib.TTFont(font_path) as font:
self.characters = set(font.getBestCmap())
self.fonts = {}
def font(self, size: int) -> PIL.ImageFont.FreeTypeFont:
if size not in self.fonts:
self.fonts[size] = PIL.ImageFont.truetype(str(self.font_path), size, layout_engine=PIL.ImageFont.Layout.RAQM)
return self.fonts[size]
def validate(self, text: str) -> None:
if not isinstance(text, str) or not text.strip() or len(text) > 200:
raise ValueError('Pico title and part must contain 1 to 200 characters')
if any(ord(character) < 32 or ord(character) not in self.characters for character in text):
raise ValueError(f'Unsupported PetalumaScript text: {text!r}')
def line(self, text: str, width: int, height: int, size: int) -> PIL.Image.Image:
self.validate(text)
if width < 5 or height < 3:
raise ValueError('Pico text tile is too small')
font = self.font(size)
while size > 14:
box = font.getbbox(text)
if box[2] - box[0] <= width - 4 and box[3] - box[1] <= height - 2:
break
size -= 1
font = self.font(size)
if font.getbbox(text)[2] - font.getbbox(text)[0] > width - 4:
if font.getbbox('...')[2] - font.getbbox('...')[0] > width - 4:
raise ValueError('Pico text tile cannot fit an ellipsis')
original = text
while original and font.getbbox(original + '...')[2] - font.getbbox(original + '...')[0] > width - 4:
original = original[:-1]
text = original + '...'
box = font.getbbox(text)
if box[3] - box[1] > height - 2:
raise ValueError('Pico text tile is too short')
image = PIL.Image.new('L', (width, height))
PIL.ImageDraw.Draw(image).text(((width - box[2] + box[0]) // 2 - box[0],
(height - box[3] + box[1]) // 2 - box[1]), text, font=font, fill=255)
return image
def digit(self, text: str) -> PIL.Image.Image:
font = self.font(24)
box = font.getbbox(text)
if text not in '0123456789/' or len(text) != 1 or box[2] - box[0] > DIGIT_WIDTH - 4 or box[1] + 2 < 0 or box[3] + 2 > 28:
raise ValueError('Invalid Pico counter tile')
image = PIL.Image.new('L', (DIGIT_WIDTH, 28))
PIL.ImageDraw.Draw(image).text(((DIGIT_WIDTH - box[2] + box[0]) // 2 - box[0], 2), text, font=font, fill=255)
return image
def title(self, title: str) -> bytes:
self.validate(title)
for size in range(32, 15, -1):
lines = ['']
for word in title.split():
candidate = (lines[-1] + ' ' + word).strip()
box = self.font(size).getbbox(candidate)
if lines[-1] and box[2] - box[0] > TITLE_WIDTH - 4:
lines.append(word)
else:
lines[-1] = candidate
if len(lines) <= 2:
break
if len(lines) > 2:
lines = [lines[0], ' '.join(lines[1:])]
image = PIL.Image.new('L', (TITLE_WIDTH, TITLE_HEIGHT))
for index, line in enumerate(lines):
image.paste(self.line(line, TITLE_WIDTH, 32, size), (0, (TITLE_HEIGHT - len(lines) * 32) // 2 + index * 32))
return TITLE_HEADER + pack_coverage(image)
def pack_coverage(image: PIL.Image.Image) -> bytes:
pixels = image.tobytes()
if len(pixels) % 2:
pixels += b'\0'
levels = [(pixel + 8) // 17 for pixel in pixels]
return bytes((levels[index] << 4) | levels[index + 1] for index in range(0, len(levels), 2))
def generate_static(output: pathlib.Path, font_root: pathlib.Path = DEFAULT_FONT_ROOT) -> None:
renderer = Renderer(font_root / 'PetalumaScript.otf')
directory = bytearray(struct.pack('<4sHH', b'VPSA', len(SPRITES), 0))
payload = bytearray()
for text in SPRITES:
image = renderer.line(text, 176, 28, 24) if text in LABELS else renderer.digit(text)
if text in LABELS:
box = image.getbbox()
image = image.crop((max(0, box[0] - 1), 0, min(image.width, box[2] + 1), image.height))
directory.extend(struct.pack('<HHI', image.width, image.height, 8 + 8 * len(SPRITES) + len(payload)))
payload.extend(pack_coverage(image))
output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(directory + payload)
def generate_titles(root: pathlib.Path, font_root: pathlib.Path = DEFAULT_FONT_ROOT) -> None:
"""Call after score generation, before catalog validation and atomic promotion.
Adds presentation.title_image to score bundles. The identifier is SHA-256 of
the complete fixed-size .mask file, shared across parts with the same title.
"""
renderer = Renderer(font_root / 'PetalumaScript.otf')
destination = root / 'pico' / 'titles'
destination.mkdir(parents=True, exist_ok=True)
titles = {}
for source in sorted((root / 'songs').rglob('*.json')):
document = json.loads(source.read_text(encoding='utf-8'))
title = document['music']['metadata']['title']
if title not in titles:
if len(titles) >= MAX_TITLES:
raise ValueError('Too many Pico titles')
image = renderer.title(title)
titles[title] = hashlib.sha256(image).hexdigest()
(destination / f'{titles[title]}.mask').write_bytes(image)
document['presentation']['title_image'] = titles[title]
source.write_text(json.dumps(document, ensure_ascii=False, separators=(',', ':')) + '\n', encoding='utf-8')
def validate_title(root: pathlib.Path, identifier: str) -> None:
if not isinstance(identifier, str) or len(identifier) != 64 or any(character not in '0123456789abcdef' for character in identifier):
raise ValueError('Invalid Pico title identifier; regenerate assets')
try:
asset_root = root.resolve(strict=True)
directory = (root / 'pico' / 'titles').resolve(strict=True)
source = (directory / f'{identifier}.mask').resolve(strict=True)
if not directory.is_relative_to(asset_root) or source.parent != directory:
raise ValueError('Pico title escapes the generated title directory')
if source.stat().st_size != TITLE_BYTES:
raise ValueError('Invalid Pico title size')
with source.open('rb') as stream:
image = stream.read(TITLE_BYTES + 1)
if len(image) != TITLE_BYTES or image[:len(TITLE_HEADER)] != TITLE_HEADER or hashlib.sha256(image).hexdigest() != identifier:
raise ValueError('Invalid Pico title header or digest')
except OSError as error:
raise ValueError(f'Pico title unavailable: {error}') from error
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--font-root', type=pathlib.Path, default=DEFAULT_FONT_ROOT)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('--static-output', type=pathlib.Path)
group.add_argument('--assets', type=pathlib.Path)
arguments = parser.parse_args()
if arguments.static_output:
generate_static(arguments.static_output, arguments.font_root)
else:
generate_titles(arguments.assets, arguments.font_root)
if __name__ == '__main__':
main()
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
import copy
import generator.harmony
import generator.model
def shift_pitch(pitch: dict, steps: int, semitones: int) -> dict:
output = copy.deepcopy(pitch)
if 'unpitched' in pitch:
return output
original_octave = pitch.get('octave', 4)
diatonic = original_octave * 7 + generator.model.LETTERS.index(pitch['letter']) + steps
octave, index = divmod(diatonic, 7)
output['letter'] = generator.model.LETTERS[index]
output['octave'] = octave
output['alter'] = generator.model.rational(generator.model.pitch_number({**pitch, 'octave': original_octave}) + semitones - (12 * (octave + 1) + generator.model.NATURAL[output['letter']]))
if 'octave' not in pitch:
output.pop('octave')
if 'accidental_display' in output:
output['accidental_display']['name'] = generator.model.accidental_name(generator.model.alteration(output['alter']))
return output
def shift_key(key: dict, steps: int, semitones: int) -> dict:
tonic = shift_pitch(key['tonic'], steps, semitones) if key.get('tonic') else None
alterations = [0] * 7
for index, letter in enumerate(generator.model.LETTERS):
p = shift_pitch({'letter': letter, 'alter': key['alterations'][index]}, steps, semitones)
alterations[generator.model.LETTERS.index(p['letter'])] = p['alter']
result = {**copy.deepcopy(key), 'tonic': tonic, 'alterations': alterations}
if 'steps' in key:
result['steps'] = [shift_pitch(step, steps, semitones) for step in key['steps']]
if not key.get('custom') and tonic:
for fifths in range(-14, 15):
candidate = generator.model.key_from_fifths(fifths, key['mode'])
if candidate['tonic'] == tonic and candidate['alterations'] == alterations:
result['fifths'] = fifths
break
else:
result['custom'] = True
if 'cancel' in key:
# A cancellation references the old signature, not a count of naturals.
canceled = shift_key(generator.model.key_from_fifths(key['cancel']), steps, semitones)
if canceled.get('custom'):
raise ValueError('transposed key cancellation cannot be represented by fifths')
result['cancel'] = canceled['fifths']
return result
def concert_document(document: dict, *, octave_transposing: bool = False) -> dict:
"""Resolve source instrument pitch once, before arranging in concert pitch."""
output = copy.deepcopy(document)
zero = {'diatonic': 0, 'chromatic': 0, 'octave_change': 0, 'explicit': True}
for staff in output['staves']:
for measure in staff['measures']:
initial = {key: measure[key] for key in ('transpose', 'clef')}
changes = copy.deepcopy(measure.get('attributes', []))
def interval(onset):
state = dict(initial)
for change in changes:
if generator.model.fraction(change['onset']) <= onset:
state.update({key: change[key] for key in initial if key in change})
transpose, clef = state['transpose'], state['clef']
steps = transpose['diatonic'] + 7 * transpose['octave_change']
semitones = transpose['chromatic'] + 12 * transpose['octave_change']
if octave_transposing and not transpose.get('explicit') and not (steps or semitones) and not clef['octave_change']:
return -7, -12
return steps, semitones
measure['key'] = shift_key(measure['key'], *interval(0))
measure['key_signature'] = generator.model.key_label(measure['key'])
for n in (n for v in measure['voices'] for n in v['notes']):
n['pitches'] = [shift_pitch(p, *interval(generator.model.fraction(n['onset']))) for p in n['pitches']]
for h in measure['harmonies']:
for field in ('root', 'bass'):
if field in h:
h[field] = shift_pitch(h[field], *interval(generator.model.fraction(h['onset'])))
h['symbol'] = generator.harmony.symbol(h)
for change in measure.get('attributes', []):
if 'key' in change:
change['key'] = shift_key(change['key'], *interval(generator.model.fraction(change['onset'])))
if 'transpose' in change:
change['transpose'] = copy.deepcopy(zero)
measure['transpose'] = copy.deepcopy(zero)
first = output['staves'][0]['measures'][0]
output['metadata']['key'] = copy.deepcopy(first['key'])
output['metadata']['key_signature'] = first['key_signature']
return output
def sounding_pitch(pitch: dict, state: dict):
if 'unpitched' in pitch:
raise ValueError('unpitched notes have no fretted pitch interpretation')
transpose = state['transpose']
return generator.model.pitch_number(pitch) + transpose['chromatic'] + 12 * transpose['octave_change']
-2
View File
@@ -1,2 +0,0 @@
"""Practice score generation."""
-77
View File
@@ -1,77 +0,0 @@
from __future__ import annotations
from pathlib import Path
from generator.score import copy_source_voice, load_json, make_arranged_document, make_measure, slugify, source_staff, write_json
from generator.transposition import TARGET_KEYS, transpose_document
def practice_document(document: dict) -> dict:
staff = source_staff(document)
source_measures = staff.get("measures")
if not isinstance(source_measures, list):
raise ValueError("practice source must contain measures")
measures: list[dict] = []
for index, measure in enumerate(source_measure for source_measure in source_measures if isinstance(source_measure, dict)):
voice = copy_source_voice(measure, strip_lyrics=True)
output_measure = make_measure(
f"measure-{index + 1}",
measure,
voice["notes"],
section_label=str(measure.get("section_label", "")),
tuplets=voice["tuplets"],
)
output_measure["voices"][0]["id"] = str(voice.get("id", output_measure["voices"][0]["id"]))
measures.append(output_measure)
return make_arranged_document(
document,
staff,
staff_id="staff-arranged-practice",
staff_name="Practice",
role="vocal",
arrangement_part_type="practice",
measures=measures,
)
def key_file_name(key: str) -> str:
return key.lower().replace("#", "sharp").replace("b", "b") + ".json"
def generate_chord_practice(source_path: Path, source_root: Path, output_dir: Path) -> int:
document = practice_document(load_json(source_path))
metadata = document.get("metadata", {})
key = str(metadata.get("key_signature", "C") if isinstance(metadata, dict) else "C")
relative = source_path.relative_to(source_root)
lesson_group = relative.parent.name
exercise_id = slugify(source_path.stem)
write_json(output_dir / "chord" / lesson_group / exercise_id / key_file_name(key), document)
return 1
def generate_melody_practice(source_path: Path, source_root: Path, output_dir: Path) -> int:
base_document = practice_document(load_json(source_path))
relative = source_path.relative_to(source_root)
lesson_group = relative.parent.name
exercise_id = slugify(source_path.stem)
count = 0
for key in TARGET_KEYS:
write_json(output_dir / "melody" / lesson_group / exercise_id / key_file_name(key), transpose_document(base_document, key))
count += 1
return count
def generate_practice_scores(source_dir: Path, output_dir: Path) -> int:
if not source_dir.exists():
return 0
count = 0
chord_root = source_dir / "chord"
if chord_root.exists():
for path in sorted(chord_root.rglob("*.json")):
count += generate_chord_practice(path, chord_root, output_dir)
melody_root = source_dir / "melody"
if melody_root.exists():
for path in sorted(melody_root.rglob("*.json")):
count += generate_melody_practice(path, melody_root, output_dir)
return count
+180
View File
@@ -0,0 +1,180 @@
"""Build-only Letter PDFs from the complete arranged parts, never screen PNGs."""
from __future__ import annotations
import dataclasses
import hashlib
import json
import math
import pathlib
import shutil
import time
import PIL.PdfParser
import generator.engraving.audit
import generator.engraving.cache
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.notation
import generator.engraving.raster
import generator.engraving.types
import generator.validation
# Two layout units per PDF point: guitar staff spacing is five points.
# Raster resolution is independent of layout and the device's display theme.
STYLE = generator.engraving.types.Style(width=1224, height=1584, margin_x=72, margin_y=72, page_clearance=28, system_gap=32, supersampling=1)
DPI = 300
PIXELS = (2550, 3300)
MEDIA_BOX = [0, 0, 612, 792]
VERSION = 'letter-parts-v1'
def layout_part(document, fonts):
generator.validation.validate_document(document)
style = dataclasses.replace(generator.engraving.types.score_style(document, STYLE), system_gap=STYLE.system_gap)
# Measure numbers are centered over barlines and can extend left of the
# staff. Reserve their actual overhang inside the half-inch paper margin.
overhang = max(-fonts.text(str(m['number']), 0, 0, 12, 'measure_number', (), True).box.left for m in document['staves'][0]['measures'])
style = dataclasses.replace(style, margin_x=style.margin_x + math.ceil(max(0, overhang)) + 4)
index = generator.engraving.notation.ScoreIndex(document)
systems = generator.engraving.layout.layout_systems(document, style, fonts, index=index)
# The shared header wraps long credits; print it at twice its display size.
header_style = dataclasses.replace(style, width=style.width // 2, margin_x=style.margin_x / 2, margin_y=style.margin_y / 2)
header = tuple(p.scaled(2) for p in generator.engraving.layout.part_header(document, fonts, header_style))
top = generator.engraving.types.bounds(header).bottom + style.page_clearance
# Reserve the measured outline for every possible page number, just as
# screen pagination does. A nominal font size is not its actual ascent.
footers = tuple(fonts.text(str(number), style.width / 2, style.height - style.margin_y - 8, 20, 'page_number', (), True)
for number in range(1, len(systems) + 1))
bottom = min(p.box.top for p in footers) - style.page_clearance
pages, placed, cursor = [], [], top
def finish():
number = len(pages) + 1
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed),
(*header, *(p for system in placed for p in system.primitives), footers[number - 1])))
for system in systems:
system = generator.engraving.layout.make_system(index, system.start, system.end, fonts, style, justify=system.end < index.end, staff_width=style.width - 2 * style.margin_x - 2)
height = system.box.bottom - system.box.top
if height > bottom - top:
raise generator.engraving.types.EngravingError(f'at musical offset {system.start}: system cannot fit a Letter page at readable size')
if placed and cursor + height > bottom:
finish()
placed, cursor = [], top
dx, dy = style.margin_x + 1, cursor - system.box.top
placed.append(dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives),
anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy)))
cursor += height + style.system_gap
finish()
result = generator.engraving.types.LayoutDocument(tuple(pages), style, fonts.fingerprint(style))
generator.engraving.audit.audit_layout(document, result, fonts, systems_per_page=None)
if any(p.box.left < STYLE.margin_x or p.box.right > style.width - STYLE.margin_x or
p.box.top < STYLE.margin_y or p.box.bottom > style.height - STYLE.margin_y for page in pages for p in page.primitives):
raise generator.engraving.types.EngravingError('print notation exceeds the half-inch printer margins')
return result
def rasterize(page, fonts):
return generator.engraving.raster.rasterize_page(page, fonts, ink=(0, 0, 0), background=(255, 255, 255), output_scale=DPI / 144)
def validate_pdf(path, expected_pages=None):
"""Check the narrow PDF format written by the pinned Pillow asset dependency.
This adapter deliberately uses Pillow's parser, not a new general PDF stack.
Tests also decode the embedded page images to check print color/resolution.
"""
try:
if path.is_symlink() or not path.is_file():
raise ValueError('expected a regular print PDF')
pdf = PIL.PdfParser.PdfParser(filename=str(path))
try:
count = len(pdf.pages)
if not 0 < count <= 10000 or (expected_pages is not None and count != expected_pages):
raise ValueError('invalid print PDF page count')
for reference in pdf.pages:
page = pdf.read_indirect(reference)
if page[b'MediaBox'] != MEDIA_BOX:
raise ValueError('print PDF must use portrait US Letter')
image = pdf.read_indirect(page[b'Resources'][b'XObject'][b'image'])
if (image.dictionary[b'Width'], image.dictionary[b'Height']) != PIXELS or image.dictionary[b'ColorSpace'] != b'DeviceGray':
raise ValueError('print PDF must contain 300 DPI grayscale pages')
return count
finally:
pdf.close()
except (OSError, KeyError, TypeError, IndexError, PIL.PdfParser.PdfFormatError) as error:
raise ValueError(f'{path.name}: invalid print PDF: {error}') from error
def write_pdf(path, document, fonts):
layout = layout_part(document, fonts)
# Append one page at a time so a long arrangement never keeps a chorus of
# full-resolution raster images in memory. The enclosing build is staged.
for page in layout.pages:
with rasterize(page, fonts) as rgb, rgb.convert('L') as image:
if image.size != PIXELS or image.getextrema()[0] == 255:
raise ValueError('print renderer produced an empty or incorrectly sized page')
image.save(path, format='PDF', append=page.number > 1, resolution=DPI, quality=95,
title=document['metadata']['title'] + ' - ' + document['staves'][0].get('name', 'Part'),
creator='Product Vamp', creationDate=None, modDate=None)
validate_pdf(path, len(layout.pages))
return len(layout.pages)
def renderer_key(fonts):
identity = generator.engraving.cache.renderer_identity(fonts, STYLE)
identity.update(print_version=VERSION, print_code=hashlib.sha256(pathlib.Path(__file__).read_bytes()).hexdigest(), dpi=DPI, ink='black', background='white', quality=95)
return hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
def restore(entry, key, target):
try:
if generator.cache.has_symlink(entry):
return None
pdf, manifest = entry / 'part.pdf', entry / 'print.json'
if pdf.is_symlink() or manifest.is_symlink():
return None
info = json.loads(manifest.read_text())
if info['key'] != key or info['sha256'] != hashlib.sha256(pdf.read_bytes()).hexdigest():
return None
pages = validate_pdf(pdf, info['pages'])
shutil.copyfile(pdf, target)
return pages
except (OSError, ValueError, KeyError, TypeError):
return None
def store(entry, key, target, pages):
with generator.cache.stage_entry(entry, '.print_') as staged:
shutil.copyfile(target, staged / 'part.pdf')
(staged / 'print.json').write_text(json.dumps({'key': key, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest(), 'pages': pages}))
generator.cache.publish_files(entry, staged, ('part.pdf', 'print.json'))
def generate_tree(root, font_path, *, cache_dir=None, progress=False):
"""Called inside the normal build's atomic staging directory."""
if cache_dir and (cache_dir.resolve().is_relative_to(root.resolve()) or root.resolve().is_relative_to(cache_dir.resolve())):
raise ValueError('print cache and generated assets must not overlap')
destination = root / 'print'
destination.mkdir(exist_ok=True)
paths = sorted((root / 'songs').glob('*.json'))
fonts = generator.engraving.fonts.Fonts(font_path)
renderer = renderer_key(fonts)
expected, pages_total, reused = set(), 0, 0
started = time.monotonic()
for path in paths:
bundle = json.loads(path.read_text())
document = bundle.get('music', bundle)
target = destination / path.with_suffix('.pdf').name
expected.add(target)
key = generator.engraving.cache.score_fingerprint(document, renderer)
entry = cache_dir / 'print' / renderer / key if cache_dir else None
pages = restore(entry, key, target) if entry else None
if pages is None:
pages = write_pdf(target, document, fonts)
if entry:
store(entry, key, target, pages)
else:
reused += 1
pages_total += pages
if progress:
print(f'Print: {target.name}: {pages} pages', flush=True)
for stale in destination.glob('*.pdf'):
if stale not in expected:
stale.unlink()
if progress:
print(f'Print complete: {len(paths)} PDFs, {pages_total} pages, {reused} cached; {time.monotonic() - started:.1f}s', flush=True)
return len(paths)
+75
View File
@@ -0,0 +1,75 @@
"""File discovery, process scheduling and publication of independent roles."""
from __future__ import annotations
import concurrent.futures
import copy
import json
import multiprocessing
import pathlib
import generator.arrange.accompaniment
import generator.arrange.assembly
import generator.composition
import generator.composition_types
import generator.musicxml
import generator.parts
import generator.score
import generator.score_io
import generator.source
def _job(request):
return generator.composition.generate_role(request.source, request.role, request.directory, json.loads(request.settings_json), request.cache)
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:
raise ValueError('composition workers must be between 1 and 16')
source_dir, output_dir = pathlib.Path(source_dir), pathlib.Path(output_dir)
sources = {}
for path in generator.parts.source_paths(source_dir):
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
sources[path] = (document, generator.source.SourceAnalysis.read(document))
requests = [(path, role, generator.composition_types.RoleRequest(s, role, source_dir.parent, cache=cache_dir)) for path, (_, s) in sources.items() for role in generator.composition_types.ROLES]
results = {p: {} for p in sources}
def accept(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 progress:
print(f'Composition: {path.stem}/{role}: ' + ('cached' if value['cache_hit'] else f'{value["seconds"]:.1f}s'), flush=True)
if jobs == 1:
for path, role, args in requests:
try:
accept(path, role, _job(args))
except Exception as error:
raise ValueError(f'{path.name}/{role}: {error}') from error
else:
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}
for future in concurrent.futures.as_completed(futures):
path, role = futures[future]
try:
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():
reuse = generator.arrange.accompaniment.audit(original, {role: value['document'] for role, value in results[path].items()}, role_documents=True)
for solos_enabled in (True, False):
assembled = generator.arrange.assembly.assemble(source, results[path], solos_enabled=solos_enabled)
generator.arrange.accompaniment.audit(original, assembled)
for part, document in assembled.items():
if part == 'vocal' and not solos_enabled:
continue
document['metadata'] = copy.deepcopy(original['metadata'])
document['metadata'].update(song_id=generator.score.slugify(path.stem), solos_enabled=solos_enabled)
suffix = '' if solos_enabled else '_improv'
generator.score_io.write_json(output_dir / (generator.score.slugify(path.stem) + '_' + part + suffix + '.json'), document)
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 generator.composition_types.ROLES))
# 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()}
for role, audit in reuse.items():
stable[role]['details']['reuse_audit'] = audit
(analysis_dir / (generator.score.slugify(path.stem) + '.roles')).write_text(json.dumps(stable, sort_keys=True, indent=2))
return len(sources) * 5
+29
View File
@@ -0,0 +1,29 @@
"""Role cache persistence; cache identity never supplies musical randomness."""
import json
import generator.cache
import generator.fretboard
import generator.validation
ENTRYPOINTS = ('generator.composition',)
def code_key():
return generator.cache.fingerprint(generator.cache.source_identity(ENTRYPOINTS))
def restore(path, key):
if path is None or not path.is_file() or path.is_symlink():
return None
try:
entry = json.loads(path.read_text())
value = entry['result']
if entry['key'] == key and entry['digest'] == generator.cache.fingerprint(value):
generator.validation.validate_document(value['document'])
generator.fretboard.validate_document(value['document'])
return {**value, 'cache_hit': True}
except (OSError, ValueError, KeyError, TypeError):
pass
return None
def store(path, key, value):
if path is not None:
generator.cache.write_json(path, {'key': key, 'digest': generator.cache.fingerprint(value), 'result': value})
+67 -741
View File
@@ -1,755 +1,81 @@
"""Pure score navigation, cloning and identity operations."""
from __future__ import annotations from __future__ import annotations
import copy import copy
import json
import re import re
from pathlib import Path
from typing import Any
WHOLE_TICKS = 16384
HALF_TICKS = WHOLE_TICKS // 2
QUARTER_TICKS = WHOLE_TICKS // 4
EIGHTH_TICKS = WHOLE_TICKS // 8
SIXTEENTH_TICKS = WHOLE_TICKS // 16
THIRTY_SECOND_TICKS = WHOLE_TICKS // 32
RENDERABLE_TICKS = (
WHOLE_TICKS + HALF_TICKS,
WHOLE_TICKS,
HALF_TICKS + QUARTER_TICKS,
HALF_TICKS,
QUARTER_TICKS + EIGHTH_TICKS,
QUARTER_TICKS,
EIGHTH_TICKS + SIXTEENTH_TICKS,
EIGHTH_TICKS,
SIXTEENTH_TICKS + THIRTY_SECOND_TICKS,
SIXTEENTH_TICKS,
THIRTY_SECOND_TICKS,
)
REST_PITCH = {"letter": "b", "accidental": "n", "octave": 4}
FORBIDDEN_OUTPUT_FIELDS = {
"tab_positions",
"tab_frets",
"finger",
"fingers",
"fingering",
"left_hand_finger",
"right_hand_finger",
"chord_optional",
}
def load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as stream:
document = json.load(stream)
if not isinstance(document, dict):
raise ValueError(f"{path} is not a score document")
return normalize_score_document(document, path)
def write_json(path: Path, document: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as stream:
json.dump(document, stream, indent=2, ensure_ascii=False)
stream.write("\n")
def slugify(value: str) -> str: def slugify(value: str) -> str:
value = value.strip().lower() return re.sub(r'[^a-z0-9]+', '_', value.strip().lower()).strip('_') or 'score'
value = re.sub(r"[^a-z0-9]+", "-", value)
value = value.strip("-")
return value or "score"
def source_staff(document: dict) -> dict:
if len(document.get('staves', [])) != 1:
raise ValueError('exactly one source staff is required')
return document['staves'][0]
def human_label(value: str) -> str: def measure_notes(measure: dict) -> list[dict]:
text = value.replace("_", " ").replace("-", " ").strip() return [note for voice in measure['voices'] for note in voice['notes']]
return " ".join(part.capitalize() for part in text.split()) or "General"
def leading_pickup_measure_index(measures: list[dict]) -> int | None:
return 0 if len(measures) > 1 and measures[0].get('implicit') else None
class CompactScoreError(ValueError): def body_start_measure_index(measures: list[dict]) -> int:
pass return 1 if leading_pickup_measure_index(measures) is not None else 0
def copy_source_voices(measure: dict, *, strip_lyrics: bool) -> list[dict]:
DURATION_TICKS = { voices = copy.deepcopy(measure['voices'])
"1": WHOLE_TICKS,
"2": HALF_TICKS,
"4": QUARTER_TICKS,
"8": EIGHTH_TICKS,
"16": SIXTEENTH_TICKS,
"32": THIRTY_SECOND_TICKS,
}
def score_field(document: dict[str, Any], metadata: dict[str, Any], *names: str, fallback: str = "") -> str:
for name in names:
value = document.get(name)
if isinstance(value, str):
return value
value = metadata.get(name)
if isinstance(value, str):
return value
return fallback
def parse_duration_token(text: str, label: str) -> int:
dotted = text.endswith(".")
base_text = text[:-1] if dotted else text
if base_text not in DURATION_TICKS:
raise CompactScoreError(f"{label}: unsupported duration {text!r}")
ticks = DURATION_TICKS[base_text]
return ticks + ticks // 2 if dotted else ticks
def compact_pitch(letter: str, accidental: str, octave: int) -> dict[str, Any]:
return {
"letter": letter.lower(),
"accidental": accidental or "n",
"octave": octave,
}
class CompactMeasureParser:
def __init__(self, text: str, *, measure_number: int, time_signature: str) -> None:
self.text = text
self.index = 0
self.measure_number = measure_number
self.time_signature = time_signature
self.sequence = 0
self.tuplet_sequence = 0
self.pending_chord = ""
self.pending_chord_optional = False
self.last_note: dict[str, Any] | None = None
self.last_octave = 4
self.tuplets: list[dict[str, Any]] = []
def label(self) -> str:
return f"measure {self.measure_number}"
def error(self, message: str) -> CompactScoreError:
return CompactScoreError(f"{self.label()}, column {self.index + 1}: {message}")
def peek(self) -> str:
return self.text[self.index] if self.index < len(self.text) else ""
def skip_space(self) -> None:
while self.peek() and self.peek().isspace():
self.index += 1
def parse_until(self, close: str, label: str) -> str:
start = self.index
escaped = False
while self.index < len(self.text):
char = self.text[self.index]
if escaped:
escaped = False
self.index += 1
continue
if char == "\\":
escaped = True
self.index += 1
continue
if char == close:
value = self.text[start:self.index]
self.index += 1
return bytes(value, "utf-8").decode("unicode_escape")
self.index += 1
raise self.error(f"unterminated {label}")
def parse_chord(self) -> None:
self.index += 1
chord = self.parse_until("]", "chord symbol").strip()
optional = chord.startswith("(") and chord.endswith(")")
if optional:
chord = chord[1:-1].strip()
if not chord:
raise self.error("chord symbol must not be empty")
self.pending_chord = chord
self.pending_chord_optional = optional
def parse_lyric(self) -> None:
if self.last_note is None:
raise self.error("lyric must follow a note")
self.index += 1
lyric = self.parse_until('"', "lyric")
self.last_note["lyric"] = lyric
def parse_tuplet_header(self) -> tuple[int, int]:
self.skip_space()
match = re.match(r"(\d+)\s*:\s*(\d+)", self.text[self.index:])
if match is None:
raise self.error("tuplet must start with N:M")
self.index += match.end()
note_count = int(match.group(1))
space_count = int(match.group(2))
if note_count <= 0 or space_count <= 0:
raise self.error("tuplet counts must be positive")
return note_count, space_count
def parse_tuplet(self) -> list[dict[str, Any]]:
self.index += 1
note_count, space_count = self.parse_tuplet_header()
self.tuplet_sequence += 1
tuplet_id = f"tuplet-{self.measure_number}-{self.tuplet_sequence}"
notes: list[dict[str, Any]] = []
while True:
self.skip_space()
if not self.peek():
raise self.error("unterminated tuplet")
if self.peek() == "}":
self.index += 1
break
if self.peek() == "{":
raise self.error("nested tuplets are not supported")
notes.extend(self.parse_item())
if len(notes) != note_count:
raise self.error(f"tuplet {tuplet_id} contains {len(notes)} notes/rests, expected {note_count}")
self.apply_tuplet_duration(notes, note_count, space_count)
for note in notes:
note["tuplet_id"] = tuplet_id
self.tuplets.append({
"id": tuplet_id,
"note_count": note_count,
"space_count": space_count,
})
return notes
def apply_tuplet_duration(self, notes: list[dict[str, Any]], note_count: int, space_count: int) -> None:
numerators = [int(note["duration_ticks"]) * space_count for note in notes]
floors = [value // note_count for value in numerators]
target_total = sum(numerators) // note_count
remainder = target_total - sum(floors)
order = sorted(range(len(notes)), key=lambda index: numerators[index] % note_count, reverse=True)
actual = floors[:]
for index in order[:remainder]:
actual[index] += 1
for note, ticks in zip(notes, actual):
display = int(note["duration_ticks"])
note["duration_ticks"] = max(1, ticks)
note["display_ticks"] = display
def parse_pitch_atom(self) -> dict[str, Any]:
start = self.index
char = self.peek()
if char.lower() not in "abcdefg":
raise self.error("expected pitch")
self.index += 1
accidental = ""
if self.peek() in {"#", "b", "n"}:
accidental = self.peek()
self.index += 1
octave_start = self.index
while self.peek().isdigit():
self.index += 1
if self.index > octave_start:
self.last_octave = int(self.text[octave_start:self.index])
if self.index == start:
raise self.error("expected pitch")
return compact_pitch(char, accidental, self.last_octave)
def parse_pitch_group(self) -> list[dict[str, Any]]:
self.index += 1
pitches: list[dict[str, Any]] = []
while True:
self.skip_space()
if not self.peek():
raise self.error("unterminated pitch group")
if self.peek() == ")":
self.index += 1
break
pitches.append(self.parse_pitch_atom())
if not pitches:
raise self.error("pitch group must contain at least one pitch")
return pitches
def parse_note(self, tie_from_previous: bool = False) -> dict[str, Any]:
start = self.index
char = self.peek()
if not char:
raise self.error("expected note or rest")
kind = "rest" if char.lower() == "r" else "note"
if kind == "rest":
self.index += 1
pitches = [copy.deepcopy(REST_PITCH)]
elif char == "(":
pitches = self.parse_pitch_group()
else:
pitches = [self.parse_pitch_atom()]
if self.peek() != ":":
raise self.error(f"expected ':' after {self.text[start:self.index]!r}")
self.index += 1
duration_start = self.index
while self.peek().isdigit():
self.index += 1
if self.peek() == ".":
self.index += 1
duration_text = self.text[duration_start:self.index]
if not duration_text:
raise self.error("duration is required")
self.sequence += 1
note: dict[str, Any] = {
"id": f"note-{self.measure_number}-{self.sequence}",
"kind": kind,
"duration_ticks": parse_duration_token(duration_text, self.label()),
"pitches": pitches,
"hidden": False,
}
if self.pending_chord:
note["chord_symbol"] = self.pending_chord
if self.pending_chord_optional:
note["chord_optional"] = True
self.pending_chord = ""
self.pending_chord_optional = False
if tie_from_previous:
note["tie_from_previous"] = True
if self.peek() == "~":
note["tie_to_next"] = True
self.index += 1
self.last_note = note
return note
def parse_item(self) -> list[dict[str, Any]]:
self.skip_space()
char = self.peek()
if not char:
return []
if char == "[":
self.parse_chord()
return []
if char == '"':
self.parse_lyric()
return []
if char == "{":
return self.parse_tuplet()
if char == "}":
return []
if char == "~":
self.index += 1
self.skip_space()
next_char = self.peek()
if next_char and (next_char.lower() in "abcdefgr" or next_char == "("):
return [self.parse_note(tie_from_previous=True)]
if self.last_note is None:
raise self.error("tie marker must attach to a note")
self.last_note["tie_to_next"] = True
return []
if char.lower() in "abcdefgr" or char == "(":
return [self.parse_note()]
raise self.error("expected note, rest, chord, lyric, tie, or tuplet")
def parse(self) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
notes: list[dict[str, Any]] = []
while True:
self.skip_space()
if not self.peek():
break
notes.extend(self.parse_item())
if self.pending_chord:
raise self.error(f"chord {self.pending_chord!r} is not followed by a note or rest")
total = sum(int(note.get("duration_ticks", 0)) for note in notes)
capacity = measure_capacity_ticks(self.time_signature)
if total > capacity:
raise CompactScoreError(f"{self.label()}: duration total {total} exceeds {self.time_signature} capacity {capacity}")
return notes, self.tuplets
def compact_measure_text(value: Any, index: int) -> tuple[str, dict[str, str]]:
if isinstance(value, str):
return value, {}
if isinstance(value, dict):
text = value.get("notes", value.get("notation", ""))
if not isinstance(text, str):
raise CompactScoreError(f"measure {index + 1}: notes must be a string")
overrides: dict[str, str] = {}
for source, target in (
("section", "section_label"),
("section_label", "section_label"),
("key", "key_signature"),
("key_signature", "key_signature"),
("time", "time_signature"),
("time_signature", "time_signature"),
):
field = value.get(source)
if isinstance(field, str):
overrides[target] = field
return text, overrides
raise CompactScoreError(f"measure {index + 1}: compact measure must be a string or object")
def normalize_score_document(document: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
if isinstance(document.get("staves"), list):
return document
measures_value = document.get("measures")
if not isinstance(measures_value, list):
return document
metadata_value = document.get("metadata", {})
metadata = metadata_value if isinstance(metadata_value, dict) else {}
title = score_field(document, metadata, "title", fallback=human_label(path.stem if path is not None else "score"))
composer = score_field(document, metadata, "composer")
key_signature = score_field(document, metadata, "key_signature", "key", fallback="C")
time_signature = score_field(document, metadata, "time_signature", "time", fallback="4/4")
clef = score_field(document, metadata, "clef", fallback="treble")
part = score_field(document, metadata, "part", "name", fallback="Vocal")
role = score_field(document, metadata, "role", fallback="vocal")
output_metadata = sanitize_json(metadata)
if not isinstance(output_metadata, dict):
output_metadata = {}
output_metadata.update({
"title": title,
"composer": composer,
"key_signature": key_signature,
"time_signature": time_signature,
})
for key, value in document.items():
if key in {"title", "composer", "key", "key_signature", "time", "time_signature", "clef", "part", "name", "role", "measures", "metadata"}:
continue
output_metadata[key] = sanitize_json(value)
measures: list[dict[str, Any]] = []
for index, value in enumerate(measures_value):
text, overrides = compact_measure_text(value, index)
measure_time = overrides.get("time_signature", time_signature)
measure_key = overrides.get("key_signature", key_signature)
parser = CompactMeasureParser(text, measure_number=index + 1, time_signature=measure_time)
notes, tuplets = parser.parse()
measures.append({
"id": f"measure-{index + 1}",
"section_label": overrides.get("section_label", ""),
"time_signature": measure_time,
"key_signature": measure_key,
"voices": [{
"id": f"voice-{index + 1}",
"notes": notes,
"tuplets": tuplets,
}],
})
return {
"version": int(document.get("version", 1)) if isinstance(document.get("version", 1), int) else 1,
"metadata": output_metadata,
"staves": [{
"id": "staff-source",
"name": part,
"clef": clef,
"role": role,
"measures": measures,
}],
}
def source_staff(document: dict[str, Any]) -> dict[str, Any]:
staves = document.get("staves")
if not isinstance(staves, list) or not staves:
raise ValueError("score document must contain at least one staff")
first_staff: dict[str, Any] | None = None
for value in staves:
if not isinstance(value, dict):
continue
if first_staff is None:
first_staff = value
staff_id = str(value.get("id", ""))
if not value.get("arranged") and staff_id not in {"staff-arranged-guitar", "staff-arranged-vocal", "staff-arranged-practice"} and value.get("role") != "guitar":
return value
if first_staff is None:
raise ValueError("score document does not contain an object staff")
return first_staff
def measure_capacity_ticks(time_signature: str) -> int:
try:
beats_text, unit_text = time_signature.split("/", 1)
beats = int(beats_text)
unit = int(unit_text)
except ValueError:
return WHOLE_TICKS
if beats <= 0 or unit <= 0:
return WHOLE_TICKS
return WHOLE_TICKS * beats // unit
def note_duration(note: dict[str, Any]) -> int:
value = note.get("duration_ticks", QUARTER_TICKS)
return max(1, int(value)) if isinstance(value, int) else QUARTER_TICKS
def renderable_duration(duration_ticks: int) -> bool:
return duration_ticks in RENDERABLE_TICKS
def renderable_duration_parts(duration_ticks: int) -> list[int]:
parts: list[int] = []
remaining = duration_ticks
while remaining > 0:
match = next((duration for duration in RENDERABLE_TICKS if duration <= remaining), None)
if match is None:
return [duration_ticks]
parts.append(match)
remaining -= match
return parts
def first_voice(measure: dict[str, Any]) -> dict[str, Any]:
voices = measure.get("voices")
if isinstance(voices, list) and voices and isinstance(voices[0], dict):
return voices[0]
return {"id": "voice-empty", "tuplets": [], "notes": []}
def note_chord_symbol(note: dict[str, Any], *, include_optional: bool = True) -> str:
chord = note.get("chord_symbol")
if not isinstance(chord, str) or not chord:
return ""
if not include_optional and note.get("chord_optional") is True:
return ""
return chord
def first_chord_symbol(measure: dict[str, Any], fallback: str = "", *, include_optional: bool = True) -> str:
voice = first_voice(measure)
notes = voice.get("notes")
if not isinstance(notes, list):
return fallback
has_filtered_chord = False
for note in notes:
if isinstance(note, dict):
if isinstance(note.get("chord_symbol"), str) and note.get("chord_symbol"):
has_filtered_chord = True
chord = note_chord_symbol(note, include_optional=include_optional)
if chord:
return chord
if not include_optional and has_filtered_chord:
return ""
return fallback
def measure_notes(measure: dict[str, Any]) -> list[dict[str, Any]]:
voice = first_voice(measure)
notes = voice.get("notes")
return [note for note in notes if isinstance(note, dict)] if isinstance(notes, list) else []
def timed_measure_notes(measure: dict[str, Any]) -> list[tuple[int, dict[str, Any]]]:
cursor = 0
result: list[tuple[int, dict[str, Any]]] = []
for note in measure_notes(measure):
result.append((cursor, note))
cursor += note_duration(note)
return result
def chord_markers(measure: dict[str, Any], *, include_optional: bool = True) -> list[tuple[int, int, str]]:
markers: list[tuple[int, int, str]] = []
for start_ticks, note in timed_measure_notes(measure):
chord = note_chord_symbol(note, include_optional=include_optional)
if chord:
markers.append((start_ticks, note_duration(note), chord))
return markers
def chord_symbol_at_offset(measure: dict[str, Any], offset_ticks: int, *, include_optional: bool = True) -> str:
for start_ticks, _, chord in chord_markers(measure, include_optional=include_optional):
if start_ticks == offset_ticks:
return chord
return ""
def active_chord_at_offset(measure: dict[str, Any], offset_ticks: int, fallback: str = "", *, include_optional: bool = True) -> str:
current = fallback
for start_ticks, _, chord in chord_markers(measure, include_optional=include_optional):
if start_ticks > offset_ticks:
break
current = chord
return current
def source_note_attack_at(measure: dict[str, Any], offset_ticks: int) -> bool:
for start_ticks, note in timed_measure_notes(measure):
if start_ticks == offset_ticks and note.get("kind", "note") == "note" and isinstance(note.get("pitches"), list) and note["pitches"]:
return True
return False
def first_pitched_note_start(measure: dict[str, Any]) -> int | None:
cursor = 0
for note in measure_notes(measure):
if note.get("kind", "note") == "note" and isinstance(note.get("pitches"), list) and note["pitches"]:
return cursor
cursor += note_duration(note)
return None
def leading_pickup_measure_index(measures: list[dict[str, Any]]) -> int | None:
if len(measures) < 2 or first_chord_symbol(measures[0]):
return None
first_pitch = first_pitched_note_start(measures[0])
if first_pitch is None or first_pitch <= 0:
return None
for index, measure in enumerate(measures):
if first_chord_symbol(measure):
return 0 if index == 1 else None
return None
def body_start_measure_index(measures: list[dict[str, Any]]) -> int:
pickup = leading_pickup_measure_index(measures)
return pickup + 1 if pickup is not None else 0
def rest_note(note_id: str, duration_ticks: int, chord_symbol: str = "") -> dict[str, Any]:
note: dict[str, Any] = {
"id": note_id,
"kind": "rest",
"duration_ticks": duration_ticks,
"pitches": [copy.deepcopy(REST_PITCH)],
"hidden": False,
}
if chord_symbol:
note["chord_symbol"] = chord_symbol
return note
def renderable_rest_notes(note_id_prefix: str, duration_ticks: int, sequence: int, chord_symbol: str = "") -> tuple[list[dict[str, Any]], int]:
notes: list[dict[str, Any]] = []
for index, duration in enumerate(renderable_duration_parts(duration_ticks)):
sequence += 1
notes.append(rest_note(f"{note_id_prefix}-{sequence}", duration, chord_symbol if index == 0 else ""))
return notes, sequence
def apply_source_chord_markers(notes: list[dict[str, Any]], measure: dict[str, Any], *, include_optional: bool = True) -> list[dict[str, Any]]:
markers = [(start, chord) for start, _, chord in chord_markers(measure, include_optional=include_optional)]
if not markers:
result = [copy.deepcopy(note) for note in notes]
for note in result:
note.pop("chord_symbol", None)
return result
unique_markers: list[tuple[int, str]] = []
for start, chord in markers:
if unique_markers and unique_markers[-1][0] == start:
unique_markers[-1] = (start, chord)
else:
unique_markers.append((start, chord))
marker_by_start = dict(unique_markers)
marker_starts = [start for start, _ in unique_markers]
result: list[dict[str, Any]] = []
cursor = 0
split_sequence = 0
for source_note in notes:
note = copy.deepcopy(source_note)
note.pop("chord_symbol", None)
duration = note_duration(note)
end = cursor + duration
split_points = [cursor] + [start for start in marker_starts if cursor < start < end] + [end]
for index in range(len(split_points) - 1):
start = split_points[index]
stop = split_points[index + 1]
part_duration = stop - start
if part_duration <= 0:
continue
part = copy.deepcopy(note)
part["duration_ticks"] = part_duration
if len(split_points) > 2:
split_sequence += 1
part["id"] = f"{note.get('id', 'note')}-split-{split_sequence}"
if part.get("kind", "note") == "note":
part["tie_to_next"] = index + 2 < len(split_points)
part["tie_from_previous"] = index > 0
if start in marker_by_start:
part["chord_symbol"] = marker_by_start[start]
result.append(part)
cursor = end
return result
def slash_note(note_id: str, duration_ticks: int, chord_symbol: str = "") -> dict[str, Any]:
note: dict[str, Any] = {
"id": note_id,
"kind": "note",
"duration_ticks": duration_ticks,
"pitches": [copy.deepcopy(REST_PITCH)],
"notehead": "slash",
"hidden": False,
}
if chord_symbol:
note["chord_symbol"] = chord_symbol
return note
def sanitize_json(value: Any) -> Any:
if isinstance(value, dict):
return {key: sanitize_json(item) for key, item in value.items() if key not in FORBIDDEN_OUTPUT_FIELDS}
if isinstance(value, list):
return [sanitize_json(item) for item in value]
return copy.deepcopy(value)
def sanitized_note(note: dict[str, Any], *, strip_lyrics: bool, include_optional: bool = True) -> dict[str, Any]:
output = sanitize_json(note)
if strip_lyrics: if strip_lyrics:
output.pop("lyric", None) for note in (n for v in voices for n in v['notes']):
if not include_optional and note.get("chord_optional") is True: note.pop('lyric', None)
output.pop("chord_symbol", None) note.pop('lyric_notation', None)
output.setdefault("hidden", False) note.pop('lyrics', None)
return output return voices
def make_measure(measure_id: str, source: dict, notes: list[dict], *, section_label: str = '') -> dict:
result = copy.deepcopy(source)
result.update({'id': measure_id, 'section_label': section_label, 'voices': [{'id': 'voice_arranged', 'notes': notes}]})
for note in notes:
note['voice'] = 'voice_arranged'
return result
def copy_source_voice(measure: dict[str, Any], *, strip_lyrics: bool, include_optional: bool = True) -> dict[str, Any]: def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
voice = first_voice(measure) """Clone-local links survive; links cut by an arrangement boundary become attacks."""
tuplets = sanitize_json(voice.get("tuplets", [])) tone_map, event_map = {}, {}
notes = [sanitized_note(note, strip_lyrics=strip_lyrics, include_optional=include_optional) for note in measure_notes(measure)] tuplets = []
return { for measure in measures:
"id": str(voice.get("id", "voice-source")), for hi, harmony in enumerate(measure.get('harmonies', [])):
"tuplets": tuplets if isinstance(tuplets, list) else [], harmony['id'] = f'{measure["id"]}_h{hi + 1}'
"notes": notes or [rest_note("arr-rest-empty", measure_capacity_ticks(str(measure.get("time_signature", "4/4"))))], for ni, note in enumerate(measure_notes(measure)):
} old = note['id']
note['id'] = f'{measure["id"]}_n{ni + 1}'
event_map[old] = note['id']
for pi, pitch in enumerate(note['pitches']):
old_pitch = pitch['id']
pitch['id'] = f'{note["id"]}_p{pi + 1}'
tone_map[old_pitch] = pitch['id']
for measure in measures:
for note in measure_notes(measure):
for p in note['pitches']:
for field in ('tie_from', 'tie_to'):
if p.get(field) in tone_map:
p[field] = tone_map[p[field]]
else:
p.pop(field, None)
if note.get('tremolo', {}).get('partner'):
partner = note['tremolo']['partner']
if partner in event_map:
note['tremolo']['partner'] = event_map[partner]
else:
note['tremolo'] = {**note['tremolo'], 'type': 'single'}
note['tremolo'].pop('partner', None)
for source in source_tuplets:
members = [n for m in measures for n in measure_notes(m) if source['id'] in n.get('tuplet_ids', [])]
if not members:
continue
t = copy.deepcopy(source)
t.update({'id': f'{measures[0]["id"]}_{source["id"]}', 'start': members[0]['id'], 'end': members[-1]['id'], 'continued_start': source['start'] not in event_map, 'continued_end': source['end'] not in event_map})
for n in members:
n['tuplet_ids'] = [t['id'] if v == source['id'] else v for v in n['tuplet_ids']]
tuplets.append(t)
return tuplets
def make_arranged_document(source_document: dict, staff: dict, *, staff_id: str, staff_name: str, role: str, arrangement_part_type: str, measures: list[dict], tuplets=None, octave_shifts=None) -> dict:
def make_measure(measure_id: str, source: dict[str, Any], notes: list[dict[str, Any]], *, section_label: str = "", tuplets: list[Any] | None = None) -> dict[str, Any]: return {'metadata': copy.deepcopy(source_document['metadata']), 'staves': [{'id': staff_id, 'name': staff_name, 'clef': copy.deepcopy(staff['clef']), 'role': role, 'arranged': True, 'arrangement_part_type': arrangement_part_type, 'measures': measures, 'tuplets': tuplets or [], 'octave_shifts': copy.deepcopy(octave_shifts or []), 'instruments': copy.deepcopy(staff.get('instruments', []))}]}
measure: dict[str, Any] = {
"id": measure_id,
"time_signature": str(source.get("time_signature", "4/4")),
"key_signature": str(source.get("key_signature", "C")),
"voices": [{
"id": f"voice-{measure_id}",
"tuplets": tuplets or [],
"notes": notes,
}],
}
if section_label:
measure["section_label"] = section_label
return measure
def make_arranged_document(source_document: dict[str, Any], staff: dict[str, Any], *, staff_id: str, staff_name: str, role: str, arrangement_part_type: str, measures: list[dict[str, Any]]) -> dict[str, Any]:
metadata = sanitize_json(source_document.get("metadata", {}))
if not isinstance(metadata, dict):
metadata = {}
output: dict[str, Any] = {
"version": int(source_document.get("version", 1)) if isinstance(source_document.get("version", 1), int) else 1,
"metadata": metadata,
"staves": [{
"id": staff_id,
"name": staff_name,
"clef": str(staff.get("clef", "treble")),
"role": role,
"arranged": True,
"arrangement_part_type": arrangement_part_type,
"measures": measures,
}],
}
return sanitize_json(output)
def count_json_files(root: Path) -> int:
return sum(1 for path in root.rglob("*.json") if path.is_file())
def validate_generated_tree(root: Path) -> None:
for path in root.rglob("*.json"):
text = path.read_text(encoding="utf-8")
for field in FORBIDDEN_OUTPUT_FIELDS:
if f'"{field}"' in text:
raise ValueError(f"{path} contains forbidden generated field {field}")
json.loads(text)
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
import json
import pathlib
import generator.engraving.validation
import generator.fretboard
import generator.performance
import generator.pico_artwork
import generator.validation
def load_json(path: pathlib.Path) -> dict:
document = json.loads(path.read_text(encoding='utf-8'))
return validate_json(document, path)
def validate_json(document: dict, path: pathlib.Path, validated_pages: set[str] | None = None) -> dict:
if not isinstance(document, dict):
raise ValueError('expected a score object')
if 'music' in document:
presentation = document.get('presentation', {})
if not isinstance(document['music'], dict) or not isinstance(presentation, dict) or not isinstance(presentation.get('pages'), list) or not presentation['pages']:
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):
raise ValueError('unsupported score presentation; regenerate score assets')
generator.performance.validate_views(document['music'], presentation['pages'], presentation.get('playback_pages'))
if document.get('performance') != generator.performance.timing(document['music']):
raise ValueError('invalid performance timing')
for page in presentation['pages'] + presentation['playback_pages']:
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')
name = page['path']
relative = pathlib.Path(name)
if '\x00' in name or '\\' in name or relative.is_absolute() or any(part in ('', '.', '..') for part in name.split('/')) or relative.suffix != '.png':
raise ValueError('invalid score page path')
resolved = (path.parent / relative).resolve()
if not resolved.is_relative_to(path.parent.resolve()):
raise ValueError('score page escapes its asset directory')
generator.engraving.validation.check_page(resolved, validated_pages, expected_sha256=page['sha256'])
document = document['music']
generator.validation.validate_document(document)
return document
def write_json(path: pathlib.Path, document: dict) -> None:
generator.validation.validate_document(document)
generator.fretboard.validate_document(document)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def count_json_files(root: pathlib.Path) -> int:
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:
validated_pages = set() if validated_pages is None else validated_pages
for path in root.rglob('*.json'):
document = json.loads(path.read_text(encoding='utf-8'))
music = validate_json(document, path, validated_pages)
generator.fretboard.validate_document(music)
if 'presentation' in document:
generator.pico_artwork.validate_title(root, document['presentation'].get('title_image'))
+1
View File
@@ -0,0 +1 @@
"""Build-time phrase composition; runtime consumes ordinary score events."""
+14
View File
@@ -0,0 +1,14 @@
import fractions
import generator.score
import generator.model
import generator.arrange.form
def regions(document):
result, first, start = {}, 0, fractions.Fraction()
source = generator.score.source_staff(document)['measures']
for section in generator.arrange.form.full_arrangement_sections(source, 'acoustic'):
length = sum((generator.model.fraction(m['duration']) for m in source[section.source_measure_index:section.source_measure_index + section.measure_count]), fractions.Fraction())
result[section.section_index] = (first, section.measure_count, start, length)
first += section.measure_count
start += length
return result
+100
View File
@@ -0,0 +1,100 @@
"""Explicit registered repertoire. Authored playback is not model prediction.
Matching is by canonical source music and role, never title or filename. This
path intentionally reads the registered solo, following the user's exact-reuse
policy. Unknown song/role pairs go to the learned composer.
"""
from __future__ import annotations
import dataclasses
import fractions
import hashlib
import json
import pathlib
import generator.harmonic_time
import generator.musicxml
import generator.parts
import generator.solo.harmonic
import generator.solo.placement
import generator.solo.profiles
import generator.solo.types
import generator.source
import generator.training_io
import generator.vocabulary
def sounding(evidence, offset=fractions.Fraction()):
result = []
for i, event in enumerate(evidence['sounding_events']):
p = event['pitch']
number = fractions.Fraction(event['sounding_midi'])
if number.denominator != 1:
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)
def validate_evidence(document, evidence, *, composition=None):
"""Training uses the same source-form harmonic context as solo playback."""
composition = composition or generator.vocabulary.current()
h = generator.solo.harmonic.Harmony(generator.harmonic_time.solo_source(document['staves'][0]), composition=composition)
checked = h.evaluate(sounding(evidence), complete=True)
if not checked.valid:
raise ValueError('Unsupported harmonic relationship in authored solo: ' + checked.error)
return checked
def lookup(source, role, data_dir):
root = pathlib.Path(data_dir)
manifest = root / 'training/manifest.json'
if not manifest.exists():
return None
value = json.loads(manifest.read_text())
if value.get('version') != 1:
raise ValueError('Unsupported training manifest')
matches = []
for entry in value['examples']:
if entry['slot'] != (1 if role == 'acoustic' else 2):
continue
path = (manifest.parent / entry['source']).resolve()
if not path.is_relative_to((root / 'songs').resolve()):
raise ValueError('Registered sources must be in data/songs')
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
if generator.source.SourceAnalysis.read(doc).musical_key == source.musical_key:
target = (manifest.parent / entry['solo']).resolve()
if not target.is_relative_to(manifest.parent.resolve()):
raise ValueError('Registered solo must be inside data/training')
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()})
if len(matches) > 1:
raise ValueError('Multiple authored solos registered for the same source music and role')
return matches[0] if matches else None
def realize(entry, source_analysis, measures, part, start, end, initial=None, *, composition=None):
composition = composition or generator.vocabulary.current()
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']))
expected = [(a, b - a) for a, b in zip(source_analysis.boundaries, source_analysis.boundaries[1:])]
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)
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')
profile = generator.solo.profiles.for_part(part, source_analysis)
illegal = next((n for n in notes if not generator.solo.placement.positions(n.pitch.number(), profile)), None)
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')
placed = generator.solo.placement.place(tuple(notes), source_analysis.tempo, profile=profile)
if placed is None:
raise ValueError(entry['id'] + ': no legal complete guitar placement')
spans = tuple(dataclasses.replace(s, start=s.start + start, end=s.end + start) for s in generator.harmonic_time.read({'measures': measures}, initial=initial))
checked = generator.solo.harmonic.Harmony(spans, composition=composition).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'],
'pitch_convention': entry['pitch_convention'], 'exact_sounding_events': True,
'note': 'Authored music is preserved and passes automatic contextual harmonic validation.',
'annotations': evidence['annotations']}, profile, start)

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