@@ -103,7 +103,7 @@ jobs:
|
|||||||
- 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
|
- name: Test asset generation
|
||||||
run: python3 -m unittest tests.test_score_generator
|
run: python3 -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:
|
||||||
|
|||||||
@@ -70,34 +70,18 @@ Artifacts are published to the khodo generic package registry.
|
|||||||
|
|
||||||
## 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 scores exported from MuseScore live in `data/`. Generated score JSON is not committed. 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
|
python3 -m generator --data-dir data --output-dir /tmp/product-vamp-generated --static-assets-dir package/assets --clean --check
|
||||||
```
|
```
|
||||||
|
|
||||||
The `--check` path compares generation invariants against `data/expected/performance_summary.json`.
|
The `--check` path compares generation invariants against `data/expected/performance_summary.json`.
|
||||||
|
The pinned build-only dependencies are in `tools/requirements.txt`. Configure/build automatically refresh the project build image when this file changes; direct builds must install it in their Python environment.
|
||||||
|
|
||||||
Authored score files use compact JSON. Metadata stays as normal JSON fields, while each measure is a readable notation string. The optional `style` field must be a supported built-in player style; compact aliases `Med.`, `Med. Swing`, and `Medium` normalize to `Medium Swing`.
|
The build-time pipeline is MuseScore -> `.musicxml` -> importer -> arranger/transposer -> schema-v2 JSON. The Pi reads only JSON. Performance sources produce acoustic, electric, and vocal parts; melody exercises produce twelve tonic variants, and chord exercises keep their authored key.
|
||||||
|
|
||||||
```json
|
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.
|
||||||
{
|
|
||||||
"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. A measure may also be an object with `notes` plus MusicXML-style metadata fields such as `attributes`, `directions`, `harmony`, or `barlines`.
|
|
||||||
|
|
||||||
Bundled fonts are OTF files in `package/assets/fonts/petaluma`: `Petaluma.otf` supplies music symbols and `PetalumaScript.otf` supplies text. Petaluma is from Steinberg under the SIL Open Font License. Runtime packages include these two fonts and `OFL.txt`; the upstream metadata is kept only as a source reference.
|
Bundled fonts are OTF files in `package/assets/fonts/petaluma`: `Petaluma.otf` supplies music symbols and `PetalumaScript.otf` supplies text. Petaluma is from Steinberg under the SIL Open Font License. Runtime packages include these two fonts and `OFL.txt`; the upstream metadata is kept only as a source reference.
|
||||||
|
|
||||||
|
|||||||
+54
-2
@@ -9,7 +9,7 @@ Product Vamp follows the Modulus product layout used by the khodo repos:
|
|||||||
- `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.cpp`: shared color palette and Petaluma font interfaces. Bitmap font data is generated in the build directory.
|
- `include/vamp_style.hpp`, `include/vamp_style/font.hpp`, and `src/vamp_style.cpp`: shared color palette and Petaluma font interfaces. Bitmap font data is generated in the build directory.
|
||||||
- `data`: authored source score JSON for performance songs and practice exercises.
|
- `data`: authored MusicXML for performance songs and practice exercises, plus the generator regression summary.
|
||||||
- `generator`: build-time Python preprocessing for performance arrangements and practice variants.
|
- `generator`: build-time Python preprocessing for performance arrangements and practice variants.
|
||||||
- `package`: generic bundle inputs, scripts, config, static font assets, and device install files.
|
- `package`: generic bundle inputs, scripts, config, static font assets, and device install files.
|
||||||
|
|
||||||
@@ -37,7 +37,59 @@ Petaluma music symbols are placed from SMuFL bounding boxes and stem/flag anchor
|
|||||||
|
|
||||||
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 keeps notation metadata while removing generator-only optional chord markers; guitar playability logic is used only inside the arrangement generator.
|
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 keeps notation metadata while removing generator-only optional chord markers; guitar playability logic is used only inside the arrangement generator.
|
||||||
|
|
||||||
Authored source files use compact JSON and are normalized by the generator before arrangement. A root-level `measures` array means compact source; 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 ...}`. Measure objects may also carry notation metadata such as attributes, directions, harmony, and barlines so the data shape can grow toward MusicXML-style concepts without changing the compact note syntax. 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.
|
The source pipeline is MuseScore -> uncompressed `.musicxml` -> `generator/musicxml.py` -> arranger/transposer -> schema-v2 JSON. Export MusicXML 3.1 or 4.0, `score-partwise`, with one part, one staff, and one rhythmic voice. Chord tones share an onset but retain their own spelling, written duration, and tie links. Separate voices/staves must not be flattened into the source voice. The schema keeps staff/voice ownership explicit for a future polyphonic renderer.
|
||||||
|
|
||||||
|
### MusicXML Contract
|
||||||
|
|
||||||
|
| Area | Accepted notation |
|
||||||
|
| --- | --- |
|
||||||
|
| Pitch | Twelve-tone letter/alteration/octave spelling, including 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 | 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 clefs on lines 1-5, octave clefs and octave-shift spans; standard major/minor/modal keys or custom seven-letter alterations; key, clef, and meter changes. |
|
||||||
|
| 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. Leading rests are retained. |
|
||||||
|
| Harmony | Structured roots, kinds, inversions, slash basses, and degree additions/alterations/subtractions. Harmony events have their own offsets, including inside held notes. Their positions never split or shorten notes. |
|
||||||
|
| Text | Title, composer, part name, one lyric verse with syllabification and extenders, rehearsal labels at measure starts, and Vamp directives. |
|
||||||
|
|
||||||
|
Microtones, independent voices, multiple staves/parts, percussion/tab staves, repeats/navigation, slurs, dynamics, articulations, and expressive ornaments are rejected. Unknown musical elements also fail, rather than disappearing. Custom key-octave placement is not supported. Resource limits are explicit: serialized times use reduced signed-64-bit numerators/positive denominators; pitches use octaves -4 through 16 and alterations -3 through 3; clef/octave shifts span at most three octaves; meter counts are 1-1024 with denominator units 1-4096. These limits 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.
|
||||||
|
|
||||||
|
### Vamp Metadata
|
||||||
|
|
||||||
|
In MuseScore's score properties, set `vamp:style` to a supported player style. It exports as standard MusicXML miscellaneous metadata:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<miscellaneous>
|
||||||
|
<miscellaneous-field name="vamp:style">Medium Swing</miscellaneous-field>
|
||||||
|
</miscellaneous>
|
||||||
|
```
|
||||||
|
|
||||||
|
The default is Medium Swing; `Med.`, `Med. Swing`, and `Medium` remain aliases. A keyless melody exercise needs `vamp:tonic` (for example, `C`) as its transposition reference. Modes and local key changes are preserved when generating practice variants.
|
||||||
|
|
||||||
|
Place staff text `vamp:optional-chord` at the same onset as exactly one harmony to mark a turnaround omitted from the final arrangement section. The text may be hidden. Parenthesizing an ordinary chord does not make it optional. Orphaned/duplicate markers and unknown `vamp:` properties are errors; consumed directives never enter runtime JSON.
|
||||||
|
|
||||||
|
### Runtime Schema
|
||||||
|
|
||||||
|
`schema_version: 2` is mandatory. `metadata` and one `staves[]` entry contain measures and an explicit voice. Measure `duration`, event `onset`/`duration`, harmony offsets, and span endpoints use `{ "numerator": N, "denominator": D }` in quarter-note units. Fractions are reduced; there is no fixed tick grid and no remainder distribution. The native loader uses exact arithmetic for validation, converting only layout coordinates to floating point.
|
||||||
|
|
||||||
|
Events have `kind`, `written: { "type", "dots", "ratio"? }`, and independently identified pitches. Each pitch carries integer `alter`, `octave`, its own `duration` and `written`, and optional reciprocal `tie_from`/`tie_to` pitch IDs. Staff-level tuplets reference event IDs; octave spans reference absolute musical time. Attributes and harmonies are timestamped independently. The normalized harmony degree alteration is relative to the natural diatonic interval; the importer converts MusicXML's kind-relative alterations and dominant-relative additions.
|
||||||
|
|
||||||
|
The arranger normalizes transposing instruments to concert pitch before generating parts. Guitar parts explicitly use written pitches one octave above sounding pitch and a -1 octave instrument transposition. Chord pitch definitions are separate from playable voicing choices; voicings may omit a natural fifth or lower unaltered extensions, but never change a chord kind or replace an unsupported chord with an unrelated major chord. Unplayable combinations produce a labelled rhythmic slash and a build warning.
|
||||||
|
|
||||||
|
The renderer uses written values for glyphs, meter-aware/multilevel beams, per-pitch ties, timestamped harmony, and OTF-derived glyph bounds. Content-aware systems split dense measures into continuations. A canonical 976x484 layout makes page navigation stable across viewport sizes; vertical extent affects pagination instead of clamping pitches. The active page geometry is cached between redraws.
|
||||||
|
|
||||||
|
### Validation And Migration
|
||||||
|
|
||||||
|
Generation validates sources and complete staged output before replacing the previous asset directory. CMake tracks the input list as well as file contents, so additions and removals regenerate the catalogue. Runtime packages contain JSON and OTF fonts, not MusicXML or Python importer dependencies. Python test discovery runs all generator/importer tests; `tests/fixtures/notation.musicxml` exercises the broader renderer contract. The optional `vamp-render-score` test target captures a page using a desktop-capable Raylib build.
|
||||||
|
|
||||||
|
Diagnostics identify the file, part, measure, and unsupported element or invalid relationship. Examples include `part P1, measure 4: unsupported musical element <backup>`, `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. Existing catalogue rows refresh from regenerated JSON at startup. Custom database rows (`catalog_managed = 0`) are retained, including ID conflicts, and are never automatically deleted to resolve a schema mismatch. Custom legacy JSON must be reimported as schema v2; the loader reports a regeneration error instead of interpreting old ticks heuristically.
|
||||||
|
|
||||||
|
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 `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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+21
-1
@@ -61,6 +61,7 @@ set(VAMP_CORE_SOURCES
|
|||||||
src/vamp_zero/errors.cpp
|
src/vamp_zero/errors.cpp
|
||||||
src/vamp_zero/library.cpp
|
src/vamp_zero/library.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/portal.cpp
|
||||||
src/vamp_zero/score.cpp
|
src/vamp_zero/score.cpp
|
||||||
@@ -69,6 +70,17 @@ set(VAMP_CORE_SOURCES
|
|||||||
)
|
)
|
||||||
|
|
||||||
add_library(vamp-zero-core STATIC ${VAMP_CORE_SOURCES})
|
add_library(vamp-zero-core STATIC ${VAMP_CORE_SOURCES})
|
||||||
|
set(VAMP_SCORE_METRICS ${CMAKE_CURRENT_BINARY_DIR}/generated/vamp_zero/score_metrics.hpp)
|
||||||
|
add_custom_command(
|
||||||
|
OUTPUT ${VAMP_SCORE_METRICS}
|
||||||
|
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/generate_score_metrics.py
|
||||||
|
--font ${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/petaluma/Petaluma.otf
|
||||||
|
--output ${VAMP_SCORE_METRICS}
|
||||||
|
DEPENDS tools/generate_score_metrics.py tools/requirements.txt package/assets/fonts/petaluma/Petaluma.otf
|
||||||
|
VERBATIM
|
||||||
|
)
|
||||||
|
target_sources(vamp-zero-core PRIVATE ${VAMP_SCORE_METRICS})
|
||||||
|
target_include_directories(vamp-zero-core PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated)
|
||||||
target_include_directories(vamp-zero-core PUBLIC
|
target_include_directories(vamp-zero-core PUBLIC
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||||
)
|
)
|
||||||
@@ -103,15 +115,20 @@ 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/*.musicxml
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/data/*.json
|
${CMAKE_CURRENT_SOURCE_DIR}/data/*.json
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/data/**/*.json
|
${CMAKE_CURRENT_SOURCE_DIR}/data/**/*.json
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/generator/*.py
|
${CMAKE_CURRENT_SOURCE_DIR}/generator/*.py
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/generator/**/*.py
|
${CMAKE_CURRENT_SOURCE_DIR}/generator/**/*.py
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/tools/requirements.txt
|
||||||
${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)
|
||||||
|
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}
|
COMMAND ${Python3_EXECUTABLE}
|
||||||
@@ -123,10 +140,13 @@ add_custom_command(
|
|||||||
--check
|
--check
|
||||||
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
|
||||||
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
|
||||||
|
|||||||
@@ -22,9 +22,15 @@ class product_vamp_conan(conan.ConanFile):
|
|||||||
exports = "modulus.json"
|
exports = "modulus.json"
|
||||||
exports_sources = (
|
exports_sources = (
|
||||||
"CMakeLists.txt",
|
"CMakeLists.txt",
|
||||||
|
"modulus.py",
|
||||||
|
"modulus.json",
|
||||||
"cmake/*.cmake",
|
"cmake/*.cmake",
|
||||||
"data/*.json",
|
"data/*.json",
|
||||||
"data/**/*.json",
|
"data/**/*.json",
|
||||||
|
"data/*.musicxml",
|
||||||
|
"data/**/*.musicxml",
|
||||||
|
"tests/fixtures/*",
|
||||||
|
"tests/fixtures/**/*",
|
||||||
"generator/*.py",
|
"generator/*.py",
|
||||||
"generator/**/*.py",
|
"generator/**/*.py",
|
||||||
"include/*.hpp",
|
"include/*.hpp",
|
||||||
|
|||||||
+7196
-5987
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "01 Cycles",
|
|
||||||
"composer": "",
|
|
||||||
"key": "C",
|
|
||||||
"time": "4/4",
|
|
||||||
"clef": "treble",
|
|
||||||
"part": "Vocal",
|
|
||||||
"role": "vocal",
|
|
||||||
"style": "Medium 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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<score-partwise version="4.0">
|
||||||
|
<work>
|
||||||
|
<work-title>01 Cycles</work-title>
|
||||||
|
</work>
|
||||||
|
<identification>
|
||||||
|
<miscellaneous>
|
||||||
|
<miscellaneous-field name="vamp:style">Medium Swing</miscellaneous-field>
|
||||||
|
</miscellaneous>
|
||||||
|
</identification>
|
||||||
|
<part-list>
|
||||||
|
<score-part id="P1">
|
||||||
|
<part-name>Vocal</part-name>
|
||||||
|
</score-part>
|
||||||
|
</part-list>
|
||||||
|
<part id="P1">
|
||||||
|
<measure number="1">
|
||||||
|
<attributes>
|
||||||
|
<divisions>12</divisions>
|
||||||
|
<key>
|
||||||
|
<fifths>0</fifths>
|
||||||
|
<mode>major</mode>
|
||||||
|
</key>
|
||||||
|
<time>
|
||||||
|
<beats>4</beats>
|
||||||
|
<beat-type>4</beat-type>
|
||||||
|
</time>
|
||||||
|
<clef>
|
||||||
|
<sign>G</sign>
|
||||||
|
<line>2</line>
|
||||||
|
</clef>
|
||||||
|
</attributes>
|
||||||
|
<harmony>
|
||||||
|
<root>
|
||||||
|
<root-step>G</root-step>
|
||||||
|
</root>
|
||||||
|
<kind text="7">dominant</kind>
|
||||||
|
</harmony>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<harmony>
|
||||||
|
<root>
|
||||||
|
<root-step>C</root-step>
|
||||||
|
</root>
|
||||||
|
<kind text="7">dominant</kind>
|
||||||
|
</harmony>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="2">
|
||||||
|
<harmony>
|
||||||
|
<root>
|
||||||
|
<root-step>F</root-step>
|
||||||
|
</root>
|
||||||
|
<kind text="7">dominant</kind>
|
||||||
|
</harmony>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<harmony>
|
||||||
|
<root>
|
||||||
|
<root-step>B</root-step>
|
||||||
|
<root-alter>-1</root-alter>
|
||||||
|
</root>
|
||||||
|
<kind text="7">dominant</kind>
|
||||||
|
</harmony>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>3</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>3</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="3">
|
||||||
|
<harmony>
|
||||||
|
<root>
|
||||||
|
<root-step>E</root-step>
|
||||||
|
<root-alter>-1</root-alter>
|
||||||
|
</root>
|
||||||
|
<kind text="7">dominant</kind>
|
||||||
|
</harmony>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<harmony>
|
||||||
|
<root>
|
||||||
|
<root-step>A</root-step>
|
||||||
|
<root-alter>-1</root-alter>
|
||||||
|
</root>
|
||||||
|
<kind text="7">dominant</kind>
|
||||||
|
</harmony>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>3</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>3</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="4">
|
||||||
|
<harmony>
|
||||||
|
<root>
|
||||||
|
<root-step>D</root-step>
|
||||||
|
<root-alter>-1</root-alter>
|
||||||
|
</root>
|
||||||
|
<kind text="maj7">major-seventh</kind>
|
||||||
|
</harmony>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<harmony>
|
||||||
|
<root>
|
||||||
|
<root-step>D</root-step>
|
||||||
|
<root-alter>-1</root-alter>
|
||||||
|
</root>
|
||||||
|
<kind text="6">major-sixth</kind>
|
||||||
|
</harmony>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<chord />
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<alter>-1</alter>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>12</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>quarter</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
</part>
|
||||||
|
</score-partwise>
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "01 The Diatonic Scales",
|
|
||||||
"composer": "",
|
|
||||||
"key": "C",
|
|
||||||
"time": "4/4",
|
|
||||||
"clef": "treble",
|
|
||||||
"part": "Vocal",
|
|
||||||
"role": "vocal",
|
|
||||||
"style": "Medium 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
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "02 The Diatonic Scales",
|
|
||||||
"composer": "",
|
|
||||||
"key": "C",
|
|
||||||
"time": "4/4",
|
|
||||||
"clef": "treble",
|
|
||||||
"part": "Vocal",
|
|
||||||
"role": "vocal",
|
|
||||||
"style": "Medium Swing",
|
|
||||||
"measures": [
|
|
||||||
"C4:8 D4:8 E4:8 D4:8 E4:8 F4:8 E4:8 F4:8",
|
|
||||||
"G4:8 F4:8 G4:8 A4:8 G4:8 A4:8 B4:8 A4:8",
|
|
||||||
"B4:8 C5:8 B4:8 C5:8 D6:8 C5:8 D5:8 E5:8",
|
|
||||||
"D5:8 E5:8 F5:8 E5:8 F5:8 G5:8 F5:8 G5:8",
|
|
||||||
"A5:8 G5:8 A5:8 B5:8 A5:8 B5:8 C6:8 B5:8",
|
|
||||||
"C6:8 D6:8 C6:8 B5:8 C6:8 B5:8 A5:8 B5:8",
|
|
||||||
"A5:8 G5:8 A5:8 G5:8 F5:8 G5:8 F5:8 E5:8",
|
|
||||||
"F5:8 E5:8 D5:8 E5:8 D5:8 C5:8 D5:8 C5:8",
|
|
||||||
"B4:8 C5:8 B4:8 A4:8 G4:8 F4:8 E4:8 D4:8",
|
|
||||||
"C4:1"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,710 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<score-partwise version="4.0">
|
||||||
|
<work>
|
||||||
|
<work-title>02 The Diatonic Scales</work-title>
|
||||||
|
</work>
|
||||||
|
<identification>
|
||||||
|
<miscellaneous>
|
||||||
|
<miscellaneous-field name="vamp:style">Medium Swing</miscellaneous-field>
|
||||||
|
</miscellaneous>
|
||||||
|
</identification>
|
||||||
|
<part-list>
|
||||||
|
<score-part id="P1">
|
||||||
|
<part-name>Vocal</part-name>
|
||||||
|
</score-part>
|
||||||
|
</part-list>
|
||||||
|
<part id="P1">
|
||||||
|
<measure number="1">
|
||||||
|
<attributes>
|
||||||
|
<divisions>12</divisions>
|
||||||
|
<key>
|
||||||
|
<fifths>0</fifths>
|
||||||
|
<mode>major</mode>
|
||||||
|
</key>
|
||||||
|
<time>
|
||||||
|
<beats>4</beats>
|
||||||
|
<beat-type>4</beat-type>
|
||||||
|
</time>
|
||||||
|
<clef>
|
||||||
|
<sign>G</sign>
|
||||||
|
<line>2</line>
|
||||||
|
</clef>
|
||||||
|
</attributes>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="2">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="3">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>6</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="4">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="5">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>6</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="6">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>6</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>6</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>6</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>6</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="7">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="8">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="9">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="10">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>48</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>whole</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
</part>
|
||||||
|
</score-partwise>
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "03 The Diatonic Scales",
|
|
||||||
"composer": "",
|
|
||||||
"key": "C",
|
|
||||||
"time": "4/4",
|
|
||||||
"clef": "treble",
|
|
||||||
"part": "Vocal",
|
|
||||||
"role": "vocal",
|
|
||||||
"style": "Medium Swing",
|
|
||||||
"measures": [
|
|
||||||
"B3:8 C4:8 D4:8 E4:8 F4:8 E4:8 F4:8 G4:8",
|
|
||||||
"F4:8 E4:8 F4:8 G4:8 A4:8 B4:8 C5:8 B4:8",
|
|
||||||
"A4:8 B4:8 C5:8 D5:8 E5:8 F5:8 E5:8 D5:8",
|
|
||||||
"C5:8 B4:8 C5:8 D5:8 E5:8 F5:8 G5:8 A5:8",
|
|
||||||
"B5:8 C6:8 B5:8 A5:8 G5:8 A5:8 B5:8 C6:8",
|
|
||||||
"D6:8 E6:8 D6:8 C6:8 B5:8 C6:8 B5:8 A5:8",
|
|
||||||
"G5:8 A5:8 B5:8 A5:8 G5:8 F5:8 E5:8 F5:8",
|
|
||||||
"G5:8 F5:8 E5:8 D5:8 E5:8 F5:8 E5:8 D5:8",
|
|
||||||
"C5:8 B4:8 A4:8 G4:8 A4:8 B4:8 A4:8 G4:8",
|
|
||||||
"F4:8 E4:8 D4:8 C4:8 B3:8 C4:8 D4:8 E4:8",
|
|
||||||
"F4:1"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,784 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<score-partwise version="4.0">
|
||||||
|
<work>
|
||||||
|
<work-title>03 The Diatonic Scales</work-title>
|
||||||
|
</work>
|
||||||
|
<identification>
|
||||||
|
<miscellaneous>
|
||||||
|
<miscellaneous-field name="vamp:style">Medium Swing</miscellaneous-field>
|
||||||
|
</miscellaneous>
|
||||||
|
</identification>
|
||||||
|
<part-list>
|
||||||
|
<score-part id="P1">
|
||||||
|
<part-name>Vocal</part-name>
|
||||||
|
</score-part>
|
||||||
|
</part-list>
|
||||||
|
<part id="P1">
|
||||||
|
<measure number="1">
|
||||||
|
<attributes>
|
||||||
|
<divisions>12</divisions>
|
||||||
|
<key>
|
||||||
|
<fifths>0</fifths>
|
||||||
|
<mode>major</mode>
|
||||||
|
</key>
|
||||||
|
<time>
|
||||||
|
<beats>4</beats>
|
||||||
|
<beat-type>4</beat-type>
|
||||||
|
</time>
|
||||||
|
<clef>
|
||||||
|
<sign>G</sign>
|
||||||
|
<line>2</line>
|
||||||
|
</clef>
|
||||||
|
</attributes>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>3</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="2">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="3">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="4">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="5">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>6</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>6</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="6">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>6</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>6</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>6</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>6</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>6</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="7">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="8">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="9">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>5</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>A</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>G</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="10">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>B</step>
|
||||||
|
<octave>3</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>C</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>D</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>E</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>6</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>eighth</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
<measure number="11">
|
||||||
|
<note>
|
||||||
|
<pitch>
|
||||||
|
<step>F</step>
|
||||||
|
<octave>4</octave>
|
||||||
|
</pitch>
|
||||||
|
<duration>48</duration>
|
||||||
|
<voice>1</voice>
|
||||||
|
<type>whole</type>
|
||||||
|
</note>
|
||||||
|
</measure>
|
||||||
|
</part>
|
||||||
|
</score-partwise>
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Autumn Leaves",
|
|
||||||
"composer": "Joseph Kosma / Johnny Mercer / Jaques Prevert",
|
|
||||||
"key": "F",
|
|
||||||
"time": "4/4",
|
|
||||||
"clef": "treble",
|
|
||||||
"part": "Vocal",
|
|
||||||
"role": "vocal",
|
|
||||||
"style": "Medium Swing",
|
|
||||||
"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
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "It Could Happen to You",
|
|
||||||
"composer": "James Van Heusen / Johnny Burke",
|
|
||||||
"key": "Eb",
|
|
||||||
"time": "4/4",
|
|
||||||
"clef": "treble",
|
|
||||||
"part": "Vocal",
|
|
||||||
"role": "vocal",
|
|
||||||
"style": "Medium Swing",
|
|
||||||
"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
+33
-21
@@ -2,12 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import shutil
|
import shutil
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from generator.arrange.generate import generate_performance_scores
|
from generator.arrange.generate import generate_performance_scores
|
||||||
from generator.practice.generate import generate_practice_scores
|
from generator.practice.generate import generate_practice_scores
|
||||||
from generator.score import count_json_files, validate_generated_tree
|
from generator.score import count_json_files, validate_generated_tree
|
||||||
from generator.summary import validate_expected_performance_summary, validate_practice_variants
|
from generator.summary import validate_expected_performance_summary, validate_practice_variants
|
||||||
|
from generator.musicxml import load_musicxml
|
||||||
|
|
||||||
RUNTIME_FONT_FILES = ("Petaluma.otf", "PetalumaScript.otf", "OFL.txt")
|
RUNTIME_FONT_FILES = ("Petaluma.otf", "PetalumaScript.otf", "OFL.txt")
|
||||||
|
|
||||||
@@ -30,33 +32,43 @@ def parse_args() -> argparse.Namespace:
|
|||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def generate_assets(args: argparse.Namespace) -> None:
|
||||||
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()
|
||||||
|
|
||||||
if args.clean and output_dir.exists():
|
for source in sorted(data_dir.rglob('*.musicxml')):
|
||||||
shutil.rmtree(output_dir)
|
load_musicxml(source)
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
copy_runtime_fonts(static_assets_dir, output_dir)
|
|
||||||
|
|
||||||
performance_count = generate_performance_scores(data_dir / "songs", output_dir / "songs")
|
|
||||||
practice_count = generate_practice_scores(data_dir / "practice", output_dir / "practice")
|
|
||||||
|
|
||||||
validate_generated_tree(output_dir)
|
|
||||||
print(
|
|
||||||
"Generated Product Vamp assets: "
|
|
||||||
f"{performance_count} performance scores, "
|
|
||||||
f"{practice_count} practice score variants, "
|
|
||||||
f"{count_json_files(output_dir)} total JSON files."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.TemporaryDirectory(prefix='.vamp-assets-', dir=output_dir.parent) as temporary:
|
||||||
|
staged = Path(temporary) / 'assets'
|
||||||
|
if not args.clean and output_dir.exists():
|
||||||
|
shutil.copytree(output_dir, staged)
|
||||||
|
staged.mkdir(parents=True, exist_ok=True)
|
||||||
|
copy_runtime_fonts(static_assets_dir, staged)
|
||||||
|
performance_count = generate_performance_scores(data_dir / 'songs', staged / 'songs')
|
||||||
|
practice_count = generate_practice_scores(data_dir / 'practice', staged / 'practice')
|
||||||
|
validate_generated_tree(staged)
|
||||||
if args.check:
|
if args.check:
|
||||||
validate_expected_performance_summary(data_dir / "expected" / "performance_summary.json", output_dir / "songs")
|
validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
|
||||||
validate_practice_variants(output_dir / "practice")
|
validate_practice_variants(staged / 'practice')
|
||||||
print("Generator check passed.")
|
backup = Path(temporary) / 'previous'
|
||||||
|
if output_dir.exists():
|
||||||
|
output_dir.rename(backup)
|
||||||
|
try:
|
||||||
|
staged.rename(output_dir)
|
||||||
|
except OSError:
|
||||||
|
if backup.exists():
|
||||||
|
backup.rename(output_dir)
|
||||||
|
raise
|
||||||
|
print(f'Generated Product Vamp assets: {performance_count} performance scores, '
|
||||||
|
f'{practice_count} practice score variants, {count_json_files(output_dir)} total JSON files.')
|
||||||
|
if args.check:
|
||||||
|
print('Generator check passed.')
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
generate_assets(parse_args())
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+41
-152
@@ -4,6 +4,8 @@ from itertools import product
|
|||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
from generator.transposition import mod12
|
from generator.transposition import mod12
|
||||||
|
from generator import harmony
|
||||||
|
from generator.model import NATURAL
|
||||||
|
|
||||||
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
|
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
|
||||||
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
|
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
|
||||||
@@ -15,134 +17,8 @@ class TabPosition(NamedTuple):
|
|||||||
midi: int
|
midi: int
|
||||||
|
|
||||||
|
|
||||||
class ChordSymbol(NamedTuple):
|
def chord_pitch_classes(symbol: str | dict) -> tuple[int, ...]:
|
||||||
root: str
|
return harmony.pitch_classes(harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol)
|
||||||
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]:
|
def positions_for_string(string_number: int, open_midi: int, pitch_classes: tuple[int, ...]) -> list[TabPosition]:
|
||||||
@@ -168,8 +44,23 @@ def candidate_score(candidate: tuple[TabPosition, ...], pitch_classes: tuple[int
|
|||||||
return missing * 1000 + span * 80 + abs(low - 5) * 12 + open_penalty * 8 + sum(position.fret for position in candidate)
|
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]:
|
def find_playable_voicing(symbol: str | dict) -> list[TabPosition]:
|
||||||
pitch_classes = chord_pitch_classes(symbol)
|
definition = harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol
|
||||||
|
semantic_degrees = harmony.degrees(definition)
|
||||||
|
voicing = dict(semantic_degrees)
|
||||||
|
if 7 in voicing and voicing.get(5) == 0:
|
||||||
|
del voicing[5]
|
||||||
|
if len(voicing) > 4:
|
||||||
|
for degree in (11, 9, 5):
|
||||||
|
if len(voicing) > 4 and voicing.get(degree) == 0:
|
||||||
|
del voicing[degree]
|
||||||
|
root = definition.get('root', {'letter':'c','alter':0})
|
||||||
|
root_pc = NATURAL[root['letter']] + root['alter']
|
||||||
|
pitch_classes = tuple(dict.fromkeys((root_pc + harmony.degree_semitones(d,a)) % 12 for d,a in voicing.items()))
|
||||||
|
bass = definition.get('bass')
|
||||||
|
bass_pc = (NATURAL[bass['letter']] + bass['alter']) % 12 if bass else None
|
||||||
|
if bass_pc is not None and bass_pc not in pitch_classes:
|
||||||
|
pitch_classes += (bass_pc,)
|
||||||
if not pitch_classes:
|
if not pitch_classes:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -182,11 +73,13 @@ def find_playable_voicing(symbol: str) -> list[TabPosition]:
|
|||||||
if any(not item for item in options):
|
if any(not item for item in options):
|
||||||
continue
|
continue
|
||||||
for candidate in product(*options):
|
for candidate in product(*options):
|
||||||
|
if bass_pc is not None and min(candidate, key=lambda p:p.midi).midi % 12 != bass_pc:
|
||||||
|
continue
|
||||||
fretted = [position.fret for position in candidate if position.fret > 0]
|
fretted = [position.fret for position in candidate if position.fret > 0]
|
||||||
if fretted and max(fretted) - min(fretted) > 4:
|
if fretted and max(fretted) - min(fretted) > 4:
|
||||||
continue
|
continue
|
||||||
covered = {mod12(position.midi) for position in candidate}
|
covered = {mod12(position.midi) for position in candidate}
|
||||||
if len(covered & set(pitch_classes)) < min(3, len(set(pitch_classes))):
|
if not set(pitch_classes).issubset(covered):
|
||||||
continue
|
continue
|
||||||
score = candidate_score(candidate, pitch_classes)
|
score = candidate_score(candidate, pitch_classes)
|
||||||
if score < best_score:
|
if score < best_score:
|
||||||
@@ -195,24 +88,20 @@ def find_playable_voicing(symbol: str) -> list[TabPosition]:
|
|||||||
return sorted(best or [], key=lambda item: item.midi)
|
return sorted(best or [], key=lambda item: item.midi)
|
||||||
|
|
||||||
|
|
||||||
def pitch_from_midi(midi: int) -> dict[str, int | str]:
|
def pitches_for_voicing(symbol: str | dict) -> list[dict[str, int | str]]:
|
||||||
names = (
|
definition = harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol
|
||||||
("c", "n"),
|
from generator.transposition import shift_pitch
|
||||||
("c", "#"),
|
root = definition.get('root', {'letter':'c', 'alter':0})
|
||||||
("d", "n"),
|
spellings = {}
|
||||||
("e", "b"),
|
for degree, alter in harmony.degrees(definition).items():
|
||||||
("e", "n"),
|
p = shift_pitch(root, degree-1, harmony.degree_semitones(degree, alter))
|
||||||
("f", "n"),
|
spellings[(NATURAL[p['letter']]+p['alter']) % 12] = p
|
||||||
("f", "#"),
|
if bass := definition.get('bass'):
|
||||||
("g", "n"),
|
spellings[(NATURAL[bass['letter']]+bass['alter']) % 12] = bass
|
||||||
("a", "b"),
|
result = []
|
||||||
("a", "n"),
|
for position in find_playable_voicing(definition):
|
||||||
("b", "b"),
|
midi = position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET
|
||||||
("b", "n"),
|
p = spellings[midi % 12].copy()
|
||||||
)
|
p['octave'] = (midi - NATURAL[p['letter']] - p['alter']) // 12 - 1
|
||||||
letter, accidental = names[mod12(midi)]
|
result.append(p)
|
||||||
return {"letter": letter, "accidental": accidental, "octave": midi // 12 - 1}
|
return result
|
||||||
|
|
||||||
|
|
||||||
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)]
|
|
||||||
|
|||||||
+150
-249
@@ -1,279 +1,180 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import warnings
|
||||||
|
from fractions import Fraction
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from generator.arrange.form import ArrangementSection, full_arrangement_sections
|
from generator.arrange.form import full_arrangement_sections
|
||||||
from generator.arrange.fretboard import pitches_for_voicing
|
from generator.arrange.fretboard import pitches_for_voicing
|
||||||
from generator.score import (
|
from generator.model import beat_lengths, fraction, key_label, make_event, meter_label, rational
|
||||||
QUARTER_TICKS,
|
from generator.musicxml import load_musicxml
|
||||||
apply_source_chord_markers,
|
from generator.transposition import concert_document, shift_pitch
|
||||||
active_chord_at_offset,
|
from generator.score import copy_source_voice, make_arranged_document, make_measure, measure_notes, reidentify, slugify, source_staff, write_json
|
||||||
chord_markers,
|
|
||||||
chord_symbol_at_offset,
|
|
||||||
copy_source_voice,
|
|
||||||
first_chord_symbol,
|
|
||||||
first_voice,
|
|
||||||
leading_pickup_measure_index,
|
|
||||||
load_json,
|
|
||||||
make_arranged_document,
|
|
||||||
make_measure,
|
|
||||||
measure_capacity_ticks,
|
|
||||||
renderable_duration,
|
|
||||||
renderable_rest_notes,
|
|
||||||
rest_note,
|
|
||||||
sanitized_note,
|
|
||||||
slash_note,
|
|
||||||
slugify,
|
|
||||||
source_staff,
|
|
||||||
source_note_attack_at,
|
|
||||||
write_json,
|
|
||||||
)
|
|
||||||
|
|
||||||
PART_TYPES = ("acoustic", "electric", "vocal")
|
|
||||||
|
|
||||||
|
|
||||||
|
PART_TYPES = ('acoustic','electric','vocal')
|
||||||
FINAL_SECTION_INDEX = 6
|
FINAL_SECTION_INDEX = 6
|
||||||
|
|
||||||
|
|
||||||
def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> list[str]:
|
def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> list[dict | None]:
|
||||||
result: list[str] = []
|
result = []
|
||||||
current = ""
|
current = None
|
||||||
for measure in measures:
|
for measure in measures:
|
||||||
current = first_chord_symbol(measure, current, include_optional=include_optional)
|
|
||||||
result.append(current)
|
result.append(current)
|
||||||
|
for h in measure.get('harmonies', []):
|
||||||
|
if include_optional or not h.get('chord_optional'):
|
||||||
|
current = h
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def playable_chord_note(note_id: str, duration_ticks: int, voicing_chord_symbol: str, visible_chord_symbol: str = "") -> dict:
|
def active_chord(measure: dict, onset: Fraction, previous: dict | None) -> dict | None:
|
||||||
note = {
|
active = previous
|
||||||
"id": note_id,
|
for h in measure['harmonies']:
|
||||||
"kind": "note",
|
if fraction(h['onset']) > onset:
|
||||||
"duration_ticks": duration_ticks,
|
|
||||||
"pitches": pitches_for_voicing(voicing_chord_symbol),
|
|
||||||
"hidden": False,
|
|
||||||
}
|
|
||||||
if not note["pitches"]:
|
|
||||||
return rest_note(note_id, duration_ticks, visible_chord_symbol)
|
|
||||||
if visible_chord_symbol:
|
|
||||||
note["chord_symbol"] = visible_chord_symbol
|
|
||||||
return note
|
|
||||||
|
|
||||||
|
|
||||||
def rhythm_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
|
|
||||||
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
|
|
||||||
hit_offsets = (0, QUARTER_TICKS + QUARTER_TICKS // 2, QUARTER_TICKS * 2, QUARTER_TICKS * 2 + QUARTER_TICKS // 2, QUARTER_TICKS * 3 + QUARTER_TICKS // 2)
|
|
||||||
hit_duration = QUARTER_TICKS // 2
|
|
||||||
notes: list[dict] = []
|
|
||||||
cursor = 0
|
|
||||||
for offset in hit_offsets:
|
|
||||||
if offset >= capacity:
|
|
||||||
break
|
break
|
||||||
if offset > cursor:
|
active = h
|
||||||
rests, sequence = renderable_rest_notes("arr-rest", offset - cursor, sequence)
|
return active if active and active['kind'] != 'none' else None
|
||||||
notes.extend(rests)
|
|
||||||
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]:
|
def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dict]:
|
||||||
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
|
length = fraction(measure['duration'])
|
||||||
notes: list[dict] = []
|
if role == 'rest':
|
||||||
lead_rest = min(QUARTER_TICKS // 2, capacity)
|
return [make_event('rest', Fraction(), length, 'rest', written={'type':'whole','dots':0}) | {'measure_rest':True}]
|
||||||
if lead_rest:
|
if role == 'rhythm' and measure['time_signature']=='4/4' and not measure.get('attributes'):
|
||||||
rests, sequence = renderable_rest_notes("arr-rest", lead_rest, sequence, chord_symbol_at_offset(measure, 0, include_optional=include_optional))
|
attacks = [(Fraction(n,2), Fraction(1,2)) for n in (0,3,4,5,7)]
|
||||||
notes.extend(rests)
|
elif role == 'accompaniment' and measure['time_signature']=='4/4' and not measure.get('attributes'):
|
||||||
if lead_rest < capacity:
|
attacks = [(Fraction(1,2),Fraction(1,2))]
|
||||||
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:
|
else:
|
||||||
notes.append(playable_chord_note(f"arr-note-{sequence}", hit, active_chord, visible_chord))
|
attacks = []
|
||||||
cursor = lead_rest + hit
|
states = [(Fraction(), measure['meter'])]
|
||||||
if cursor < capacity:
|
states.extend((fraction(a['onset']), a['meter']) for a in measure.get('attributes', []) if 'meter' in a)
|
||||||
markers = [marker for marker in chord_markers(measure, include_optional=include_optional) if marker[0] >= cursor]
|
for index, (onset, meter) in enumerate(states):
|
||||||
if markers:
|
end = states[index+1][0] if index+1 < len(states) else length
|
||||||
segment_cursor = cursor
|
beats = beat_lengths(meter)
|
||||||
for index, (start, _, chord) in enumerate(markers):
|
beat_index = 0
|
||||||
if start > segment_cursor:
|
while onset < end:
|
||||||
rests, sequence = renderable_rest_notes("arr-rest", start - segment_cursor, sequence)
|
beat = min(beats[beat_index % len(beats)] if beats else end-onset, end-onset)
|
||||||
notes.extend(rests)
|
duration = beat if role == 'slash' or not beats else min(beat/2, Fraction(1,2))
|
||||||
next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity
|
attacks.append((onset, duration))
|
||||||
rests, sequence = renderable_rest_notes("arr-rest", next_start - start, sequence, chord)
|
onset += beat
|
||||||
notes.extend(rests)
|
beat_index += 1
|
||||||
segment_cursor = next_start
|
events = []
|
||||||
if segment_cursor < capacity:
|
cursor = Fraction()
|
||||||
rests, sequence = renderable_rest_notes("arr-rest", capacity - segment_cursor, sequence)
|
for onset, duration in attacks:
|
||||||
notes.extend(rests)
|
if onset >= length:
|
||||||
|
break
|
||||||
|
if onset > cursor:
|
||||||
|
events.append(make_event(f'rest-{len(events)}',cursor,onset-cursor,'rest'))
|
||||||
|
duration = min(duration,length-onset)
|
||||||
|
chord = active_chord(measure,onset,previous)
|
||||||
|
source_attack = any(fraction(n['onset'])==onset and n['kind']=='note' and not n.get('grace') and any(not p.get('tie_from') for p in n['pitches']) for n in measure_notes(measure))
|
||||||
|
if role=='slash':
|
||||||
|
event = make_event(f'slash-{len(events)}',onset,duration,'note')
|
||||||
|
event['notehead'] = 'slash'
|
||||||
|
elif not chord or role=='accompaniment' and source_attack:
|
||||||
|
event = make_event(f'rest-{len(events)}',onset,duration,'rest')
|
||||||
else:
|
else:
|
||||||
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
|
pitches = pitches_for_voicing(chord)
|
||||||
notes.extend(rests)
|
event = make_event(f'chord-{len(events)}',onset,duration,'note',pitches)
|
||||||
return apply_source_chord_markers(notes, measure, include_optional=include_optional), sequence
|
if not pitches:
|
||||||
|
warnings.warn(f'{measure["id"]}: no playable voicing for {chord["symbol"]}; using labelled rhythmic slashes', stacklevel=2)
|
||||||
|
event['notehead'] = 'slash'
|
||||||
def slash_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
|
events.append(event)
|
||||||
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
|
cursor = onset + duration
|
||||||
notes: list[dict] = []
|
if cursor < length:
|
||||||
cursor = 0
|
events.append(make_event(f'rest-{len(events)}',cursor,length-cursor,'rest'))
|
||||||
while cursor < capacity:
|
return events
|
||||||
sequence += 1
|
|
||||||
duration = min(QUARTER_TICKS, capacity - cursor)
|
|
||||||
notes.append(slash_note(f"arr-slash-{sequence}", duration))
|
|
||||||
cursor += duration
|
|
||||||
return apply_source_chord_markers(notes, measure, include_optional=include_optional), sequence
|
|
||||||
|
|
||||||
|
|
||||||
def rest_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")))
|
|
||||||
markers = chord_markers(measure, include_optional=include_optional)
|
|
||||||
if not markers:
|
|
||||||
notes, sequence = renderable_rest_notes("arr-rest", capacity, sequence)
|
|
||||||
return notes, sequence
|
|
||||||
|
|
||||||
notes: list[dict] = []
|
|
||||||
cursor = 0
|
|
||||||
for index, (start, _, chord) in enumerate(markers):
|
|
||||||
if start > cursor:
|
|
||||||
if not renderable_duration(start - cursor):
|
|
||||||
return source_rest_skeleton_notes(measure, sequence, include_optional=include_optional)
|
|
||||||
rests, sequence = renderable_rest_notes("arr-rest", start - cursor, sequence)
|
|
||||||
notes.extend(rests)
|
|
||||||
next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity
|
|
||||||
if not renderable_duration(next_start - start):
|
|
||||||
return source_rest_skeleton_notes(measure, sequence, include_optional=include_optional)
|
|
||||||
rests, sequence = renderable_rest_notes("arr-rest", next_start - start, sequence, chord)
|
|
||||||
notes.extend(rests)
|
|
||||||
cursor = next_start
|
|
||||||
if cursor < capacity:
|
|
||||||
if not renderable_duration(capacity - cursor):
|
|
||||||
return source_rest_skeleton_notes(measure, sequence, include_optional=include_optional)
|
|
||||||
rests, sequence = renderable_rest_notes("arr-rest", capacity - cursor, sequence)
|
|
||||||
notes.extend(rests)
|
|
||||||
return notes, sequence
|
|
||||||
|
|
||||||
|
|
||||||
def source_rest_skeleton_notes(measure: dict, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
|
|
||||||
notes: list[dict] = []
|
|
||||||
for source_note in first_voice(measure).get("notes", []):
|
|
||||||
if not isinstance(source_note, dict):
|
|
||||||
continue
|
|
||||||
sequence += 1
|
|
||||||
chord = "" if not include_optional and source_note.get("chord_optional") is True else str(source_note.get("chord_symbol", ""))
|
|
||||||
note = rest_note(f"arr-rest-{sequence}", int(source_note.get("duration_ticks", QUARTER_TICKS)), chord)
|
|
||||||
if "display_ticks" in source_note:
|
|
||||||
note["display_ticks"] = source_note["display_ticks"]
|
|
||||||
if "tuplet_id" in source_note:
|
|
||||||
note["tuplet_id"] = source_note["tuplet_id"]
|
|
||||||
notes.append(note)
|
|
||||||
if not notes:
|
|
||||||
sequence += 1
|
|
||||||
notes.append(rest_note(f"arr-rest-{sequence}", measure_capacity_ticks(str(measure.get("time_signature", "4/4")))))
|
|
||||||
return notes, sequence
|
|
||||||
|
|
||||||
|
|
||||||
def melody_voice(measure: dict, *, strip_lyrics: bool, 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:
|
def arrange_document(document: dict, part_type: str) -> dict:
|
||||||
|
if part_type not in PART_TYPES:
|
||||||
|
raise ValueError(f'unknown arrangement part: {part_type}')
|
||||||
|
document = concert_document(document)
|
||||||
staff = source_staff(document)
|
staff = source_staff(document)
|
||||||
measures = staff.get("measures")
|
source = staff['measures']
|
||||||
if not isinstance(measures, list) or not measures:
|
sections = full_arrangement_sections(source,part_type)
|
||||||
raise ValueError("performance arrangement requires at least one source measure")
|
output, tuplets, octave_shifts = [], [], []
|
||||||
output = make_arranged_document(
|
absolute = Fraction()
|
||||||
document,
|
source_starts = []
|
||||||
staff,
|
source_time = Fraction()
|
||||||
staff_id="staff-arranged-vocal" if part_type == "vocal" else "staff-arranged-guitar",
|
for m in source:
|
||||||
staff_name=part_type.capitalize(),
|
source_starts.append(source_time)
|
||||||
role="vocal" if part_type == "vocal" else "guitar",
|
source_time += fraction(m['duration'])
|
||||||
arrangement_part_type=part_type,
|
for section in sections:
|
||||||
measures=arranged_measures([measure for measure in measures if isinstance(measure, dict)], part_type),
|
previous = propagated_chords(source, include_optional=section.section_index!=FINAL_SECTION_INDEX)
|
||||||
)
|
segment = []
|
||||||
metadata = output.setdefault("metadata", {})
|
for si in range(section.source_measure_index,section.source_measure_index+section.measure_count):
|
||||||
if isinstance(metadata, dict):
|
m = copy.deepcopy(source[si])
|
||||||
metadata.setdefault("tempo", 124)
|
m['harmonies'] = [h for h in m['harmonies'] if section.section_index!=FINAL_SECTION_INDEX or not h.get('chord_optional')]
|
||||||
return output
|
for h in m['harmonies']:
|
||||||
|
h.pop('chord_optional',None)
|
||||||
|
notes = copy_source_voice(m,strip_lyrics=section.role=='melody')['notes'] if section.role in ('vocal','melody') else generated_voice(m,section.role,previous[si])
|
||||||
|
if section.role == 'melody':
|
||||||
|
for note in notes:
|
||||||
|
note['pitches'] = [shift_pitch(p,7,12) for p in note['pitches']]
|
||||||
|
result = make_measure(f'measure-{len(output)+len(segment)+1}',m,notes,section_label=section.label if not segment else '')
|
||||||
|
if part_type!='vocal':
|
||||||
|
result['clef'] = {'sign':'G','line':2,'octave_change':0}
|
||||||
|
result['transpose'] = {'diatonic':0,'chromatic':0,'octave_change':-1}
|
||||||
|
for change in result.get('attributes',[]):
|
||||||
|
change.pop('clef',None)
|
||||||
|
change.pop('transpose',None)
|
||||||
|
segment.append(result)
|
||||||
|
identity_segment = segment
|
||||||
|
segment_length = sum((fraction(m['duration']) for m in segment),Fraction())
|
||||||
|
if section.role in ('vocal','melody'):
|
||||||
|
start = source_starts[section.source_measure_index]
|
||||||
|
for shift in staff.get('octave_shifts',[]):
|
||||||
|
low, high = max(start,fraction(shift['start'])), min(start+segment_length,fraction(shift['end']))
|
||||||
|
if low < high:
|
||||||
|
octave_shifts.append({**shift,'id':f'section-{section.section_index}-{shift["id"]}','start':rational(absolute+low-start),'end':rational(absolute+high-start)})
|
||||||
|
if part_type=='vocal' and section.section_index in (1,5) and section.source_measure_index==1 and source[0].get('implicit') and output:
|
||||||
|
pickup = copy.deepcopy(source[0])
|
||||||
|
destination = output[-1]
|
||||||
|
pad = fraction(destination['duration']) - fraction(pickup['duration'])
|
||||||
|
if pad < 0:
|
||||||
|
raise ValueError('pickup is longer than the preceding arrangement measure')
|
||||||
|
notes = copy_source_voice(pickup,strip_lyrics=False)['notes']
|
||||||
|
for n in notes:
|
||||||
|
n['onset'] = rational(fraction(n['onset'])+pad)
|
||||||
|
if pad:
|
||||||
|
notes.insert(0,make_event('pickup-rest',Fraction(),pad,'rest'))
|
||||||
|
destination['voices'][0]['notes'] = notes
|
||||||
|
identity_segment = [destination, *segment]
|
||||||
|
if pickup['harmonies']:
|
||||||
|
destination['harmonies'] = [h for h in destination['harmonies'] if fraction(h['onset']) < pad]
|
||||||
|
for h in pickup['harmonies']:
|
||||||
|
h['onset'] = rational(fraction(h['onset'])+pad)
|
||||||
|
destination['harmonies'].append(h)
|
||||||
|
state = {k:copy.deepcopy(pickup[k]) for k in ('key','clef','meter','transpose')}
|
||||||
|
if pad:
|
||||||
|
destination['attributes'].append({'onset':rational(pad), **state})
|
||||||
|
else:
|
||||||
|
destination.update(state)
|
||||||
|
destination['key_signature'] = key_label(state['key'])
|
||||||
|
destination['time_signature'] = meter_label(state['meter'])
|
||||||
|
for change in pickup.get('attributes',[]):
|
||||||
|
destination['attributes'].append({**change,'onset':rational(fraction(change['onset'])+pad)})
|
||||||
|
for shift in staff.get('octave_shifts',[]):
|
||||||
|
low, high = fraction(shift['start']), min(fraction(pickup['duration']),fraction(shift['end']))
|
||||||
|
if low < high:
|
||||||
|
offset = absolute-fraction(pickup['duration'])
|
||||||
|
octave_shifts.append({**shift,'id':f'pickup-{section.section_index}-{shift["id"]}','start':rational(offset+low),'end':rational(offset+high)})
|
||||||
|
tuplets.extend(reidentify(identity_segment,staff.get('tuplets',[])))
|
||||||
|
output.extend(segment)
|
||||||
|
absolute += segment_length
|
||||||
|
result = 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)
|
||||||
|
result['metadata'].setdefault('tempo',124)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def generate_performance_scores(source_dir: Path, output_dir: Path) -> int:
|
def generate_performance_scores(source_dir: Path, output_dir: Path) -> int:
|
||||||
if not source_dir.exists():
|
if not source_dir.exists():
|
||||||
raise ValueError(f"performance source directory not found: {source_dir}")
|
raise ValueError(f'performance source directory not found: {source_dir}')
|
||||||
count = 0
|
count = 0
|
||||||
for source_path in sorted(source_dir.glob("*.json")):
|
for path in sorted(source_dir.glob('*.musicxml')):
|
||||||
document = load_json(source_path)
|
document = load_musicxml(path)
|
||||||
for part_type in PART_TYPES:
|
for part in PART_TYPES:
|
||||||
output = arrange_document(document, part_type)
|
write_json(output_dir/f'{slugify(path.stem)}-{part}.json',arrange_document(document,part))
|
||||||
target = output_dir / f"{slugify(source_path.stem)}-{part_type}.json"
|
|
||||||
write_json(target, output)
|
|
||||||
count += 1
|
count += 1
|
||||||
return count
|
return count
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from generator.model import NATURAL, parse_pitch_name, pitch_name
|
||||||
|
|
||||||
|
# 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':'', 'minor':'-', 'augmented':'+', 'diminished':'dim', 'dominant':'7', 'major-seventh':'maj7', 'minor-seventh':'-7', 'diminished-seventh':'dim7', 'augmented-seventh':'+7', 'augmented-major-seventh':'+maj7', 'half-diminished':'-7b5', 'major-minor':'-maj7', 'major-sixth':'6', 'minor-sixth':'-6', 'suspended-second':'sus2', 'suspended-fourth':'sus4', 'power':'5', 'pedal':'', 'other':''}
|
||||||
|
for family, prefix in (('dominant',''), ('major','maj'), ('minor','-')):
|
||||||
|
for suffix, number in (('ninth','9'), ('11th','11'), ('13th','13')):
|
||||||
|
SUFFIXES[f'{family}-{suffix}'] = prefix + number
|
||||||
|
|
||||||
|
|
||||||
|
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'], change['alter'], change['type']
|
||||||
|
if type(degree) is not int or degree < 1 or type(alter) is not int:
|
||||||
|
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:
|
||||||
|
raise ValueError('harmony contains no pitch information')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
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 = NATURAL[root['letter']] + root['alter']
|
||||||
|
values = [(root_pc + degree_semitones(d, a)) % 12 for d, a in degrees(harmony).items()]
|
||||||
|
if bass := harmony.get('bass'):
|
||||||
|
values.append((NATURAL[bass['letter']] + bass['alter']) % 12)
|
||||||
|
return tuple(dict.fromkeys(values))
|
||||||
|
|
||||||
|
|
||||||
|
def symbol(harmony: dict) -> str:
|
||||||
|
if harmony['kind'] == 'none':
|
||||||
|
return 'N.C.'
|
||||||
|
suffix = harmony.get('kind_text', SUFFIXES.get(harmony['kind'], harmony['kind']))
|
||||||
|
if 'kind_text' not in harmony:
|
||||||
|
for d in harmony.get('degrees', []):
|
||||||
|
sign = '#' * max(d['alter'], 0) + 'b' * max(-d['alter'], 0)
|
||||||
|
suffix += ('no' if d['type']=='subtract' else 'add' if d['type']=='add' and not sign else '') + sign + str(d['value'])
|
||||||
|
result = pitch_name(harmony['root']) + suffix
|
||||||
|
if bass := harmony.get('bass'):
|
||||||
|
result += '/' + pitch_name(bass)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parse_symbol(text: str) -> dict:
|
||||||
|
import re
|
||||||
|
match = re.fullmatch(r'([A-Ga-g][#b]*)([^/]*)(?:/([A-Ga-g][#b]*))?', text.strip())
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f'invalid chord symbol: {text!r}')
|
||||||
|
root, suffix, bass = match.groups()
|
||||||
|
aliases = {'m':'minor', 'min':'minor', 'maj':'major', 'M':'major', 'm7':'minor-seventh', 'm6':'minor-sixth', 'm7b5':'half-diminished', 'o':'diminished', 'o7':'diminished-seventh', 'aug':'augmented', 'sus':'suspended-fourth'}
|
||||||
|
aliases.update({v:k for k,v in SUFFIXES.items() if k not in ('other','pedal')})
|
||||||
|
core = suffix
|
||||||
|
changes = []
|
||||||
|
while core not in aliases:
|
||||||
|
m = re.search(r'(add|no)?([b#]?)(\d+)$', core)
|
||||||
|
if not m:
|
||||||
|
raise ValueError(f'unsupported chord spelling: {text!r}')
|
||||||
|
op, accidental, value = m.groups()
|
||||||
|
changes.insert(0, {'value':int(value), 'alter':1 if accidental=='#' else -1 if accidental=='b' else 0, 'type':'subtract' if op=='no' else 'add'})
|
||||||
|
core = core[:m.start()]
|
||||||
|
kind = aliases[core]
|
||||||
|
for d in changes:
|
||||||
|
if d['type']=='add' and d['value'] in KINDS[kind]:
|
||||||
|
d['type'] = 'alter'
|
||||||
|
result = {'root':parse_pitch_name(root), 'kind':kind, 'degrees':changes, 'kind_text':suffix}
|
||||||
|
if bass:
|
||||||
|
result['bass'] = parse_pitch_name(bass)
|
||||||
|
degrees(result)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from fractions import Fraction
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 2
|
||||||
|
MAX_INTEGER = (1 << 63) - 1
|
||||||
|
NOTE_TYPES = ('maxima', 'long', 'breve', 'whole', 'half', 'quarter', 'eighth', '16th', '32nd', '64th', '128th', '256th', '512th', '1024th')
|
||||||
|
TYPE_LENGTHS = {name: 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}
|
||||||
|
|
||||||
|
|
||||||
|
def rational(value: Fraction | int) -> dict[str, int]:
|
||||||
|
value = 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: Any) -> 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 = Fraction(n, d)
|
||||||
|
if rational(result) != value:
|
||||||
|
raise ValueError('musical time must be reduced')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def written_length(written: dict) -> 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 - Fraction(1, 2 ** dots))
|
||||||
|
|
||||||
|
|
||||||
|
def written_for(length: Fraction) -> dict:
|
||||||
|
for name, base in TYPE_LENGTHS.items():
|
||||||
|
for dots in range(17):
|
||||||
|
if length == base * (2 - 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) -> 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 pitch_number(pitch: dict) -> int:
|
||||||
|
letter = pitch['letter'].lower()
|
||||||
|
alter, octave = pitch.get('alter', 0), pitch['octave']
|
||||||
|
if letter not in NATURAL or type(alter) is not int or type(octave) is not int or not -3 <= alter <= 3 or not -4 <= octave <= 16:
|
||||||
|
raise ValueError('invalid spelled pitch')
|
||||||
|
return 12 * (octave + 1) + NATURAL[letter] + alter
|
||||||
|
|
||||||
|
|
||||||
|
def pitch_name(pitch: dict) -> str:
|
||||||
|
alter = pitch.get('alter', 0)
|
||||||
|
return pitch['letter'].upper() + ('#' * alter if alter > 0 else 'b' * -alter)
|
||||||
|
|
||||||
|
|
||||||
|
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': 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': signature[LETTERS.index(letter)]}
|
||||||
|
return {'fifths': fifths, 'mode': mode, 'tonic': tonic if mode != 'none' else None, 'alterations': 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) -> Fraction:
|
||||||
|
return sum((Fraction(sum(group['beats']) * 4, group['unit']) for group in meter.get('groups', [])), 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[Fraction]:
|
||||||
|
result = []
|
||||||
|
for group in meter.get('groups', []):
|
||||||
|
beats, unit = group['beats'], group['unit']
|
||||||
|
if len(beats) > 1:
|
||||||
|
result.extend(Fraction(b * 4, unit) for b in beats)
|
||||||
|
elif beats[0] > 3 and beats[0] % 3 == 0 and unit >= 8:
|
||||||
|
result.extend([Fraction(12, unit)] * (beats[0] // 3))
|
||||||
|
else:
|
||||||
|
result.extend([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: Fraction, duration: 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)})
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def validate_document(document: dict) -> None:
|
||||||
|
if document.get('schema_version') != SCHEMA_VERSION:
|
||||||
|
raise ValueError('unsupported score schema; regenerate score assets with the current generator')
|
||||||
|
staves = document.get('staves', [])
|
||||||
|
if len(staves) != 1:
|
||||||
|
raise ValueError('exactly one staff is currently supported')
|
||||||
|
ids: set[str] = set()
|
||||||
|
events: dict[str, dict] = {}
|
||||||
|
tones: dict[str, tuple[dict, Fraction, Fraction]] = {}
|
||||||
|
absolute = Fraction()
|
||||||
|
for measure in staves[0]['measures']:
|
||||||
|
length = fraction(measure['duration'])
|
||||||
|
if length <= 0:
|
||||||
|
raise ValueError('measure duration must be positive')
|
||||||
|
voices = measure.get('voices', [])
|
||||||
|
if len(voices) != 1:
|
||||||
|
raise ValueError('exactly one rhythmic voice is currently supported')
|
||||||
|
previous_end = Fraction()
|
||||||
|
for note in voices[0]['notes']:
|
||||||
|
if not note['id'] or note['id'] in ids:
|
||||||
|
raise ValueError(f'duplicate event ID: {note["id"]}')
|
||||||
|
ids.add(note['id'])
|
||||||
|
events[note['id']] = note
|
||||||
|
onset, duration = fraction(note['onset']), fraction(note['duration'])
|
||||||
|
if onset < 0 or duration < 0 or onset + duration > length:
|
||||||
|
raise ValueError(f'event outside measure: {note["id"]}')
|
||||||
|
if note['kind'] not in ('note', 'rest', 'spacer'):
|
||||||
|
raise ValueError('invalid event kind')
|
||||||
|
if not note.get('grace'):
|
||||||
|
if duration == 0 or onset < previous_end:
|
||||||
|
raise ValueError('overlapping events in one rhythmic voice')
|
||||||
|
previous_end = onset + duration
|
||||||
|
elif duration != 0:
|
||||||
|
raise ValueError('grace notes must not advance metric time')
|
||||||
|
expected = metric_length(note['written'])
|
||||||
|
if not note.get('grace') and not note.get('measure_rest') and duration != expected:
|
||||||
|
raise ValueError('metric and written event durations disagree')
|
||||||
|
if note['kind'] != 'note' and note.get('pitches'):
|
||||||
|
raise ValueError('rests and spacers cannot contain pitches')
|
||||||
|
if note['kind'] == 'note' and not note.get('pitches') and note.get('notehead') != 'slash':
|
||||||
|
raise ValueError('pitched event requires at least one pitch')
|
||||||
|
for p in note.get('pitches', []):
|
||||||
|
pitch_number(p)
|
||||||
|
pd = fraction(p['duration'])
|
||||||
|
if pd < 0 or (pd == 0 and not note.get('grace')) or pd > duration or not p['id'] or p['id'] in tones:
|
||||||
|
raise ValueError('invalid chord-tone duration or duplicate ID')
|
||||||
|
if not note.get('grace') and pd != metric_length(p['written']):
|
||||||
|
raise ValueError('metric and written chord-tone durations disagree')
|
||||||
|
tones[p['id']] = (p, absolute + onset, pd)
|
||||||
|
for harmony in measure.get('harmonies', []):
|
||||||
|
if not 0 <= fraction(harmony['onset']) < length:
|
||||||
|
raise ValueError('harmony outside measure')
|
||||||
|
for change in measure.get('attributes', []):
|
||||||
|
if not 0 <= fraction(change['onset']) < length:
|
||||||
|
raise ValueError('attribute change outside measure')
|
||||||
|
for state in [measure, *measure.get('attributes', [])]:
|
||||||
|
if 'meter' in state:
|
||||||
|
validate_meter(state['meter'])
|
||||||
|
if 'key' in state and (len(state['key']['alterations']) != 7 or any(type(a) is not int or not -3 <= a <= 3 for a in state['key']['alterations'])):
|
||||||
|
raise ValueError('key alterations must contain seven integers from -3 through 3')
|
||||||
|
if 'clef' in state and (state['clef']['sign'] not in ('G','F','C') or not 1 <= state['clef']['line'] <= 5 or not -3 <= state['clef']['octave_change'] <= 3):
|
||||||
|
raise ValueError('invalid pitched clef')
|
||||||
|
absolute += length
|
||||||
|
for identifier, (p, start, duration) in tones.items():
|
||||||
|
if target := p.get('tie_to'):
|
||||||
|
if target not in tones:
|
||||||
|
raise ValueError(f'dangling tie: {identifier} -> {target}')
|
||||||
|
other, next_start, _ = tones[target]
|
||||||
|
if other.get('tie_from') != identifier or pitch_number(p) != pitch_number(other) or next_start != start + duration:
|
||||||
|
raise ValueError(f'invalid tie: {identifier} -> {target}')
|
||||||
|
if source := p.get('tie_from'):
|
||||||
|
if source not in tones or tones[source][0].get('tie_to') != identifier:
|
||||||
|
raise ValueError(f'dangling incoming tie: {identifier}')
|
||||||
|
span_ids = set()
|
||||||
|
event_order = {identifier:index for index, identifier in enumerate(events)}
|
||||||
|
for span in staves[0].get('tuplets', []):
|
||||||
|
if not span['id'] or span['id'] in span_ids or span['start'] not in ids or span['end'] not in ids or not 0 < span['note_count'] <= 1000000 or not 0 < span['space_count'] <= 1000000 or not 0 <= span['depth'] <= 16:
|
||||||
|
raise ValueError('invalid tuplet span')
|
||||||
|
members = [n['id'] for n in events.values() if span['id'] in n.get('tuplet_ids', [])]
|
||||||
|
if not members or members[0] != span['start'] or members[-1] != span['end'] or event_order[span['start']] > event_order[span['end']]:
|
||||||
|
raise ValueError('tuplet endpoints disagree with member events')
|
||||||
|
if span.get('bracket', 'auto') not in ('yes','no','auto') or span.get('show_number','actual') not in ('none','actual','both'):
|
||||||
|
raise ValueError('invalid tuplet appearance')
|
||||||
|
span_ids.add(span['id'])
|
||||||
|
for note in events.values():
|
||||||
|
if any(t not in span_ids for t in note.get('tuplet_ids', [])):
|
||||||
|
raise ValueError('event refers to an unknown tuplet')
|
||||||
|
if tremolo := note.get('tremolo'):
|
||||||
|
if tremolo['type'] not in ('single','start','stop') or type(tremolo['marks']) is not int or not 0 <= tremolo['marks'] <= 8:
|
||||||
|
raise ValueError('invalid measured tremolo')
|
||||||
|
if tremolo['type'] in ('start','stop'):
|
||||||
|
other = events.get(tremolo.get('partner'))
|
||||||
|
expected_type = 'stop' if tremolo['type'] == 'start' else 'start'
|
||||||
|
if not other or other.get('tremolo',{}).get('partner') != note['id'] or other['tremolo']['type'] != expected_type or other['tremolo']['marks'] != tremolo['marks']:
|
||||||
|
raise ValueError('invalid paired tremolo')
|
||||||
|
for span in staves[0].get('octave_shifts', []):
|
||||||
|
if not 0 <= fraction(span['start']) < fraction(span['end']) <= absolute or not -3 <= span['octaves'] <= 3:
|
||||||
|
raise ValueError('invalid octave-shift span')
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from fractions import Fraction
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from defusedxml import ElementTree as XML
|
||||||
|
|
||||||
|
from generator import harmony
|
||||||
|
from generator.model import (
|
||||||
|
SCHEMA_VERSION, TYPE_LENGTHS, fraction, key_from_fifths, key_label,
|
||||||
|
meter_label, meter_length, pitch_number, rational, validate_document,
|
||||||
|
written_for, written_length,
|
||||||
|
)
|
||||||
|
from generator.styles import canonical_style
|
||||||
|
|
||||||
|
|
||||||
|
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: Path) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.location = str(path)
|
||||||
|
self.divisions = Fraction(1)
|
||||||
|
self.key = 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.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 = None
|
||||||
|
self.lyric_number = None
|
||||||
|
self.absolute = Fraction()
|
||||||
|
self.tremolo_start = None
|
||||||
|
|
||||||
|
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) -> Fraction | int:
|
||||||
|
try:
|
||||||
|
result = Fraction(value or '')
|
||||||
|
if integer and result.denominator != 1 or positive and result <= 0:
|
||||||
|
raise ValueError()
|
||||||
|
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: Fraction) -> 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 self.voice_id is None:
|
||||||
|
self.voice_id = voice
|
||||||
|
if voice != self.voice_id:
|
||||||
|
self.fail('multiple rhythmic voices are not supported')
|
||||||
|
|
||||||
|
def attributes(self, node, onset: Fraction) -> dict:
|
||||||
|
self.children(node, {'divisions', 'key', 'time', 'clef', 'staves', 'transpose', 'instruments'})
|
||||||
|
result = {'onset':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 = key_from_fifths(self.number(item.findtext('fifths'), integer=True), item.findtext('mode', 'major'))
|
||||||
|
else:
|
||||||
|
self.key = {'fifths':0, 'mode':'none', 'tonic':None, 'alterations':[0]*7, 'custom':True}
|
||||||
|
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')
|
||||||
|
self.key['alterations']['cdefgab'.index(pending)] = self.number(field.text, integer=True)
|
||||||
|
pending = None
|
||||||
|
elif field.tag in ('key-octave', 'key-accidental'):
|
||||||
|
self.fail(f'<{field.tag}> requires unsupported custom key placement')
|
||||||
|
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 not in ('G','F','C'):
|
||||||
|
self.fail(f'unsupported clef {sign!r}; only pitched G/F/C staves are supported')
|
||||||
|
line = self.number(item.findtext('line', {'G':'2','F':'4','C':'3'}[sign]), integer=True)
|
||||||
|
if not 1 <= line <= 5:
|
||||||
|
self.fail('clef line must be between 1 and 5')
|
||||||
|
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 == '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'))}
|
||||||
|
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: 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')
|
||||||
|
result = {'id':f'harmony-{self.sequence}-{cursor}', 'onset':rational(self.offset(node, cursor)), 'kind':kind.text, 'degrees':[]}
|
||||||
|
self.attrs(kind, {'text', 'use-symbols', 'stack-degrees', 'parentheses-degrees', 'bracket-degrees'})
|
||||||
|
if 'text' in kind.attrib:
|
||||||
|
result['kind_text'] = kind.get('text')
|
||||||
|
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':self.number(item.findtext(tag+'-alter','0'), integer=True)}
|
||||||
|
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)
|
||||||
|
alteration = self.number(degree.findtext('degree-alter','0'), integer=True)
|
||||||
|
operation = degree.findtext('degree-type')
|
||||||
|
# MusicXML alterations are relative to the kind; additions use dominant intervals.
|
||||||
|
if operation == 'alter':
|
||||||
|
alteration += harmony.KINDS.get(result['kind'], {}).get(value, 0)
|
||||||
|
elif operation == 'add' and (value-1)%7 == 6:
|
||||||
|
alteration -= 1
|
||||||
|
elif operation == 'subtract' and alteration:
|
||||||
|
self.fail('cannot subtract a degree different from the one in the chord kind')
|
||||||
|
result['degrees'].append({'value':value, 'alter':alteration, 'type':operation})
|
||||||
|
chord_degrees = harmony.degrees(result)
|
||||||
|
if 'kind_text' in result and result['kind'] != 'none':
|
||||||
|
from generator.model import pitch_name
|
||||||
|
label = pitch_name(result['root']) + result['kind_text']
|
||||||
|
try:
|
||||||
|
displayed = harmony.degrees(harmony.parse_symbol(label))
|
||||||
|
except ValueError:
|
||||||
|
self.fail(f'unmapped harmony display text {label!r}; use a standard chord kind/degree spelling')
|
||||||
|
if displayed != chord_degrees:
|
||||||
|
if displayed != harmony.KINDS[result['kind']]:
|
||||||
|
self.fail(f'contradictory harmony text {label!r} and structured chord degrees')
|
||||||
|
result.pop('kind_text')
|
||||||
|
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]
|
||||||
|
from generator.model import NATURAL
|
||||||
|
target = NATURAL[root['letter']] + root['alter'] + harmony.degree_semitones(degree, chord_degrees[degree])
|
||||||
|
result['bass'] = {'letter':bass_letter, 'alter':target - (12*(letter_index//7)+NATURAL[bass_letter])}
|
||||||
|
result['symbol'] = harmony.symbol(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def direction(self, node, cursor: Fraction, measure: dict, optional: list[Fraction]) -> None:
|
||||||
|
self.attrs(node, {'print-object', 'directive'})
|
||||||
|
self.children(node, {'direction-type','offset','staff','voice','sound'})
|
||||||
|
onset = self.offset(node, cursor)
|
||||||
|
for group in node.findall('direction-type'):
|
||||||
|
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 = 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'))}
|
||||||
|
written_length(written)
|
||||||
|
bpm = self.number(item.findtext('per-minute'), positive=True)
|
||||||
|
measure.setdefault('tempos', []).append({'onset':rational(onset), 'written':written, 'bpm':rational(bpm)})
|
||||||
|
sound = node.find('sound')
|
||||||
|
if sound is not None:
|
||||||
|
self.attrs(sound, {'tempo'})
|
||||||
|
self.children(sound, set())
|
||||||
|
if sound.get('tempo') and not any(fraction(t['onset'])==onset for t in measure.get('tempos', [])):
|
||||||
|
measure.setdefault('tempos', []).append({'onset':rational(onset), 'written':{'type':'quarter','dots':0}, 'bpm':rational(self.number(sound.get('tempo'), positive=True))})
|
||||||
|
|
||||||
|
def read_note(self, node, cursor: Fraction, previous: dict | None) -> tuple[dict, 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','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)
|
||||||
|
is_chord = node.find('chord') is not None
|
||||||
|
grace = node.find('grace')
|
||||||
|
duration = Fraction() if grace is not None else self.number(node.findtext('duration'), positive=True) / self.divisions
|
||||||
|
onset = fraction(previous['onset']) if is_chord and previous else cursor
|
||||||
|
if is_chord and (previous is None or previous['kind']!='note' or duration > 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 written_for(duration)
|
||||||
|
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'] = rational(Fraction(normal, actual))
|
||||||
|
if modulation.find('normal-type') is not None:
|
||||||
|
written['normal'] = {'type':modulation.findtext('normal-type'), 'dots':len(modulation.findall('normal-dot'))}
|
||||||
|
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 = written_length(written) * fraction(written.get('ratio', 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}', 'kind':'rest' if node.find('rest') is not None else 'note', 'onset':rational(onset), 'duration':rational(duration), 'written':written, 'hidden':node.get('print-object')=='no', 'pitches':[]}
|
||||||
|
if is_chord:
|
||||||
|
note = previous
|
||||||
|
if grace is not None:
|
||||||
|
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:
|
||||||
|
note['grace'][name] = rational(self.number(grace.get(name), positive=True))
|
||||||
|
if node.find('cue') is not None:
|
||||||
|
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')
|
||||||
|
if pitch_node is None:
|
||||||
|
self.fail('note has neither pitch nor rest')
|
||||||
|
self.children(pitch_node, {'step','alter','octave'})
|
||||||
|
p = {'id':f'{note["id"]}-p{len(note["pitches"])+1}', 'letter':pitch_node.findtext('step','').lower(), 'alter':self.number(pitch_node.findtext('alter','0'), integer=True), 'octave':self.number(pitch_node.findtext('octave'), integer=True), 'duration':rational(duration), 'written':written}
|
||||||
|
value = pitch_number(p)
|
||||||
|
p['_absolute'] = self.absolute + onset
|
||||||
|
accidental = node.find('accidental')
|
||||||
|
if accidental is not None:
|
||||||
|
self.attrs(accidental, {'cautionary','editorial','parentheses','bracket','size','smufl'})
|
||||||
|
allowed = {'natural':0,'sharp':1,'flat':-1,'double-sharp':2,'sharp-sharp':2,'flat-flat':-2,'triple-sharp':3,'triple-flat':-3,'natural-sharp':1,'natural-flat':-1}
|
||||||
|
if accidental.text not in allowed or allowed[accidental.text] != p['alter']:
|
||||||
|
self.fail('unsupported or contradictory accidental')
|
||||||
|
p['accidental_display'] = {'name':accidental.text, 'parentheses':accidental.get('parentheses')=='yes' or accidental.get('cautionary')=='yes', 'bracket':accidental.get('bracket')=='yes'}
|
||||||
|
ties = {t.get('type') for t in node.findall('tie')}
|
||||||
|
notations = node.find('notations')
|
||||||
|
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'] + 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:
|
||||||
|
if head not in ('normal','slash'):
|
||||||
|
self.fail(f'unsupported notehead: {head}')
|
||||||
|
note['notehead'] = head
|
||||||
|
beams = []
|
||||||
|
for beam in node.findall('beam'):
|
||||||
|
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 self.lyric_number is None:
|
||||||
|
self.lyric_number = number
|
||||||
|
if self.lyric_number != number or 'lyric' in note:
|
||||||
|
self.fail('only one lyric verse is supported')
|
||||||
|
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')
|
||||||
|
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'})
|
||||||
|
for item in notations:
|
||||||
|
if item.tag == 'tuplet' and not is_chord:
|
||||||
|
number, action = item.get('number','1'), item.get('type')
|
||||||
|
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 '')
|
||||||
|
if self.active_tuplets 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}', 'start':note['id'], 'note_count':self.number(actual, integer=True, positive=True), 'space_count':self.number(normal, integer=True, positive=True), 'depth':len(self.active_tuplets), 'bracket':item.get('bracket','auto'), 'show_number':item.get('show-number','actual')}
|
||||||
|
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 self.tremolo_start is not None:
|
||||||
|
self.fail('nested tremolo')
|
||||||
|
self.tremolo_start = note
|
||||||
|
elif action == 'stop':
|
||||||
|
if self.tremolo_start is None:
|
||||||
|
self.fail('tremolo stop without start')
|
||||||
|
note['tremolo']['partner'] = self.tremolo_start['id']
|
||||||
|
self.tremolo_start['tremolo']['partner'] = note['id']
|
||||||
|
self.tremolo_start = None
|
||||||
|
elif action != 'single':
|
||||||
|
self.fail('unsupported tremolo type')
|
||||||
|
if not is_chord:
|
||||||
|
note['tuplet_ids'] = [t['id'] for t in self.active_tuplets.values()]
|
||||||
|
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 = XML.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')
|
||||||
|
fields = {}
|
||||||
|
for item in root.findall('identification/miscellaneous/miscellaneous-field'):
|
||||||
|
name = item.get('name','')
|
||||||
|
if name.startswith('vamp:'):
|
||||||
|
if name not in ('vamp:style','vamp:tonic') or name in fields:
|
||||||
|
self.fail(f'unknown or duplicate Vamp property {name!r}')
|
||||||
|
fields[name] = item.text or ''
|
||||||
|
metadata = {'title':root.findtext('work/work-title') or root.findtext('movement-title') or self.path.stem, 'composer':' / '.join(c.text for c in root.findall('identification/creator') if c.get('type')=='composer' and c.text), 'style':canonical_style(fields.get('vamp:style'))}
|
||||||
|
if 'vamp:tonic' in fields:
|
||||||
|
from generator.model import parse_pitch_name
|
||||||
|
metadata['transpose_origin'] = parse_pitch_name(fields['vamp:tonic'])
|
||||||
|
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','harmony','forward','backup','barline','print'})
|
||||||
|
self.attrs(bar, {'number','implicit','non-controlling','width','text'})
|
||||||
|
cursor = Fraction()
|
||||||
|
events = []
|
||||||
|
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)}
|
||||||
|
for item in bar:
|
||||||
|
if item.tag == 'attributes':
|
||||||
|
state = self.attributes(item, cursor)
|
||||||
|
if cursor == 0:
|
||||||
|
for k in ('key','meter','clef','transpose'):
|
||||||
|
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:
|
||||||
|
events.append(previous)
|
||||||
|
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 == 'backup':
|
||||||
|
self.fail('backward timeline movement requires unsupported independent voices')
|
||||||
|
elif item.tag == 'forward':
|
||||||
|
self.children(item, {'duration','voice','staff'})
|
||||||
|
self.identity(item)
|
||||||
|
duration = self.number(item.findtext('duration'), positive=True)/self.divisions
|
||||||
|
self.sequence += 1
|
||||||
|
from generator.model import make_event
|
||||||
|
events.append(make_event(f'spacer-{self.sequence}', cursor, duration, 'spacer'))
|
||||||
|
cursor += duration
|
||||||
|
previous = None
|
||||||
|
elif item.tag == 'barline':
|
||||||
|
self.children(item, {'bar-style'})
|
||||||
|
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', {})[item.get('location','right')] = style
|
||||||
|
if not events or cursor <= 0:
|
||||||
|
self.fail('empty or zero-duration measure')
|
||||||
|
expected = 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'] = rational(cursor)
|
||||||
|
measure['key_signature'], measure['time_signature'] = key_label(measure['key']), meter_label(measure['meter'])
|
||||||
|
measure['voices'] = [{'id':'voice-source', 'notes':events}]
|
||||||
|
measure['harmonies'].sort(key=lambda h:fraction(h['onset']))
|
||||||
|
used = set()
|
||||||
|
for onset in optional:
|
||||||
|
matches = [h for h in measure['harmonies'] if 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
|
||||||
|
used.add(onset)
|
||||||
|
measures.append(measure)
|
||||||
|
self.absolute += cursor
|
||||||
|
if self.open_ties or self.active_tuplets or self.open_octaves or self.tremolo_start:
|
||||||
|
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 measure['voices'][0]['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 = {'schema_version':SCHEMA_VERSION, '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}]}
|
||||||
|
validate_document(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def load_musicxml(path: Path) -> dict:
|
||||||
|
importer = Importer(Path(path))
|
||||||
|
try:
|
||||||
|
return importer.read()
|
||||||
|
except MusicXMLError:
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
raise MusicXMLError(f'{importer.location}: {error}') from error
|
||||||
@@ -2,7 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
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.score import copy_source_voice, make_arranged_document, make_measure, reidentify, slugify, source_staff, write_json
|
||||||
|
from generator.musicxml import load_musicxml
|
||||||
from generator.transposition import TARGET_KEYS, transpose_document
|
from generator.transposition import TARGET_KEYS, transpose_document
|
||||||
|
|
||||||
|
|
||||||
@@ -19,10 +20,10 @@ def practice_document(document: dict) -> dict:
|
|||||||
measure,
|
measure,
|
||||||
voice["notes"],
|
voice["notes"],
|
||||||
section_label=str(measure.get("section_label", "")),
|
section_label=str(measure.get("section_label", "")),
|
||||||
tuplets=voice["tuplets"],
|
|
||||||
)
|
)
|
||||||
output_measure["voices"][0]["id"] = str(voice.get("id", output_measure["voices"][0]["id"]))
|
output_measure["voices"][0]["id"] = str(voice.get("id", output_measure["voices"][0]["id"]))
|
||||||
measures.append(output_measure)
|
measures.append(output_measure)
|
||||||
|
tuplets = reidentify(measures, staff.get('tuplets', []))
|
||||||
return make_arranged_document(
|
return make_arranged_document(
|
||||||
document,
|
document,
|
||||||
staff,
|
staff,
|
||||||
@@ -31,15 +32,17 @@ def practice_document(document: dict) -> dict:
|
|||||||
role="vocal",
|
role="vocal",
|
||||||
arrangement_part_type="practice",
|
arrangement_part_type="practice",
|
||||||
measures=measures,
|
measures=measures,
|
||||||
|
tuplets=tuplets,
|
||||||
|
octave_shifts=staff.get('octave_shifts', []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def key_file_name(key: str) -> str:
|
def key_file_name(key: str) -> str:
|
||||||
return key.lower().replace("#", "sharp").replace("b", "b") + ".json"
|
return slugify(key.replace("#", "sharp")) + ".json"
|
||||||
|
|
||||||
|
|
||||||
def generate_chord_practice(source_path: Path, source_root: Path, output_dir: Path) -> int:
|
def generate_chord_practice(source_path: Path, source_root: Path, output_dir: Path) -> int:
|
||||||
document = practice_document(load_json(source_path))
|
document = practice_document(load_musicxml(source_path))
|
||||||
metadata = document.get("metadata", {})
|
metadata = document.get("metadata", {})
|
||||||
key = str(metadata.get("key_signature", "C") if isinstance(metadata, dict) else "C")
|
key = str(metadata.get("key_signature", "C") if isinstance(metadata, dict) else "C")
|
||||||
relative = source_path.relative_to(source_root)
|
relative = source_path.relative_to(source_root)
|
||||||
@@ -50,7 +53,7 @@ def generate_chord_practice(source_path: Path, source_root: Path, output_dir: Pa
|
|||||||
|
|
||||||
|
|
||||||
def generate_melody_practice(source_path: Path, source_root: Path, output_dir: Path) -> int:
|
def generate_melody_practice(source_path: Path, source_root: Path, output_dir: Path) -> int:
|
||||||
base_document = practice_document(load_json(source_path))
|
base_document = practice_document(load_musicxml(source_path))
|
||||||
relative = source_path.relative_to(source_root)
|
relative = source_path.relative_to(source_root)
|
||||||
lesson_group = relative.parent.name
|
lesson_group = relative.parent.name
|
||||||
exercise_id = slugify(source_path.stem)
|
exercise_id = slugify(source_path.stem)
|
||||||
@@ -67,11 +70,10 @@ def generate_practice_scores(source_dir: Path, output_dir: Path) -> int:
|
|||||||
count = 0
|
count = 0
|
||||||
chord_root = source_dir / "chord"
|
chord_root = source_dir / "chord"
|
||||||
if chord_root.exists():
|
if chord_root.exists():
|
||||||
for path in sorted(chord_root.rglob("*.json")):
|
for path in sorted(chord_root.rglob("*.musicxml")):
|
||||||
count += generate_chord_practice(path, chord_root, output_dir)
|
count += generate_chord_practice(path, chord_root, output_dir)
|
||||||
melody_root = source_dir / "melody"
|
melody_root = source_dir / "melody"
|
||||||
if melody_root.exists():
|
if melody_root.exists():
|
||||||
for path in sorted(melody_root.rglob("*.json")):
|
for path in sorted(melody_root.rglob("*.musicxml")):
|
||||||
count += generate_melody_practice(path, melody_root, output_dir)
|
count += generate_melody_practice(path, melody_root, output_dir)
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|||||||
+77
-772
@@ -4,812 +4,117 @@ import copy
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from generator.styles import canonical_style
|
from generator.model import SCHEMA_VERSION, validate_document
|
||||||
|
|
||||||
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 = {
|
|
||||||
"chord_optional",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def load_json(path: Path) -> dict[str, Any]:
|
def load_json(path: Path) -> dict:
|
||||||
with path.open("r", encoding="utf-8") as stream:
|
document = json.loads(path.read_text(encoding='utf-8'))
|
||||||
document = json.load(stream)
|
validate_document(document)
|
||||||
if not isinstance(document, dict):
|
return document
|
||||||
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:
|
def write_json(path: Path, document: dict) -> None:
|
||||||
|
validate_document(document)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with path.open("w", encoding="utf-8") as stream:
|
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||||
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 human_label(value: str) -> str:
|
def human_label(value: str) -> str:
|
||||||
text = value.replace("_", " ").replace("-", " ").strip()
|
return ' '.join(v.capitalize() for v in value.replace('_',' ').replace('-',' ').split()) or 'General'
|
||||||
return " ".join(part.capitalize() for part in text.split()) or "General"
|
|
||||||
|
|
||||||
|
|
||||||
class CompactScoreError(ValueError):
|
def source_staff(document: dict) -> dict:
|
||||||
pass
|
if len(document.get('staves', [])) != 1:
|
||||||
|
raise ValueError('exactly one source staff is required')
|
||||||
|
return document['staves'][0]
|
||||||
|
|
||||||
|
|
||||||
DURATION_TICKS = {
|
def first_voice(measure: dict) -> dict:
|
||||||
"1": WHOLE_TICKS,
|
if len(measure.get('voices', [])) != 1:
|
||||||
"2": HALF_TICKS,
|
raise ValueError('exactly one source voice is required')
|
||||||
"4": QUARTER_TICKS,
|
return measure['voices'][0]
|
||||||
"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:
|
def measure_notes(measure: dict) -> list[dict]:
|
||||||
for name in names:
|
return first_voice(measure)['notes']
|
||||||
value = document.get(name)
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value
|
|
||||||
value = metadata.get(name)
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
|
|
||||||
def canonicalize_score_metadata(metadata: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
|
def leading_pickup_measure_index(measures: list[dict]) -> int | None:
|
||||||
output = sanitize_json(metadata)
|
return 0 if len(measures)>1 and measures[0].get('implicit') else None
|
||||||
if not isinstance(output, dict):
|
|
||||||
output = {}
|
|
||||||
style_source = output.get("style")
|
|
||||||
try:
|
|
||||||
output["style"] = canonical_style(style_source)
|
|
||||||
except ValueError as error:
|
|
||||||
location = f"{path}: " if path is not None else ""
|
|
||||||
raise CompactScoreError(f"{location}{error}") from error
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_full_score_document(document: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
|
def body_start_measure_index(measures: list[dict]) -> int:
|
||||||
output = copy.deepcopy(document)
|
return 1 if leading_pickup_measure_index(measures) is not None else 0
|
||||||
output.pop("version", None)
|
|
||||||
metadata_value = output.get("metadata", {})
|
|
||||||
if metadata_value is not None and not isinstance(metadata_value, dict):
|
|
||||||
location = f"{path}: " if path is not None else ""
|
|
||||||
raise CompactScoreError(f"{location}metadata must be an object")
|
|
||||||
metadata = copy.deepcopy(metadata_value) if isinstance(metadata_value, dict) else {}
|
|
||||||
for key in ("style",):
|
|
||||||
if key in output and key not in metadata:
|
|
||||||
metadata[key] = output[key]
|
|
||||||
output.pop(key, None)
|
|
||||||
output["metadata"] = canonicalize_score_metadata(metadata, path)
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def parse_duration_token(text: str, label: str) -> int:
|
def copy_source_voice(measure: dict, *, strip_lyrics: bool) -> dict:
|
||||||
dotted = text.endswith(".")
|
voice = copy.deepcopy(first_voice(measure))
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
COMPACT_MEASURE_CONTROL_FIELDS = {
|
|
||||||
"notes",
|
|
||||||
"notation",
|
|
||||||
"section",
|
|
||||||
"section_label",
|
|
||||||
"key",
|
|
||||||
"key_signature",
|
|
||||||
"time",
|
|
||||||
"time_signature",
|
|
||||||
"id",
|
|
||||||
"voices",
|
|
||||||
}
|
|
||||||
|
|
||||||
GENERATED_MEASURE_FIELDS = {
|
|
||||||
"id",
|
|
||||||
"section_label",
|
|
||||||
"time_signature",
|
|
||||||
"key_signature",
|
|
||||||
"voices",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def compact_measure_text(value: Any, index: int) -> tuple[str, dict[str, str], dict[str, Any]]:
|
|
||||||
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
|
|
||||||
extra_fields = {
|
|
||||||
key: sanitize_json(extra_value)
|
|
||||||
for key, extra_value in value.items()
|
|
||||||
if key not in COMPACT_MEASURE_CONTROL_FIELDS
|
|
||||||
}
|
|
||||||
return text, overrides, extra_fields
|
|
||||||
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 normalize_full_score_document(document, path)
|
|
||||||
measures_value = document.get("measures")
|
|
||||||
if not isinstance(measures_value, list):
|
|
||||||
return normalize_full_score_document(document, path)
|
|
||||||
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 {"version", "title", "composer", "key", "key_signature", "time", "time_signature", "clef", "part", "name", "role", "measures", "metadata"}:
|
|
||||||
continue
|
|
||||||
output_metadata[key] = sanitize_json(value)
|
|
||||||
output_metadata = canonicalize_score_metadata(output_metadata, path)
|
|
||||||
|
|
||||||
measures: list[dict[str, Any]] = []
|
|
||||||
for index, value in enumerate(measures_value):
|
|
||||||
text, overrides, extra_fields = 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()
|
|
||||||
measure = {
|
|
||||||
"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,
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
measure.update(extra_fields)
|
|
||||||
measures.append(measure)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"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 voice['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)
|
return voice
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def copy_source_voice(measure: dict[str, Any], *, strip_lyrics: bool, include_optional: bool = True) -> dict[str, Any]:
|
def make_measure(measure_id: str, source: dict, notes: list[dict], *, section_label: str = '') -> dict:
|
||||||
voice = first_voice(measure)
|
result = copy.deepcopy(source)
|
||||||
tuplets = sanitize_json(voice.get("tuplets", []))
|
result.update({'id':measure_id, 'section_label':section_label, 'voices':[{'id':'voice-arranged', 'notes':notes}]})
|
||||||
notes = [sanitized_note(note, strip_lyrics=strip_lyrics, include_optional=include_optional) for note in measure_notes(measure)]
|
return result
|
||||||
return {
|
|
||||||
"id": str(voice.get("id", "voice-source")),
|
|
||||||
"tuplets": tuplets if isinstance(tuplets, list) else [],
|
|
||||||
"notes": notes or [rest_note("arr-rest-empty", measure_capacity_ticks(str(measure.get("time_signature", "4/4"))))],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def measure_notation_metadata(source: dict[str, Any]) -> dict[str, Any]:
|
def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
|
||||||
return {
|
"""Clone-local links survive; links cut by an arrangement boundary become attacks."""
|
||||||
key: sanitize_json(value)
|
tone_map, event_map = {}, {}
|
||||||
for key, value in source.items()
|
tuplets = []
|
||||||
if key not in GENERATED_MEASURE_FIELDS
|
for measure in measures:
|
||||||
}
|
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_measure(measure_id: str, source: dict[str, Any], notes: list[dict[str, Any]], *, section_label: str = "", tuplets: list[Any] | None = None) -> dict[str, Any]:
|
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:
|
||||||
measure: dict[str, Any] = {
|
return {'schema_version':SCHEMA_VERSION, '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':octave_shifts or []}]}
|
||||||
"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
|
|
||||||
measure.update(measure_notation_metadata(source))
|
|
||||||
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]:
|
|
||||||
source_metadata = source_document.get("metadata", {})
|
|
||||||
metadata = canonicalize_score_metadata(source_metadata if isinstance(source_metadata, dict) else {})
|
|
||||||
output: dict[str, Any] = {
|
|
||||||
"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:
|
def count_json_files(root: Path) -> int:
|
||||||
return sum(1 for path in root.rglob("*.json") if path.is_file())
|
return sum(1 for p in root.rglob('*.json') if p.is_file())
|
||||||
|
|
||||||
|
|
||||||
def validate_generated_tree(root: Path) -> None:
|
def validate_generated_tree(root: Path) -> None:
|
||||||
for path in root.rglob("*.json"):
|
for path in root.rglob('*.json'):
|
||||||
text = path.read_text(encoding="utf-8")
|
load_json(path)
|
||||||
for field in FORBIDDEN_OUTPUT_FIELDS:
|
|
||||||
if f'"{field}"' in text:
|
|
||||||
raise ValueError(f"{path} contains forbidden generated field {field}")
|
|
||||||
json.loads(text)
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any
|
|||||||
|
|
||||||
from generator.score import load_json, measure_notes
|
from generator.score import load_json, measure_notes
|
||||||
from generator.transposition import TARGET_KEYS
|
from generator.transposition import TARGET_KEYS
|
||||||
|
from generator.model import fraction, pitch_name
|
||||||
|
|
||||||
|
|
||||||
def _arranged_staff(document: dict[str, Any]) -> dict[str, Any]:
|
def _arranged_staff(document: dict[str, Any]) -> dict[str, Any]:
|
||||||
@@ -20,17 +21,16 @@ def _measure_summary(staff: dict[str, Any], measure: dict[str, Any], measure_num
|
|||||||
summary: dict[str, Any] = {
|
summary: dict[str, Any] = {
|
||||||
"measure": measure_number,
|
"measure": measure_number,
|
||||||
"section_label": str(measure.get("section_label", "")),
|
"section_label": str(measure.get("section_label", "")),
|
||||||
"chord_symbols": [str(note.get("chord_symbol", "")) for note in notes if isinstance(note.get("chord_symbol"), str) and note.get("chord_symbol")],
|
"harmonies": [{"onset": str(fraction(h["onset"])), "symbol": h["symbol"]} for h in measure["harmonies"]],
|
||||||
|
"duration": str(fraction(measure["duration"])),
|
||||||
"slash_count": sum(1 for note in notes if note.get("kind", "note") == "note" and note.get("notehead") == "slash"),
|
"slash_count": sum(1 for note in notes if note.get("kind", "note") == "note" and note.get("notehead") == "slash"),
|
||||||
}
|
}
|
||||||
if staff.get("arrangement_part_type") == "vocal":
|
if staff.get("arrangement_part_type") == "vocal":
|
||||||
summary["vocal_skeleton"] = [
|
summary["vocal_skeleton"] = [
|
||||||
{
|
f'{fraction(note["onset"])}+{fraction(note["duration"])} '
|
||||||
"kind": str(note.get("kind", "note")),
|
+ note['written']['type'] + '.'*note['written'].get('dots',0)
|
||||||
"duration_ticks": int(note.get("duration_ticks", 0)),
|
+ ' ' + note['kind'] + ' ' + ','.join(pitch_name(p)+str(p['octave']) for p in note['pitches'])
|
||||||
"display_ticks": int(note["display_ticks"]) if isinstance(note.get("display_ticks"), int) else None,
|
+ ' ' + note.get('lyric','')
|
||||||
"has_lyric": isinstance(note.get("lyric"), str) and bool(note.get("lyric")),
|
|
||||||
}
|
|
||||||
for note in notes
|
for note in notes
|
||||||
]
|
]
|
||||||
return summary
|
return summary
|
||||||
|
|||||||
+120
-166
@@ -2,34 +2,20 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import copy
|
import copy
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
|
||||||
|
from generator import harmony
|
||||||
|
from generator.model import LETTERS, NATURAL, key_from_fifths, key_label, parse_pitch_name, pitch_number
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class KeyProfile:
|
class KeyProfile:
|
||||||
name: str
|
name: str
|
||||||
root: int
|
root: int
|
||||||
degrees: tuple[str, str, str, str, str, str, str]
|
fifths: int
|
||||||
prefer_flats: bool
|
|
||||||
|
|
||||||
|
|
||||||
KEY_PROFILES = (
|
KEY_PROFILES = tuple(KeyProfile(name, root, fifths) for root, (name, fifths) in enumerate((('C',0),('Db',-5),('D',2),('Eb',-3),('E',4),('F',-1),('Gb',-6),('G',1),('Ab',-4),('A',3),('Bb',-2),('B',5))))
|
||||||
KeyProfile("C", 0, ("C", "D", "E", "F", "G", "A", "B"), False),
|
TARGET_KEYS = tuple(p.name for p in KEY_PROFILES)
|
||||||
KeyProfile("Db", 1, ("Db", "Eb", "F", "Gb", "Ab", "Bb", "C"), True),
|
|
||||||
KeyProfile("D", 2, ("D", "E", "F#", "G", "A", "B", "C#"), False),
|
|
||||||
KeyProfile("Eb", 3, ("Eb", "F", "G", "Ab", "Bb", "C", "D"), True),
|
|
||||||
KeyProfile("E", 4, ("E", "F#", "G#", "A", "B", "C#", "D#"), False),
|
|
||||||
KeyProfile("F", 5, ("F", "G", "A", "Bb", "C", "D", "E"), True),
|
|
||||||
KeyProfile("Gb", 6, ("Gb", "Ab", "Bb", "Cb", "Db", "Eb", "F"), True),
|
|
||||||
KeyProfile("G", 7, ("G", "A", "B", "C", "D", "E", "F#"), False),
|
|
||||||
KeyProfile("Ab", 8, ("Ab", "Bb", "C", "Db", "Eb", "F", "G"), True),
|
|
||||||
KeyProfile("A", 9, ("A", "B", "C#", "D", "E", "F#", "G#"), False),
|
|
||||||
KeyProfile("Bb", 10, ("Bb", "C", "D", "Eb", "F", "G", "A"), True),
|
|
||||||
KeyProfile("B", 11, ("B", "C#", "D#", "E", "F#", "G#", "A#"), False),
|
|
||||||
)
|
|
||||||
TARGET_KEYS = tuple(profile.name for profile in KEY_PROFILES)
|
|
||||||
NATURAL_PITCH_CLASSES = {"c": 0, "d": 2, "e": 4, "f": 5, "g": 7, "a": 9, "b": 11}
|
|
||||||
LETTER_DEGREES = {"c": 0, "d": 1, "e": 2, "f": 3, "g": 4, "a": 5, "b": 6}
|
|
||||||
|
|
||||||
|
|
||||||
def mod12(value: int) -> int:
|
def mod12(value: int) -> int:
|
||||||
@@ -37,158 +23,126 @@ def mod12(value: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def find_key(name: str) -> KeyProfile:
|
def find_key(name: str) -> KeyProfile:
|
||||||
normalized = name.lower()
|
tonic = parse_pitch_name(name)
|
||||||
aliases = {"c#": "db", "d#": "eb", "f#": "gb", "g#": "ab", "a#": "bb"}
|
for fifths in range(-14, 15):
|
||||||
normalized = aliases.get(normalized, normalized)
|
key = key_from_fifths(fifths)
|
||||||
for profile in KEY_PROFILES:
|
if key['tonic'] == tonic:
|
||||||
if profile.name.lower() == normalized:
|
return KeyProfile(name, (NATURAL[tonic['letter']] + tonic['alter']) % 12, fifths)
|
||||||
return profile
|
raise ValueError(f'unsupported key signature: {name}')
|
||||||
raise ValueError(f"unsupported key signature: {name}")
|
|
||||||
|
|
||||||
|
|
||||||
def signed_interval(source: KeyProfile, target: KeyProfile) -> int:
|
def signed_interval(source: KeyProfile, target: KeyProfile) -> int:
|
||||||
interval = mod12(target.root - source.root)
|
value = (target.root-source.root) % 12
|
||||||
return interval - 12 if interval > 6 else interval
|
return value-12 if value > 6 else value
|
||||||
|
|
||||||
|
|
||||||
def split_spelling(value: str) -> tuple[str, str]:
|
def interval_between(source: dict, target: dict) -> tuple[int, int]:
|
||||||
if not value:
|
semitones = (NATURAL[target['letter']] + target['alter'] - NATURAL[source['letter']] - source['alter']) % 12
|
||||||
return "C", ""
|
if semitones > 6:
|
||||||
return value[0], value[1:]
|
semitones -= 12
|
||||||
|
steps = (LETTERS.index(target['letter']) - LETTERS.index(source['letter'])) % 7
|
||||||
|
if semitones < 0 or semitones == 0 and steps > 3:
|
||||||
|
steps -= 7
|
||||||
|
return steps, semitones
|
||||||
|
|
||||||
|
|
||||||
def accidental_offset(accidental: str) -> int:
|
def shift_pitch(pitch: dict, steps: int, semitones: int) -> dict:
|
||||||
value = accidental.lower()
|
output = copy.deepcopy(pitch)
|
||||||
if value in {"#", "sharp"}:
|
original_octave = pitch.get('octave', 4)
|
||||||
return 1
|
diatonic = original_octave * 7 + LETTERS.index(pitch['letter']) + steps
|
||||||
if value in {"b", "flat"}:
|
octave, index = divmod(diatonic, 7)
|
||||||
return -1
|
output['letter'] = LETTERS[index]
|
||||||
return 0
|
output['octave'] = octave
|
||||||
|
output['alter'] = pitch_number({**pitch, 'octave':original_octave}) + semitones - (12 * (octave+1) + NATURAL[output['letter']])
|
||||||
|
if 'octave' not in pitch:
|
||||||
def spelling_pitch_class(spelling: str) -> int:
|
output.pop('octave')
|
||||||
letter, accidental = split_spelling(spelling)
|
if 'accidental_display' in output:
|
||||||
return mod12(NATURAL_PITCH_CLASSES.get(letter.lower(), 0) + accidental_offset(accidental))
|
output['accidental_display']['name'] = {-3:'triple-flat',-2:'flat-flat',-1:'flat',0:'natural',1:'sharp',2:'double-sharp',3:'triple-sharp'}.get(output['alter'], 'compound')
|
||||||
|
|
||||||
|
|
||||||
def pitch_class(pitch: dict[str, Any]) -> int:
|
|
||||||
letter = str(pitch.get("letter", "c")).lower()
|
|
||||||
accidental = str(pitch.get("accidental", "n"))
|
|
||||||
return mod12(NATURAL_PITCH_CLASSES.get(letter, 0) + accidental_offset(accidental))
|
|
||||||
|
|
||||||
|
|
||||||
def pitch_midi(pitch: dict[str, Any]) -> int:
|
|
||||||
octave = int(pitch.get("octave", 4))
|
|
||||||
return octave * 12 + pitch_class(pitch)
|
|
||||||
|
|
||||||
|
|
||||||
def octave_for(midi: int, pitch_class_value: int) -> int:
|
|
||||||
octave = (midi - pitch_class_value) // 12
|
|
||||||
while octave * 12 + pitch_class_value > midi:
|
|
||||||
octave -= 1
|
|
||||||
while (octave + 1) * 12 + pitch_class_value <= midi:
|
|
||||||
octave += 1
|
|
||||||
return octave
|
|
||||||
|
|
||||||
|
|
||||||
def matching_source_degree_for_pitch(pitch: dict[str, Any], source: KeyProfile) -> int | None:
|
|
||||||
actual = pitch_class(pitch)
|
|
||||||
source_letter_degree = LETTER_DEGREES.get(str(pitch.get("letter", "c")).lower(), 0)
|
|
||||||
for index, spelling in enumerate(source.degrees):
|
|
||||||
letter, _ = split_spelling(spelling)
|
|
||||||
if LETTER_DEGREES.get(letter.lower(), 0) == source_letter_degree and spelling_pitch_class(spelling) == actual:
|
|
||||||
return index
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def chromatic_spelling(pitch_class_value: int, prefer_flats: bool) -> str:
|
|
||||||
flats = ("C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B")
|
|
||||||
sharps = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")
|
|
||||||
return flats[pitch_class_value] if prefer_flats else sharps[pitch_class_value]
|
|
||||||
|
|
||||||
|
|
||||||
def transpose_pitch(pitch: dict[str, Any], source: KeyProfile, target: KeyProfile, interval: int) -> dict[str, Any]:
|
|
||||||
transposed_midi = pitch_midi(pitch) + interval
|
|
||||||
degree = matching_source_degree_for_pitch(pitch, source)
|
|
||||||
spelling = target.degrees[degree] if degree is not None else chromatic_spelling(mod12(transposed_midi), target.prefer_flats)
|
|
||||||
letter, accidental = split_spelling(spelling)
|
|
||||||
pc = spelling_pitch_class(spelling)
|
|
||||||
return {
|
|
||||||
"letter": letter.lower(),
|
|
||||||
"accidental": accidental or "n",
|
|
||||||
"octave": octave_for(transposed_midi, pc),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def parse_chord_root(value: str, offset: int) -> tuple[int, str] | None:
|
|
||||||
if offset >= len(value) or value[offset].upper() not in "ABCDEFG":
|
|
||||||
return None
|
|
||||||
length = 1
|
|
||||||
if offset + length < len(value) and value[offset + length] in {"#", "b"}:
|
|
||||||
length += 1
|
|
||||||
return length, value[offset:offset + length]
|
|
||||||
|
|
||||||
|
|
||||||
def matching_source_degree_for_root(root: str, source: KeyProfile) -> int | None:
|
|
||||||
root_letter, _ = split_spelling(root)
|
|
||||||
actual = spelling_pitch_class(root)
|
|
||||||
for index, spelling in enumerate(source.degrees):
|
|
||||||
letter, _ = split_spelling(spelling)
|
|
||||||
if LETTER_DEGREES.get(letter.lower(), 0) == LETTER_DEGREES.get(root_letter.lower(), 0) and spelling_pitch_class(spelling) == actual:
|
|
||||||
return index
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def transpose_root(root: str, source: KeyProfile, target: KeyProfile, interval: int) -> str:
|
|
||||||
degree = matching_source_degree_for_root(root, source)
|
|
||||||
if degree is not None:
|
|
||||||
return target.degrees[degree]
|
|
||||||
return chromatic_spelling(mod12(spelling_pitch_class(root) + interval), target.prefer_flats)
|
|
||||||
|
|
||||||
|
|
||||||
def transpose_chord_symbol(symbol: str, source: KeyProfile, target: KeyProfile, interval: int) -> str:
|
|
||||||
root = parse_chord_root(symbol, 0)
|
|
||||||
if root is None:
|
|
||||||
return symbol
|
|
||||||
root_length, root_text = root
|
|
||||||
result = transpose_root(root_text, source, target, interval) + symbol[root_length:]
|
|
||||||
slash = result.find("/")
|
|
||||||
if slash < 0:
|
|
||||||
return result
|
|
||||||
bass = parse_chord_root(result, slash + 1)
|
|
||||||
if bass is None:
|
|
||||||
return result
|
|
||||||
bass_length, bass_text = bass
|
|
||||||
return result[:slash + 1] + transpose_root(bass_text, source, target, interval) + result[slash + 1 + bass_length:]
|
|
||||||
|
|
||||||
|
|
||||||
def transpose_document(document: dict[str, Any], target_key_name: str) -> dict[str, Any]:
|
|
||||||
output = copy.deepcopy(document)
|
|
||||||
metadata = output.setdefault("metadata", {})
|
|
||||||
if not isinstance(metadata, dict):
|
|
||||||
raise ValueError("score metadata must be an object")
|
|
||||||
source_key = find_key(str(metadata.get("key_signature", "C") or "C"))
|
|
||||||
target_key = find_key(target_key_name)
|
|
||||||
interval = signed_interval(source_key, target_key)
|
|
||||||
metadata["key_signature"] = target_key.name
|
|
||||||
for staff in output.get("staves", []):
|
|
||||||
if not isinstance(staff, dict):
|
|
||||||
continue
|
|
||||||
for measure in staff.get("measures", []):
|
|
||||||
if not isinstance(measure, dict):
|
|
||||||
continue
|
|
||||||
measure["key_signature"] = target_key.name
|
|
||||||
for voice in measure.get("voices", []):
|
|
||||||
if not isinstance(voice, dict):
|
|
||||||
continue
|
|
||||||
for note in voice.get("notes", []):
|
|
||||||
if not isinstance(note, dict):
|
|
||||||
continue
|
|
||||||
chord = note.get("chord_symbol")
|
|
||||||
if isinstance(chord, str) and chord:
|
|
||||||
note["chord_symbol"] = transpose_chord_symbol(chord, source_key, target_key, interval)
|
|
||||||
pitches = note.get("pitches")
|
|
||||||
if isinstance(pitches, list):
|
|
||||||
note["pitches"] = [transpose_pitch(pitch, source_key, target_key, interval) if isinstance(pitch, dict) else pitch for pitch in pitches]
|
|
||||||
return output
|
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(LETTERS):
|
||||||
|
p = shift_pitch({'letter':letter, 'alter':key['alterations'][index]}, steps, semitones)
|
||||||
|
alterations[LETTERS.index(p['letter'])] = p['alter']
|
||||||
|
result = {**copy.deepcopy(key), 'tonic':tonic, 'alterations':alterations}
|
||||||
|
if not key.get('custom') and tonic:
|
||||||
|
for fifths in range(-14,15):
|
||||||
|
candidate = key_from_fifths(fifths, key['mode'])
|
||||||
|
if candidate['tonic']==tonic and candidate['alterations']==alterations:
|
||||||
|
result['fifths'] = fifths
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
result['custom'] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def transpose_document(document: dict, target_key_name: str) -> dict:
|
||||||
|
output = copy.deepcopy(document)
|
||||||
|
metadata = output['metadata']
|
||||||
|
key = metadata['key']
|
||||||
|
origin = key.get('tonic') or metadata.get('transpose_origin')
|
||||||
|
if origin is None:
|
||||||
|
raise ValueError('keyless melody practice requires a vamp:tonic score property')
|
||||||
|
target = parse_pitch_name(target_key_name)
|
||||||
|
steps, semitones = interval_between(origin, target)
|
||||||
|
metadata['key'] = shift_key(key, steps, semitones)
|
||||||
|
metadata['key_signature'] = key_label(metadata['key'])
|
||||||
|
metadata['variant_tonic'] = target_key_name
|
||||||
|
for staff in output['staves']:
|
||||||
|
for measure in staff['measures']:
|
||||||
|
measure['key'] = shift_key(measure['key'], steps, semitones)
|
||||||
|
measure['key_signature'] = key_label(measure['key'])
|
||||||
|
for change in measure.get('attributes', []):
|
||||||
|
if 'key' in change:
|
||||||
|
change['key'] = shift_key(change['key'], steps, semitones)
|
||||||
|
for h in measure.get('harmonies', []):
|
||||||
|
for field in ('root','bass'):
|
||||||
|
if field in h:
|
||||||
|
h[field] = shift_pitch(h[field], steps, semitones)
|
||||||
|
h['symbol'] = harmony.symbol(h)
|
||||||
|
for voice in measure['voices']:
|
||||||
|
for note in voice['notes']:
|
||||||
|
note['pitches'] = [shift_pitch(p, steps, semitones) for p in note.get('pitches', [])]
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def concert_document(document: dict) -> dict:
|
||||||
|
"""Normalize MusicXML instrument transposition before concert-pitch arranging."""
|
||||||
|
output = copy.deepcopy(document)
|
||||||
|
zero = {'diatonic':0, 'chromatic':0, 'octave_change':0}
|
||||||
|
for staff in output['staves']:
|
||||||
|
for measure in staff['measures']:
|
||||||
|
initial = measure['transpose']
|
||||||
|
changes = copy.deepcopy(measure.get('attributes', []))
|
||||||
|
|
||||||
|
def interval(onset):
|
||||||
|
state = initial
|
||||||
|
from generator.model import fraction
|
||||||
|
for change in changes:
|
||||||
|
if fraction(change['onset']) <= onset and 'transpose' in change:
|
||||||
|
state = change['transpose']
|
||||||
|
return state['diatonic']+7*state['octave_change'], state['chromatic']+12*state['octave_change']
|
||||||
|
|
||||||
|
from generator.model import fraction
|
||||||
|
measure['key'] = shift_key(measure['key'], *interval(0))
|
||||||
|
measure['key_signature'] = key_label(measure['key'])
|
||||||
|
for n in measure['voices'][0]['notes']:
|
||||||
|
n['pitches'] = [shift_pitch(p,*interval(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(fraction(h['onset'])))
|
||||||
|
h['symbol'] = harmony.symbol(h)
|
||||||
|
for change in measure.get('attributes',[]):
|
||||||
|
if 'key' in change:
|
||||||
|
change['key'] = shift_key(change['key'],*interval(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
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <expected>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <modulus.hpp>
|
||||||
|
#include <vamp_zero/types.hpp>
|
||||||
|
|
||||||
|
namespace vamp_zero {
|
||||||
|
auto parse_notation_document(std::string_view json, std::string source_path) -> std::expected<score_document, modulus::error>;
|
||||||
|
auto written_quarters(score_written_duration const& written) -> double;
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ enum class score_accidental_mark {
|
|||||||
FLAT,
|
FLAT,
|
||||||
NATURAL,
|
NATURAL,
|
||||||
SHARP,
|
SHARP,
|
||||||
|
DOUBLE_FLAT,
|
||||||
|
DOUBLE_SHARP,
|
||||||
|
TRIPLE_FLAT,
|
||||||
|
TRIPLE_SHARP,
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class score_layout_item_kind {
|
enum class score_layout_item_kind {
|
||||||
@@ -48,6 +52,8 @@ enum class score_layout_item_role {
|
|||||||
TIE,
|
TIE,
|
||||||
TUPLET,
|
TUPLET,
|
||||||
TUPLET_BRACKET,
|
TUPLET_BRACKET,
|
||||||
|
OCTAVE_SHIFT,
|
||||||
|
TREMOLO,
|
||||||
TITLE,
|
TITLE,
|
||||||
COMPOSER,
|
COMPOSER,
|
||||||
PART,
|
PART,
|
||||||
@@ -71,6 +77,7 @@ struct score_layout_item {
|
|||||||
std::float32_t staff_spacing{};
|
std::float32_t staff_spacing{};
|
||||||
std::int32_t codepoint{};
|
std::int32_t codepoint{};
|
||||||
std::int32_t text_scale{};
|
std::int32_t text_scale{};
|
||||||
|
std::float32_t text_size_multiplier{1.0F};
|
||||||
std::size_t system_index{std::numeric_limits<std::size_t>::max()};
|
std::size_t system_index{std::numeric_limits<std::size_t>::max()};
|
||||||
std::size_t measure_index{std::numeric_limits<std::size_t>::max()};
|
std::size_t measure_index{std::numeric_limits<std::size_t>::max()};
|
||||||
std::size_t note_index{std::numeric_limits<std::size_t>::max()};
|
std::size_t note_index{std::numeric_limits<std::size_t>::max()};
|
||||||
@@ -86,6 +93,8 @@ struct score_page_layout {
|
|||||||
struct score_system_range {
|
struct score_system_range {
|
||||||
std::size_t first_measure{};
|
std::size_t first_measure{};
|
||||||
std::size_t measure_count{};
|
std::size_t measure_count{};
|
||||||
|
std::size_t first_note{};
|
||||||
|
std::size_t note_count{std::numeric_limits<std::size_t>::max()};
|
||||||
};
|
};
|
||||||
|
|
||||||
constexpr auto score_measures_per_system = std::size_t{4};
|
constexpr auto score_measures_per_system = std::size_t{4};
|
||||||
@@ -95,6 +104,7 @@ auto notated_accidentals_for_measure(vamp_zero::score_measure const& measure, st
|
|||||||
auto score_system_ranges(vamp_zero::score_document const& score) -> std::vector<vamp_zero::score_system_range>;
|
auto score_system_ranges(vamp_zero::score_document const& score) -> std::vector<vamp_zero::score_system_range>;
|
||||||
auto score_page_count(vamp_zero::score_document const& score) -> std::size_t;
|
auto score_page_count(vamp_zero::score_document const& score) -> std::size_t;
|
||||||
auto layout_score_page(luma::rect bounds, vamp_zero::score_document const& score, std::size_t page_index) -> vamp_zero::score_page_layout;
|
auto layout_score_page(luma::rect bounds, vamp_zero::score_document const& score, std::size_t page_index) -> vamp_zero::score_page_layout;
|
||||||
|
auto draw_score_layout(luma::draw_context& context, vamp_zero::score_page_layout const& layout) -> void;
|
||||||
auto draw_score_page(luma::draw_context& context, luma::rect bounds, vamp_zero::score_document const& score, std::size_t page_index) -> void;
|
auto draw_score_page(luma::draw_context& context, luma::rect bounds, vamp_zero::score_document const& score, std::size_t page_index) -> void;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <array>
|
||||||
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -10,6 +12,41 @@
|
|||||||
|
|
||||||
namespace vamp_zero {
|
namespace vamp_zero {
|
||||||
|
|
||||||
|
struct score_time {
|
||||||
|
std::int64_t numerator{};
|
||||||
|
std::int64_t denominator{1};
|
||||||
|
auto quarters() const -> double { return static_cast<double>(numerator) / static_cast<double>(denominator); }
|
||||||
|
};
|
||||||
|
|
||||||
|
struct score_written_duration {
|
||||||
|
std::string type{};
|
||||||
|
std::int32_t dots{};
|
||||||
|
score_time ratio{1, 1};
|
||||||
|
std::string normal_type{};
|
||||||
|
std::int32_t normal_dots{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct score_beam {
|
||||||
|
std::int32_t level{1};
|
||||||
|
std::string type{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct score_attribute {
|
||||||
|
score_time onset{};
|
||||||
|
std::string clef{};
|
||||||
|
std::string time_signature{};
|
||||||
|
std::string key_signature{};
|
||||||
|
std::optional<std::array<std::int32_t, 7>> key_alterations{};
|
||||||
|
std::vector<score_time> beats{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct score_harmony {
|
||||||
|
std::string id{};
|
||||||
|
score_time onset{};
|
||||||
|
std::string symbol{};
|
||||||
|
std::string kind{};
|
||||||
|
};
|
||||||
|
|
||||||
enum class hardware_mode {
|
enum class hardware_mode {
|
||||||
FAKE,
|
FAKE,
|
||||||
REAL,
|
REAL,
|
||||||
@@ -19,14 +56,23 @@ struct score_pitch {
|
|||||||
std::char_t letter{'c'};
|
std::char_t letter{'c'};
|
||||||
std::int32_t octave{};
|
std::int32_t octave{};
|
||||||
std::string accidental{};
|
std::string accidental{};
|
||||||
|
std::string id{};
|
||||||
|
std::optional<std::int32_t> alteration{};
|
||||||
|
score_time duration{};
|
||||||
|
score_written_duration written{};
|
||||||
|
std::string tie_to{};
|
||||||
|
std::string tie_from{};
|
||||||
|
std::bool_t explicit_accidental{};
|
||||||
|
std::bool_t accidental_parentheses{};
|
||||||
|
std::bool_t accidental_bracket{};
|
||||||
};
|
};
|
||||||
|
|
||||||
struct score_note {
|
struct score_note {
|
||||||
std::string id{};
|
std::string id{};
|
||||||
std::string kind{};
|
std::string kind{};
|
||||||
std::bool_t hidden{};
|
std::bool_t hidden{};
|
||||||
std::int64_t duration_ticks{};
|
double duration_ticks{};
|
||||||
std::int64_t display_ticks{};
|
double display_ticks{};
|
||||||
std::string notehead{};
|
std::string notehead{};
|
||||||
std::string chord_symbol{};
|
std::string chord_symbol{};
|
||||||
std::string lyric{};
|
std::string lyric{};
|
||||||
@@ -34,12 +80,34 @@ struct score_note {
|
|||||||
std::bool_t tie_from_previous{};
|
std::bool_t tie_from_previous{};
|
||||||
std::string tuplet_id{};
|
std::string tuplet_id{};
|
||||||
std::vector<vamp_zero::score_pitch> pitches{};
|
std::vector<vamp_zero::score_pitch> pitches{};
|
||||||
|
score_time onset{};
|
||||||
|
score_time duration{};
|
||||||
|
score_written_duration written{};
|
||||||
|
std::vector<score_beam> beams{};
|
||||||
|
std::vector<std::string> tuplet_ids{};
|
||||||
|
std::string stem{};
|
||||||
|
std::bool_t grace{};
|
||||||
|
std::bool_t grace_slash{};
|
||||||
|
std::bool_t cue{};
|
||||||
|
std::bool_t measure_rest{};
|
||||||
|
std::int32_t tremolo_marks{};
|
||||||
|
std::string tremolo_type{};
|
||||||
|
std::string tremolo_partner{};
|
||||||
|
std::string lyric_extend{};
|
||||||
|
std::string clef{};
|
||||||
|
std::int32_t display_octaves{};
|
||||||
|
std::optional<score_pitch> rest_position{};
|
||||||
};
|
};
|
||||||
|
|
||||||
struct score_tuplet {
|
struct score_tuplet {
|
||||||
std::string id{};
|
std::string id{};
|
||||||
std::int32_t note_count{};
|
std::int32_t note_count{};
|
||||||
std::int32_t space_count{};
|
std::int32_t space_count{};
|
||||||
|
std::string start{};
|
||||||
|
std::string end{};
|
||||||
|
std::int32_t depth{};
|
||||||
|
std::string bracket{"auto"};
|
||||||
|
std::string show_number{"actual"};
|
||||||
};
|
};
|
||||||
|
|
||||||
struct score_measure {
|
struct score_measure {
|
||||||
@@ -49,6 +117,21 @@ struct score_measure {
|
|||||||
std::string key_signature{};
|
std::string key_signature{};
|
||||||
std::vector<vamp_zero::score_note> notes{};
|
std::vector<vamp_zero::score_note> notes{};
|
||||||
std::vector<vamp_zero::score_tuplet> tuplets{};
|
std::vector<vamp_zero::score_tuplet> tuplets{};
|
||||||
|
score_time duration{};
|
||||||
|
std::string clef{};
|
||||||
|
std::array<std::int32_t, 7> key_alterations{};
|
||||||
|
std::bool_t structured_key{};
|
||||||
|
std::vector<score_time> beats{};
|
||||||
|
std::vector<score_harmony> harmonies{};
|
||||||
|
std::vector<score_attribute> attributes{};
|
||||||
|
std::string right_barline{"regular"};
|
||||||
|
std::bool_t implicit{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct score_octave_shift {
|
||||||
|
score_time start{};
|
||||||
|
score_time end{};
|
||||||
|
std::int32_t octaves{};
|
||||||
};
|
};
|
||||||
|
|
||||||
struct score_document {
|
struct score_document {
|
||||||
@@ -63,6 +146,7 @@ struct score_document {
|
|||||||
std::string source_path{};
|
std::string source_path{};
|
||||||
std::string source_json{};
|
std::string source_json{};
|
||||||
std::vector<vamp_zero::score_measure> measures{};
|
std::vector<vamp_zero::score_measure> measures{};
|
||||||
|
std::vector<score_octave_shift> octave_shifts{};
|
||||||
};
|
};
|
||||||
|
|
||||||
struct library_part {
|
struct library_part {
|
||||||
|
|||||||
+21
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import json
|
import json
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import shlex
|
import shlex
|
||||||
@@ -136,6 +137,9 @@ RUN python -m pip install --no-cache-dir conan=={CONAN_VERSION}
|
|||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def build_requirements_digest():
|
||||||
|
return hashlib.sha256((repo_root() / "tools/requirements.txt").read_bytes()).hexdigest()
|
||||||
|
|
||||||
def product_build_dockerfile():
|
def product_build_dockerfile():
|
||||||
return f"""FROM {DEFAULT_BUILD_BASE_IMAGE}
|
return f"""FROM {DEFAULT_BUILD_BASE_IMAGE}
|
||||||
|
|
||||||
@@ -202,6 +206,8 @@ COPY tools/requirements.txt /tmp/vamp-build-requirements.txt
|
|||||||
RUN python -m pip install --no-cache-dir \\
|
RUN python -m pip install --no-cache-dir \\
|
||||||
conan=={CONAN_VERSION} -r /tmp/vamp-build-requirements.txt
|
conan=={CONAN_VERSION} -r /tmp/vamp-build-requirements.txt
|
||||||
|
|
||||||
|
LABEL org.khodo.product-vamp.build-requirements="{build_requirements_digest()}"
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
RUN mkdir -p /opt && touch /opt/modulus-build-image
|
RUN mkdir -p /opt && touch /opt/modulus-build-image
|
||||||
@@ -482,6 +488,19 @@ def delegate(command, argv):
|
|||||||
ensure_runtime(root)
|
ensure_runtime(root)
|
||||||
sys.path.insert(0, str(root / RUNTIME_DIR))
|
sys.path.insert(0, str(root / RUNTIME_DIR))
|
||||||
import modulus.cli
|
import modulus.cli
|
||||||
|
original_image_available = modulus.cli.docker_image_available
|
||||||
|
|
||||||
|
def current_build_image(context):
|
||||||
|
if not original_image_available(context):
|
||||||
|
return False
|
||||||
|
inspected = subprocess.run(
|
||||||
|
["docker", "image", "inspect", context.docker_image, "--format",
|
||||||
|
'{{index .Config.Labels "org.khodo.product-vamp.build-requirements"}}'],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
return inspected.returncode == 0 and inspected.stdout.strip() == build_requirements_digest()
|
||||||
|
|
||||||
|
modulus.cli.docker_image_available = current_build_image
|
||||||
original_docker_run = modulus.cli.docker_run
|
original_docker_run = modulus.cli.docker_run
|
||||||
|
|
||||||
def docker_run_with_host_socket(context, command, docker_args=None, env_overrides=None):
|
def docker_run_with_host_socket(context, command, docker_args=None, env_overrides=None):
|
||||||
@@ -502,6 +521,8 @@ def delegate(command, argv):
|
|||||||
try:
|
try:
|
||||||
return modulus.cli.main(command)
|
return modulus.cli.main(command)
|
||||||
finally:
|
finally:
|
||||||
|
modulus.cli.docker_image_available = original_image_available
|
||||||
|
modulus.cli.docker_run = original_docker_run
|
||||||
os.chdir(previous_cwd)
|
os.chdir(previous_cwd)
|
||||||
sys.argv = previous_argv
|
sys.argv = previous_argv
|
||||||
|
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ ON CONFLICT(id) DO UPDATE SET
|
|||||||
THEN excluded.updated_at_ms
|
THEN excluded.updated_at_ms
|
||||||
ELSE songs.updated_at_ms
|
ELSE songs.updated_at_ms
|
||||||
END
|
END
|
||||||
|
WHERE songs.catalog_managed = 1
|
||||||
)sql",
|
)sql",
|
||||||
std::move(params));
|
std::move(params));
|
||||||
if (!inserted) {
|
if (!inserted) {
|
||||||
@@ -417,6 +418,7 @@ ON CONFLICT(id) DO UPDATE SET
|
|||||||
THEN excluded.updated_at_ms
|
THEN excluded.updated_at_ms
|
||||||
ELSE practice_exercises.updated_at_ms
|
ELSE practice_exercises.updated_at_ms
|
||||||
END
|
END
|
||||||
|
WHERE practice_exercises.catalog_managed = 1
|
||||||
)sql",
|
)sql",
|
||||||
std::move(params));
|
std::move(params));
|
||||||
if (!inserted) {
|
if (!inserted) {
|
||||||
|
|||||||
+4
-309
@@ -20,6 +20,7 @@
|
|||||||
|
|
||||||
#include <vamp_zero/errors.hpp>
|
#include <vamp_zero/errors.hpp>
|
||||||
#include <vamp_zero/score.hpp>
|
#include <vamp_zero/score.hpp>
|
||||||
|
#include <vamp_zero/notation.hpp>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -27,72 +28,6 @@ auto invalid(std::string message) -> modulus::error {
|
|||||||
return vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, std::move(message));
|
return vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, std::move(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
auto required_string(boost::json::object const& object, std::char_t const* field) -> std::expected<std::string, modulus::error> {
|
|
||||||
auto found = object.find(field);
|
|
||||||
if (found == object.end() || !found->value().is_string()) {
|
|
||||||
return std::unexpected(invalid(std::string{field} + " must be a string"));
|
|
||||||
}
|
|
||||||
auto value = std::string{found->value().as_string()};
|
|
||||||
if (value.empty()) {
|
|
||||||
return std::unexpected(invalid(std::string{field} + " must not be empty"));
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto optional_string_value(boost::json::object const& object, std::char_t const* field, std::string fallback = {}) -> std::expected<std::string, modulus::error> {
|
|
||||||
auto found = object.find(field);
|
|
||||||
if (found == object.end()) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
if (!found->value().is_string()) {
|
|
||||||
return std::unexpected(invalid(std::string{field} + " must be a string"));
|
|
||||||
}
|
|
||||||
return std::string{found->value().as_string()};
|
|
||||||
}
|
|
||||||
|
|
||||||
auto optional_bool_value(boost::json::object const& object, std::char_t const* field, std::bool_t fallback = false) -> std::expected<std::bool_t, modulus::error> {
|
|
||||||
auto found = object.find(field);
|
|
||||||
if (found == object.end()) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
if (!found->value().is_bool()) {
|
|
||||||
return std::unexpected(invalid(std::string{field} + " must be a boolean"));
|
|
||||||
}
|
|
||||||
return found->value().as_bool();
|
|
||||||
}
|
|
||||||
|
|
||||||
auto optional_int64_value(boost::json::object const& object, std::char_t const* field, std::int64_t fallback = 0) -> std::expected<std::int64_t, modulus::error> {
|
|
||||||
auto found = object.find(field);
|
|
||||||
if (found == object.end()) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
if (found->value().is_int64()) {
|
|
||||||
return found->value().as_int64();
|
|
||||||
}
|
|
||||||
if (found->value().is_uint64()) {
|
|
||||||
auto value = found->value().as_uint64();
|
|
||||||
if (value <= static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max())) {
|
|
||||||
return static_cast<std::int64_t>(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return std::unexpected(invalid(std::string{field} + " must be an integer"));
|
|
||||||
}
|
|
||||||
|
|
||||||
auto require_object(boost::json::value const& value, std::string_view label) -> std::expected<boost::json::object const*, modulus::error> {
|
|
||||||
if (!value.is_object()) {
|
|
||||||
return std::unexpected(invalid(std::string{label} + " must be an object"));
|
|
||||||
}
|
|
||||||
return &value.as_object();
|
|
||||||
}
|
|
||||||
|
|
||||||
auto require_array(boost::json::object const& object, std::char_t const* field) -> std::expected<boost::json::array const*, modulus::error> {
|
|
||||||
auto found = object.find(field);
|
|
||||||
if (found == object.end() || !found->value().is_array()) {
|
|
||||||
return std::unexpected(invalid(std::string{field} + " must be an array"));
|
|
||||||
}
|
|
||||||
return &found->value().as_array();
|
|
||||||
}
|
|
||||||
|
|
||||||
auto human_label(std::string value) -> std::string {
|
auto human_label(std::string value) -> std::string {
|
||||||
if (value.empty()) {
|
if (value.empty()) {
|
||||||
return "Score";
|
return "Score";
|
||||||
@@ -116,164 +51,6 @@ auto practice_type_label(std::string const& value) -> std::string {
|
|||||||
return human_label(value) + " Practice";
|
return human_label(value) + " Practice";
|
||||||
}
|
}
|
||||||
|
|
||||||
auto generated_source_id(std::string const& source_path, std::string const& title, std::string const& part) -> std::string {
|
|
||||||
if (!source_path.empty()) {
|
|
||||||
auto stem = std::filesystem::path{source_path}.stem().string();
|
|
||||||
if (!stem.empty()) {
|
|
||||||
return stem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
auto value = title + "-" + part;
|
|
||||||
for (auto& ch : value) {
|
|
||||||
if (ch >= 'A' && ch <= 'Z') {
|
|
||||||
ch = static_cast<std::char_t>(ch + ('a' - 'A'));
|
|
||||||
} else if (!((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9'))) {
|
|
||||||
ch = '-';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value.empty() ? "score" : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto parse_pitch(boost::json::value const& value) -> std::expected<vamp_zero::score_pitch, modulus::error> {
|
|
||||||
auto object = require_object(value, "pitch");
|
|
||||||
if (!object) {
|
|
||||||
return std::unexpected(object.error());
|
|
||||||
}
|
|
||||||
auto letter = optional_string_value(**object, "letter", "c");
|
|
||||||
auto octave = optional_int64_value(**object, "octave", 4);
|
|
||||||
auto accidental = optional_string_value(**object, "accidental", {});
|
|
||||||
if (!letter || !octave || !accidental) {
|
|
||||||
return std::unexpected(!letter ? letter.error() : (!octave ? octave.error() : accidental.error()));
|
|
||||||
}
|
|
||||||
return vamp_zero::score_pitch{
|
|
||||||
.letter = letter->empty() ? 'c' : (*letter)[0],
|
|
||||||
.octave = static_cast<std::int32_t>(*octave),
|
|
||||||
.accidental = std::move(*accidental),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
auto parse_note(boost::json::value const& value) -> std::expected<vamp_zero::score_note, modulus::error> {
|
|
||||||
auto object = require_object(value, "note");
|
|
||||||
if (!object) {
|
|
||||||
return std::unexpected(object.error());
|
|
||||||
}
|
|
||||||
auto id = optional_string_value(**object, "id", {});
|
|
||||||
auto kind = optional_string_value(**object, "kind", "note");
|
|
||||||
auto hidden = optional_bool_value(**object, "hidden", false);
|
|
||||||
auto duration = optional_int64_value(**object, "duration_ticks", 0);
|
|
||||||
auto display = std::expected<std::int64_t, modulus::error>{0};
|
|
||||||
if (duration) {
|
|
||||||
display = optional_int64_value(**object, "display_ticks", *duration);
|
|
||||||
}
|
|
||||||
auto notehead = optional_string_value(**object, "notehead", {});
|
|
||||||
auto chord = optional_string_value(**object, "chord_symbol", {});
|
|
||||||
auto lyric = optional_string_value(**object, "lyric", {});
|
|
||||||
auto tie_to_next = optional_bool_value(**object, "tie_to_next", false);
|
|
||||||
auto tie_from_previous = optional_bool_value(**object, "tie_from_previous", false);
|
|
||||||
auto tuplet_id = optional_string_value(**object, "tuplet_id", {});
|
|
||||||
if (!id || !kind || !hidden || !duration || !display || !notehead || !chord || !lyric || !tie_to_next || !tie_from_previous || !tuplet_id) {
|
|
||||||
return std::unexpected(!id ? id.error() : (!kind ? kind.error() : (!hidden ? hidden.error() : (!duration ? duration.error() : (!display ? display.error() : (!notehead ? notehead.error() : (!chord ? chord.error() : (!lyric ? lyric.error() : (!tie_to_next ? tie_to_next.error() : (!tie_from_previous ? tie_from_previous.error() : tuplet_id.error()))))))))));
|
|
||||||
}
|
|
||||||
auto note = vamp_zero::score_note{
|
|
||||||
.id = std::move(*id),
|
|
||||||
.kind = std::move(*kind),
|
|
||||||
.hidden = *hidden,
|
|
||||||
.duration_ticks = *duration,
|
|
||||||
.display_ticks = *display,
|
|
||||||
.notehead = std::move(*notehead),
|
|
||||||
.chord_symbol = std::move(*chord),
|
|
||||||
.lyric = std::move(*lyric),
|
|
||||||
.tie_to_next = *tie_to_next,
|
|
||||||
.tie_from_previous = *tie_from_previous,
|
|
||||||
.tuplet_id = std::move(*tuplet_id),
|
|
||||||
};
|
|
||||||
if (auto found = (*object)->find("pitches"); found != (*object)->end()) {
|
|
||||||
if (!found->value().is_array()) {
|
|
||||||
return std::unexpected(invalid("pitches must be an array"));
|
|
||||||
}
|
|
||||||
for (auto const& pitch_value : found->value().as_array()) {
|
|
||||||
auto pitch = parse_pitch(pitch_value);
|
|
||||||
if (!pitch) {
|
|
||||||
return std::unexpected(pitch.error());
|
|
||||||
}
|
|
||||||
note.pitches.push_back(std::move(*pitch));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return note;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto parse_tuplet(boost::json::value const& value) -> std::expected<vamp_zero::score_tuplet, modulus::error> {
|
|
||||||
auto object = require_object(value, "tuplet");
|
|
||||||
if (!object) {
|
|
||||||
return std::unexpected(object.error());
|
|
||||||
}
|
|
||||||
auto id = required_string(**object, "id");
|
|
||||||
auto note_count = optional_int64_value(**object, "note_count", 0);
|
|
||||||
auto space_count = optional_int64_value(**object, "space_count", 0);
|
|
||||||
if (!id || !note_count || !space_count) {
|
|
||||||
return std::unexpected(!id ? id.error() : (!note_count ? note_count.error() : space_count.error()));
|
|
||||||
}
|
|
||||||
return vamp_zero::score_tuplet{
|
|
||||||
.id = std::move(*id),
|
|
||||||
.note_count = static_cast<std::int32_t>(*note_count),
|
|
||||||
.space_count = static_cast<std::int32_t>(*space_count),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
auto parse_measure(boost::json::value const& value) -> std::expected<vamp_zero::score_measure, modulus::error> {
|
|
||||||
auto object = require_object(value, "measure");
|
|
||||||
if (!object) {
|
|
||||||
return std::unexpected(object.error());
|
|
||||||
}
|
|
||||||
auto id = optional_string_value(**object, "id", {});
|
|
||||||
auto section = optional_string_value(**object, "section_label", {});
|
|
||||||
auto time = optional_string_value(**object, "time_signature", {});
|
|
||||||
auto key = optional_string_value(**object, "key_signature", {});
|
|
||||||
if (!id || !section || !time || !key) {
|
|
||||||
return std::unexpected(!id ? id.error() : (!section ? section.error() : (!time ? time.error() : key.error())));
|
|
||||||
}
|
|
||||||
auto measure = vamp_zero::score_measure{
|
|
||||||
.id = std::move(*id),
|
|
||||||
.section_label = std::move(*section),
|
|
||||||
.time_signature = std::move(*time),
|
|
||||||
.key_signature = std::move(*key),
|
|
||||||
};
|
|
||||||
auto voices = require_array(**object, "voices");
|
|
||||||
if (!voices) {
|
|
||||||
return std::unexpected(voices.error());
|
|
||||||
}
|
|
||||||
for (auto const& voice_value : **voices) {
|
|
||||||
auto voice = require_object(voice_value, "voice");
|
|
||||||
if (!voice) {
|
|
||||||
return std::unexpected(voice.error());
|
|
||||||
}
|
|
||||||
auto notes = require_array(**voice, "notes");
|
|
||||||
if (!notes) {
|
|
||||||
return std::unexpected(notes.error());
|
|
||||||
}
|
|
||||||
for (auto const& note_value : **notes) {
|
|
||||||
auto note = parse_note(note_value);
|
|
||||||
if (!note) {
|
|
||||||
return std::unexpected(note.error());
|
|
||||||
}
|
|
||||||
measure.notes.push_back(std::move(*note));
|
|
||||||
}
|
|
||||||
if (auto tuplets_value = (*voice)->find("tuplets"); tuplets_value != (*voice)->end()) {
|
|
||||||
if (!tuplets_value->value().is_array()) {
|
|
||||||
return std::unexpected(invalid("tuplets must be an array"));
|
|
||||||
}
|
|
||||||
for (auto const& tuplet_value : tuplets_value->value().as_array()) {
|
|
||||||
auto tuplet = parse_tuplet(tuplet_value);
|
|
||||||
if (!tuplet) {
|
|
||||||
return std::unexpected(tuplet.error());
|
|
||||||
}
|
|
||||||
measure.tuplets.push_back(std::move(*tuplet));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return measure;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto load_score_documents(std::filesystem::path const& directory, std::bool_t required) -> std::expected<std::vector<vamp_zero::score_document>, modulus::error> {
|
auto load_score_documents(std::filesystem::path const& directory, std::bool_t required) -> std::expected<std::vector<vamp_zero::score_document>, modulus::error> {
|
||||||
auto error = std::error_code{};
|
auto error = std::error_code{};
|
||||||
if (!std::filesystem::exists(directory, error)) {
|
if (!std::filesystem::exists(directory, error)) {
|
||||||
@@ -334,7 +111,8 @@ auto key_rank(std::string const& key) -> std::size_t {
|
|||||||
"Bb",
|
"Bb",
|
||||||
"B",
|
"B",
|
||||||
};
|
};
|
||||||
auto found = std::find(keys.begin(), keys.end(), key);
|
auto tonic = key.substr(0, key.find(' '));
|
||||||
|
auto found = std::find(keys.begin(), keys.end(), tonic);
|
||||||
return found == keys.end() ? keys.size() : static_cast<std::size_t>(std::distance(keys.begin(), found));
|
return found == keys.end() ? keys.size() : static_cast<std::size_t>(std::distance(keys.begin(), found));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,90 +145,7 @@ auto same_practice_exercise(vamp_zero::practice_exercise const& left, vamp_zero:
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto vamp_zero::parse_score_document(std::string_view json, std::string source_path) -> std::expected<vamp_zero::score_document, modulus::error> {
|
auto vamp_zero::parse_score_document(std::string_view json, std::string source_path) -> std::expected<vamp_zero::score_document, modulus::error> {
|
||||||
auto error = boost::system::error_code{};
|
return vamp_zero::parse_notation_document(json, std::move(source_path));
|
||||||
auto parsed = boost::json::parse(json, error);
|
|
||||||
if (error) {
|
|
||||||
return std::unexpected(invalid("score JSON is invalid: " + error.message()));
|
|
||||||
}
|
|
||||||
auto root = require_object(parsed, "score root");
|
|
||||||
if (!root) {
|
|
||||||
return std::unexpected(root.error());
|
|
||||||
}
|
|
||||||
auto metadata_value = (*root)->find("metadata");
|
|
||||||
if (metadata_value == (*root)->end()) {
|
|
||||||
return std::unexpected(invalid("score metadata is required"));
|
|
||||||
}
|
|
||||||
auto metadata = require_object(metadata_value->value(), "metadata");
|
|
||||||
if (!metadata) {
|
|
||||||
return std::unexpected(metadata.error());
|
|
||||||
}
|
|
||||||
auto title = required_string(**metadata, "title");
|
|
||||||
auto composer = optional_string_value(**metadata, "composer", {});
|
|
||||||
auto key = optional_string_value(**metadata, "key_signature", {});
|
|
||||||
auto time = optional_string_value(**metadata, "time_signature", {});
|
|
||||||
if (!title || !composer || !key || !time) {
|
|
||||||
return std::unexpected(!title ? title.error() : (!composer ? composer.error() : (!key ? key.error() : time.error())));
|
|
||||||
}
|
|
||||||
|
|
||||||
auto staves = require_array(**root, "staves");
|
|
||||||
if (!staves) {
|
|
||||||
return std::unexpected(staves.error());
|
|
||||||
}
|
|
||||||
if ((*staves)->empty() || !(*staves)->front().is_object()) {
|
|
||||||
return std::unexpected(invalid("score must contain at least one staff"));
|
|
||||||
}
|
|
||||||
auto const& staff = (*staves)->front().as_object();
|
|
||||||
auto part = optional_string_value(staff, "arrangement_part_type", {});
|
|
||||||
if (!part || part->empty()) {
|
|
||||||
part = optional_string_value(staff, "role", {});
|
|
||||||
}
|
|
||||||
if (!part || part->empty()) {
|
|
||||||
part = optional_string_value(staff, "name", "score");
|
|
||||||
}
|
|
||||||
if (!part) {
|
|
||||||
return std::unexpected(part.error());
|
|
||||||
}
|
|
||||||
auto clef = optional_string_value(staff, "clef", "treble");
|
|
||||||
if (!clef) {
|
|
||||||
return std::unexpected(clef.error());
|
|
||||||
}
|
|
||||||
|
|
||||||
auto measures = require_array(staff, "measures");
|
|
||||||
if (!measures) {
|
|
||||||
return std::unexpected(measures.error());
|
|
||||||
}
|
|
||||||
auto document = vamp_zero::score_document{
|
|
||||||
.id = generated_source_id(source_path, *title, *part),
|
|
||||||
.title = std::move(*title),
|
|
||||||
.composer = std::move(*composer),
|
|
||||||
.key_signature = std::move(*key),
|
|
||||||
.time_signature = std::move(*time),
|
|
||||||
.clef = clef->empty() ? std::string{"treble"} : std::move(*clef),
|
|
||||||
.part = human_label(*part),
|
|
||||||
.arrangement_part_type = std::move(*part),
|
|
||||||
.source_path = std::move(source_path),
|
|
||||||
.source_json = std::string{json},
|
|
||||||
};
|
|
||||||
for (auto const& measure_value : **measures) {
|
|
||||||
auto measure = parse_measure(measure_value);
|
|
||||||
if (!measure) {
|
|
||||||
return std::unexpected(measure.error());
|
|
||||||
}
|
|
||||||
if (document.key_signature.empty() && !measure->key_signature.empty()) {
|
|
||||||
document.key_signature = measure->key_signature;
|
|
||||||
}
|
|
||||||
if (document.time_signature.empty() && !measure->time_signature.empty()) {
|
|
||||||
document.time_signature = measure->time_signature;
|
|
||||||
}
|
|
||||||
document.measures.push_back(std::move(*measure));
|
|
||||||
}
|
|
||||||
if (document.key_signature.empty()) {
|
|
||||||
document.key_signature = "C";
|
|
||||||
}
|
|
||||||
if (document.time_signature.empty()) {
|
|
||||||
document.time_signature = "4/4";
|
|
||||||
}
|
|
||||||
return document;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
auto vamp_zero::group_library_parts(std::vector<vamp_zero::library_part> parts) -> vamp_zero::library_state {
|
auto vamp_zero::group_library_parts(std::vector<vamp_zero::library_part> parts) -> vamp_zero::library_state {
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
#include <vamp_zero/notation.hpp>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <limits>
|
||||||
|
#include <map>
|
||||||
|
#include <numeric>
|
||||||
|
#include <set>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <boost/json.hpp>
|
||||||
|
#include <boost/rational.hpp>
|
||||||
|
#include <boost/multiprecision/cpp_int.hpp>
|
||||||
|
#include <vamp_zero/errors.hpp>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
using object = boost::json::object;
|
||||||
|
using value = boost::json::value;
|
||||||
|
using exact_time = boost::rational<boost::multiprecision::cpp_int>;
|
||||||
|
auto exact(vamp_zero::score_time t) -> exact_time { return {t.numerator, t.denominator}; }
|
||||||
|
constexpr auto types = std::array<std::string_view, 14>{"maxima", "long", "breve", "whole", "half", "quarter", "eighth", "16th", "32nd", "64th", "128th", "256th", "512th", "1024th"};
|
||||||
|
|
||||||
|
auto notation_error(std::string const& message) -> void { throw std::invalid_argument(message); }
|
||||||
|
auto text(object const& o, char const* key, std::string fallback = {}) -> std::string {
|
||||||
|
auto p = o.if_contains(key);
|
||||||
|
return p == nullptr ? fallback : std::string{p->as_string()};
|
||||||
|
}
|
||||||
|
auto integer(value const& v) -> std::int64_t { return boost::json::value_to<std::int64_t>(v); }
|
||||||
|
auto integer(object const& o, char const* key, std::int64_t fallback = 0) -> std::int64_t {
|
||||||
|
auto p = o.if_contains(key);
|
||||||
|
return p == nullptr ? fallback : integer(*p);
|
||||||
|
}
|
||||||
|
auto boolean(object const& o, char const* key) -> bool {
|
||||||
|
auto p = o.if_contains(key);
|
||||||
|
return p != nullptr && p->as_bool();
|
||||||
|
}
|
||||||
|
auto time(value const& v) -> vamp_zero::score_time {
|
||||||
|
auto const& o = v.as_object();
|
||||||
|
auto n = integer(o.at("numerator"));
|
||||||
|
auto d = integer(o.at("denominator"));
|
||||||
|
if (o.size() != 2 || n < 0 || d <= 0 || std::gcd(n, d) != 1) notation_error("invalid or unreduced musical time");
|
||||||
|
return {n, d};
|
||||||
|
}
|
||||||
|
auto written(value const& v) -> vamp_zero::score_written_duration {
|
||||||
|
auto const& o = v.as_object();
|
||||||
|
auto name = text(o, "type");
|
||||||
|
auto dots = integer(o, "dots");
|
||||||
|
if (std::find(types.begin(), types.end(), name) == types.end() || dots < 0 || dots > 16) notation_error("invalid written duration");
|
||||||
|
auto result = vamp_zero::score_written_duration{.type = name, .dots = static_cast<std::int32_t>(dots)};
|
||||||
|
if (auto ratio = o.if_contains("ratio")) {
|
||||||
|
result.ratio = time(*ratio);
|
||||||
|
if (result.ratio.numerator == 0) notation_error("duration ratio must be positive");
|
||||||
|
}
|
||||||
|
if (auto normal = o.if_contains("normal")) {
|
||||||
|
auto parsed = written(*normal);
|
||||||
|
result.normal_type = parsed.type;
|
||||||
|
result.normal_dots = parsed.dots;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
auto metric_length(vamp_zero::score_written_duration const& written) -> exact_time {
|
||||||
|
auto index = std::distance(types.begin(), std::find(types.begin(), types.end(), written.type));
|
||||||
|
auto dots = std::int64_t{1} << written.dots;
|
||||||
|
return exact_time{32, std::int64_t{1} << index} * exact_time{2*dots-1, dots} * exact(written.ratio);
|
||||||
|
}
|
||||||
|
auto clef(value const& v) -> std::string {
|
||||||
|
auto const& o = v.as_object();
|
||||||
|
auto sign = text(o, "sign");
|
||||||
|
auto line = integer(o, "line");
|
||||||
|
auto octave = integer(o, "octave_change");
|
||||||
|
if ((sign != "G" && sign != "F" && sign != "C") || line < 1 || line > 5 || octave < -3 || octave > 3) notation_error("invalid pitched clef");
|
||||||
|
return sign + ":" + std::to_string(line) + ":" + std::to_string(octave);
|
||||||
|
}
|
||||||
|
auto alterations(value const& v) -> std::array<std::int32_t, 7> {
|
||||||
|
auto const& values = v.as_object().at("alterations").as_array();
|
||||||
|
if (values.size() != 7) notation_error("key must have seven diatonic alterations");
|
||||||
|
auto result = std::array<std::int32_t, 7>{};
|
||||||
|
for (auto i = std::size_t{}; i < 7; ++i) {
|
||||||
|
auto a = integer(values[i]);
|
||||||
|
if (a < -3 || a > 3) notation_error("key alteration outside supported engraving range");
|
||||||
|
result[i] = static_cast<std::int32_t>(a);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
auto pitch(value const& v) -> vamp_zero::score_pitch {
|
||||||
|
auto const& o = v.as_object();
|
||||||
|
auto letter = text(o, "letter");
|
||||||
|
auto alter = integer(o.at("alter"));
|
||||||
|
auto octave = integer(o.at("octave"));
|
||||||
|
if (letter.size() != 1 || std::string_view{"cdefgab"}.find(letter[0]) == std::string_view::npos || alter < -3 || alter > 3 || octave < -4 || octave > 16) notation_error("invalid spelled pitch");
|
||||||
|
auto result = vamp_zero::score_pitch{.letter = letter[0], .octave = static_cast<std::int32_t>(octave), .accidental = alter == 0 ? "n" : std::string(static_cast<std::size_t>(std::abs(alter)), alter > 0 ? '#' : 'b'), .id = text(o, "id"), .alteration = static_cast<std::int32_t>(alter), .duration = time(o.at("duration")), .written = written(o.at("written")), .tie_to = text(o, "tie_to"), .tie_from = text(o, "tie_from")};
|
||||||
|
if (auto a = o.if_contains("accidental_display")) {
|
||||||
|
result.explicit_accidental = true;
|
||||||
|
result.accidental_parentheses = boolean(a->as_object(), "parentheses");
|
||||||
|
result.accidental_bracket = boolean(a->as_object(), "bracket");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
auto note(value const& v) -> vamp_zero::score_note {
|
||||||
|
auto const& o = v.as_object();
|
||||||
|
auto result = vamp_zero::score_note{};
|
||||||
|
result.id = text(o, "id");
|
||||||
|
result.kind = text(o, "kind");
|
||||||
|
if (result.kind != "note" && result.kind != "rest" && result.kind != "spacer") notation_error("invalid event kind");
|
||||||
|
result.hidden = boolean(o, "hidden") || result.kind == "spacer";
|
||||||
|
result.onset = time(o.at("onset"));
|
||||||
|
result.duration = time(o.at("duration"));
|
||||||
|
result.written = written(o.at("written"));
|
||||||
|
// Layout coordinates are floating point; the parsed rational remains authoritative.
|
||||||
|
result.duration_ticks = result.duration.quarters() * 4096.0;
|
||||||
|
result.display_ticks = vamp_zero::written_quarters(result.written) * 4096.0;
|
||||||
|
result.lyric = text(o, "lyric");
|
||||||
|
result.notehead = text(o, "notehead");
|
||||||
|
result.stem = text(o, "stem");
|
||||||
|
result.cue = boolean(o, "cue");
|
||||||
|
result.measure_rest = boolean(o, "measure_rest");
|
||||||
|
if (auto position = o.if_contains("rest_position")) {
|
||||||
|
auto const& p = position->as_object();
|
||||||
|
auto letter = text(p,"letter");
|
||||||
|
auto octave = integer(p,"octave");
|
||||||
|
if (letter.size()!=1 || std::string_view{"cdefgab"}.find(letter[0])==std::string_view::npos || octave < -4 || octave > 16) notation_error("invalid rest position");
|
||||||
|
result.rest_position = vamp_zero::score_pitch{.letter=letter[0], .octave=static_cast<std::int32_t>(octave)};
|
||||||
|
}
|
||||||
|
if (auto grace = o.if_contains("grace")) {
|
||||||
|
result.grace = true;
|
||||||
|
result.grace_slash = boolean(grace->as_object(), "slash");
|
||||||
|
}
|
||||||
|
if (result.grace != (result.duration.numerator == 0)) notation_error("only grace notes may have zero metric duration");
|
||||||
|
if (!result.grace && !result.measure_rest && exact(result.duration) != metric_length(result.written)) notation_error("metric and written event durations disagree");
|
||||||
|
for (auto const& p : o.at("pitches").as_array()) {
|
||||||
|
result.pitches.push_back(pitch(p));
|
||||||
|
if (!result.grace && result.pitches.back().duration.numerator == 0) notation_error("metric chord tones require positive duration");
|
||||||
|
if (!result.grace && exact(result.pitches.back().duration) != metric_length(result.pitches.back().written)) notation_error("metric and written chord-tone durations disagree");
|
||||||
|
if (exact(result.pitches.back().duration) > exact(result.duration)) notation_error("chord tone exceeds its anchor duration");
|
||||||
|
}
|
||||||
|
if (result.kind != "note" && !result.pitches.empty()) notation_error("rests/spacers cannot contain pitches");
|
||||||
|
if (result.kind == "note" && result.pitches.empty() && result.notehead != "slash") notation_error("pitched event has no pitches");
|
||||||
|
result.tie_to_next = std::any_of(result.pitches.begin(), result.pitches.end(), [](auto const& p) { return !p.tie_to.empty(); });
|
||||||
|
result.tie_from_previous = std::all_of(result.pitches.begin(), result.pitches.end(), [](auto const& p) { return !p.tie_from.empty(); });
|
||||||
|
if (auto tuplets = o.if_contains("tuplet_ids")) for (auto const& id : tuplets->as_array()) result.tuplet_ids.emplace_back(id.as_string());
|
||||||
|
if (!result.tuplet_ids.empty()) result.tuplet_id = result.tuplet_ids.back();
|
||||||
|
if (auto beams = o.if_contains("beams")) for (auto const& b : beams->as_array()) {
|
||||||
|
auto const& beam = b.as_object();
|
||||||
|
auto level = integer(beam, "level");
|
||||||
|
auto type = text(beam, "type");
|
||||||
|
if (level < 1 || level > 8 || (type != "begin" && type != "continue" && type != "end" && type != "forward hook" && type != "backward hook")) notation_error("invalid beam");
|
||||||
|
result.beams.push_back({static_cast<std::int32_t>(level), type});
|
||||||
|
}
|
||||||
|
if (auto tremolo = o.if_contains("tremolo")) {
|
||||||
|
auto marks = integer(tremolo->as_object(), "marks");
|
||||||
|
if (marks < 0 || marks > 8) notation_error("invalid measured tremolo");
|
||||||
|
result.tremolo_marks = static_cast<std::int32_t>(marks);
|
||||||
|
result.tremolo_type = text(tremolo->as_object(), "type");
|
||||||
|
if (result.tremolo_type != "single" && result.tremolo_type != "start" && result.tremolo_type != "stop") notation_error("invalid tremolo type");
|
||||||
|
result.tremolo_partner = text(tremolo->as_object(), "partner");
|
||||||
|
}
|
||||||
|
if (auto lyric = o.if_contains("lyric_notation")) result.lyric_extend = text(lyric->as_object(), "extend");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
auto beats(value const& v) -> std::vector<vamp_zero::score_time> {
|
||||||
|
auto result = std::vector<vamp_zero::score_time>{};
|
||||||
|
if (v.as_object().at("groups").as_array().empty() && !boolean(v.as_object(), "unmetered")) notation_error("meter requires beat groups or senza misura");
|
||||||
|
for (auto const& group : v.as_object().at("groups").as_array()) {
|
||||||
|
auto unit = integer(group.as_object().at("unit"));
|
||||||
|
auto const& counts = group.as_object().at("beats").as_array();
|
||||||
|
if (unit <= 0 || unit > 4096 || counts.empty()) notation_error("invalid meter group");
|
||||||
|
for (auto const& count : counts) {
|
||||||
|
auto n = integer(count);
|
||||||
|
if (n <= 0 || n > 1024) notation_error("invalid meter count");
|
||||||
|
auto compound = counts.size() == 1 && unit >= 8 && n > 3 && n % 3 == 0;
|
||||||
|
auto repetitions = counts.size() > 1 ? 1 : compound ? n / 3 : n;
|
||||||
|
auto numerator = counts.size() > 1 ? n * 4 : compound ? 12 : 4;
|
||||||
|
auto divisor = std::gcd(numerator, unit);
|
||||||
|
for (auto i = std::int64_t{}; i < repetitions; ++i) result.push_back({numerator / divisor, unit / divisor});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto meter_text(value const& v) -> std::string {
|
||||||
|
auto const& meter = v.as_object();
|
||||||
|
auto symbol = text(meter,"symbol","normal");
|
||||||
|
if (symbol == "common" || symbol == "cut") return symbol;
|
||||||
|
if (boolean(meter,"unmetered")) return "senza misura";
|
||||||
|
auto result = std::string{};
|
||||||
|
for (auto const& group : meter.at("groups").as_array()) {
|
||||||
|
if (!result.empty()) result += '+';
|
||||||
|
auto numerator = std::string{};
|
||||||
|
for (auto const& count : group.as_object().at("beats").as_array()) {
|
||||||
|
if (!numerator.empty()) numerator += '+';
|
||||||
|
numerator += std::to_string(integer(count));
|
||||||
|
}
|
||||||
|
result += numerator + '/' + std::to_string(integer(group.as_object().at("unit")));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
auto measure(value const& v) -> vamp_zero::score_measure {
|
||||||
|
auto const& o = v.as_object();
|
||||||
|
auto result = vamp_zero::score_measure{.id = text(o, "id"), .section_label = text(o, "section_label"), .time_signature = text(o, "time_signature"), .key_signature = text(o, "key_signature")};
|
||||||
|
result.duration = time(o.at("duration"));
|
||||||
|
if (result.duration.numerator == 0) notation_error("measure has zero duration");
|
||||||
|
result.clef = clef(o.at("clef"));
|
||||||
|
result.key_alterations = alterations(o.at("key"));
|
||||||
|
result.structured_key = true;
|
||||||
|
result.beats = beats(o.at("meter"));
|
||||||
|
result.time_signature = meter_text(o.at("meter"));
|
||||||
|
result.implicit = boolean(o, "implicit");
|
||||||
|
auto const& voices = o.at("voices").as_array();
|
||||||
|
if (voices.size() != 1) notation_error("exactly one voice is supported; voices cannot be flattened");
|
||||||
|
auto cursor = exact_time{};
|
||||||
|
for (auto const& item : voices.front().as_object().at("notes").as_array()) {
|
||||||
|
auto n = note(item);
|
||||||
|
if (exact(n.onset) + exact(n.duration) > exact(result.duration)) notation_error("event extends beyond measure");
|
||||||
|
if (!n.grace) {
|
||||||
|
if (exact(n.onset) < cursor) notation_error("events overlap within one rhythmic voice");
|
||||||
|
cursor = exact(n.onset) + exact(n.duration);
|
||||||
|
}
|
||||||
|
n.clef = result.clef;
|
||||||
|
result.notes.push_back(std::move(n));
|
||||||
|
}
|
||||||
|
if (auto harmonies = o.if_contains("harmonies")) for (auto const& item : harmonies->as_array()) {
|
||||||
|
auto const& h = item.as_object();
|
||||||
|
auto onset = time(h.at("onset"));
|
||||||
|
if (exact(onset) >= exact(result.duration)) notation_error("harmony outside measure");
|
||||||
|
result.harmonies.push_back({text(h,"id"), onset, text(h,"symbol"), text(h,"kind")});
|
||||||
|
}
|
||||||
|
if (auto attrs = o.if_contains("attributes")) for (auto const& item : attrs->as_array()) {
|
||||||
|
auto const& a = item.as_object();
|
||||||
|
auto change = vamp_zero::score_attribute{.onset = time(a.at("onset"))};
|
||||||
|
if (auto c = a.if_contains("clef")) change.clef = clef(*c);
|
||||||
|
if (auto k = a.if_contains("key")) change.key_alterations = alterations(*k);
|
||||||
|
if (auto m = a.if_contains("meter")) { change.time_signature = meter_text(*m); change.beats = beats(*m); }
|
||||||
|
if (exact(change.onset) >= exact(result.duration)) notation_error("attribute change outside measure");
|
||||||
|
result.attributes.push_back(std::move(change));
|
||||||
|
}
|
||||||
|
if (auto bars = o.if_contains("barlines")) result.right_barline = text(bars->as_object(), "right", "regular");
|
||||||
|
for (auto& n : result.notes) {
|
||||||
|
for (auto const& a : result.attributes)
|
||||||
|
if (exact(a.onset) <= exact(n.onset) && !a.clef.empty()) n.clef = a.clef;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto vamp_zero::written_quarters(score_written_duration const& written) -> double {
|
||||||
|
auto found = std::find(types.begin(), types.end(), written.type);
|
||||||
|
if (found == types.end()) return 0.0;
|
||||||
|
return std::ldexp(32.0, -static_cast<int>(std::distance(types.begin(), found))) * (2.0 - std::ldexp(1.0, -written.dots));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto vamp_zero::parse_notation_document(std::string_view json, std::string source_path) -> std::expected<score_document, modulus::error> {
|
||||||
|
try {
|
||||||
|
auto parsed = boost::json::parse(json);
|
||||||
|
auto const& root = parsed.as_object();
|
||||||
|
if (integer(root, "schema_version") != 2) notation_error("unsupported score schema; regenerate assets with the current Product Vamp generator");
|
||||||
|
auto const& meta = root.at("metadata").as_object();
|
||||||
|
auto const& staves = root.at("staves").as_array();
|
||||||
|
if (staves.size() != 1) notation_error("exactly one staff is currently supported");
|
||||||
|
auto const& staff = staves.front().as_object();
|
||||||
|
auto result = score_document{};
|
||||||
|
result.title = text(meta, "title");
|
||||||
|
if (result.title.empty()) notation_error("score title is required");
|
||||||
|
result.composer = text(meta, "composer");
|
||||||
|
result.key_signature = text(meta, "key_signature");
|
||||||
|
result.time_signature = text(meta, "time_signature");
|
||||||
|
result.clef = clef(staff.at("clef"));
|
||||||
|
result.arrangement_part_type = text(staff, "arrangement_part_type", "practice");
|
||||||
|
result.part = text(staff, "name");
|
||||||
|
result.source_path = source_path;
|
||||||
|
result.source_json = std::string{json};
|
||||||
|
result.id = std::filesystem::path(result.source_path).stem().string();
|
||||||
|
if (result.id.empty()) result.id = result.title + "-" + result.part;
|
||||||
|
for (auto const& item : staff.at("measures").as_array()) result.measures.push_back(measure(item));
|
||||||
|
if (result.measures.empty()) notation_error("score has no measures");
|
||||||
|
auto ids = std::set<std::string>{};
|
||||||
|
auto events = std::map<std::string, score_note const*>{};
|
||||||
|
auto event_order = std::map<std::string, std::size_t>{};
|
||||||
|
struct tone_ref { score_pitch const* pitch; exact_time start; };
|
||||||
|
auto tones = std::map<std::string, tone_ref>{};
|
||||||
|
auto absolute = exact_time{};
|
||||||
|
for (auto const& m : result.measures) {
|
||||||
|
for (auto const& n : m.notes) {
|
||||||
|
if (n.id.empty() || !ids.insert(n.id).second) notation_error("duplicate or empty event ID");
|
||||||
|
event_order.emplace(n.id, events.size());
|
||||||
|
events.emplace(n.id, &n);
|
||||||
|
for (auto const& p : n.pitches) {
|
||||||
|
if (p.id.empty() || !tones.emplace(p.id, tone_ref{&p, absolute+exact(n.onset)}).second) notation_error("duplicate or empty pitch ID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
absolute += exact(m.duration);
|
||||||
|
}
|
||||||
|
constexpr auto natural = std::array<int,7>{0,2,4,5,7,9,11};
|
||||||
|
auto midi = [&](score_pitch const& p) { return 12*(p.octave+1)+natural[std::string_view{"cdefgab"}.find(p.letter)]+p.alteration.value_or(0); };
|
||||||
|
for (auto const& [id, ref] : tones) {
|
||||||
|
auto const& p = *ref.pitch;
|
||||||
|
if (!p.tie_to.empty()) {
|
||||||
|
auto next = tones.find(p.tie_to);
|
||||||
|
if (next == tones.end() || next->second.pitch->tie_from != id || midi(p) != midi(*next->second.pitch) || ref.start+exact(p.duration) != next->second.start) notation_error("invalid or dangling pitch tie");
|
||||||
|
}
|
||||||
|
if (!p.tie_from.empty()) {
|
||||||
|
auto prev = tones.find(p.tie_from);
|
||||||
|
if (prev == tones.end() || prev->second.pitch->tie_to != id) notation_error("dangling incoming pitch tie");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto span_ids = std::set<std::string>{};
|
||||||
|
if (auto spans = staff.if_contains("tuplets")) for (auto const& item : spans->as_array()) {
|
||||||
|
auto const& t = item.as_object();
|
||||||
|
if (integer(t,"note_count") <= 0 || integer(t,"note_count") > 1000000 || integer(t,"space_count") <= 0 || integer(t,"space_count") > 1000000 || integer(t,"depth") < 0 || integer(t,"depth") > 16) notation_error("invalid tuplet ratio or nesting depth");
|
||||||
|
auto span = score_tuplet{.id = text(t,"id"), .note_count = static_cast<std::int32_t>(integer(t,"note_count")), .space_count = static_cast<std::int32_t>(integer(t,"space_count")), .start = text(t,"start"), .end = text(t,"end"), .depth = static_cast<std::int32_t>(integer(t,"depth")), .bracket = text(t,"bracket","auto"), .show_number = text(t,"show_number","actual")};
|
||||||
|
if (span.id.empty() || !span_ids.insert(span.id).second || !ids.contains(span.start) || !ids.contains(span.end) || event_order.at(span.start) > event_order.at(span.end)) notation_error("invalid tuplet span");
|
||||||
|
if ((span.bracket != "auto" && span.bracket != "yes" && span.bracket != "no") || (span.show_number != "none" && span.show_number != "actual" && span.show_number != "both")) notation_error("invalid tuplet appearance");
|
||||||
|
for (auto const& [id, n] : events) {
|
||||||
|
auto member = std::find(n->tuplet_ids.begin(), n->tuplet_ids.end(), span.id) != n->tuplet_ids.end();
|
||||||
|
if ((id == span.start || id == span.end) && !member) notation_error("tuplet endpoint is not a member");
|
||||||
|
if (member && (event_order.at(id) < event_order.at(span.start) || event_order.at(id) > event_order.at(span.end))) notation_error("tuplet member outside span");
|
||||||
|
}
|
||||||
|
for (auto& m : result.measures) if (std::any_of(m.notes.begin(), m.notes.end(), [&](auto const& n) { return std::find(n.tuplet_ids.begin(), n.tuplet_ids.end(), span.id) != n.tuplet_ids.end(); })) m.tuplets.push_back(span);
|
||||||
|
}
|
||||||
|
for (auto const& [id, n] : events) {
|
||||||
|
for (auto const& span_id : n->tuplet_ids) if (!span_ids.contains(span_id)) notation_error("event refers to an unknown tuplet");
|
||||||
|
if (n->tremolo_type == "start" || n->tremolo_type == "stop") {
|
||||||
|
auto other = events.find(n->tremolo_partner);
|
||||||
|
auto expected = n->tremolo_type == "start" ? "stop" : "start";
|
||||||
|
if (other == events.end() || other->second->tremolo_partner != id || other->second->tremolo_type != expected || other->second->tremolo_marks != n->tremolo_marks) notation_error("invalid paired tremolo");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (auto spans = staff.if_contains("octave_shifts")) for (auto const& item : spans->as_array()) {
|
||||||
|
auto const& span = item.as_object();
|
||||||
|
if (integer(span,"octaves") < -3 || integer(span,"octaves") > 3 || exact(time(span.at("end"))) <= exact(time(span.at("start"))) || exact(time(span.at("end"))) > absolute) notation_error("invalid octave-shift span");
|
||||||
|
result.octave_shifts.push_back({time(span.at("start")), time(span.at("end")), static_cast<std::int32_t>(integer(span,"octaves"))});
|
||||||
|
}
|
||||||
|
auto start = exact_time{};
|
||||||
|
for (auto& m : result.measures) {
|
||||||
|
for (auto& n : m.notes) {
|
||||||
|
for (auto const& span : result.octave_shifts) {
|
||||||
|
if (exact(span.end) <= exact(span.start) || exact(span.end) > absolute || span.octaves < -3 || span.octaves > 3) notation_error("invalid octave-shift span");
|
||||||
|
if (start+exact(n.onset) >= exact(span.start) && start+exact(n.onset) < exact(span.end)) n.display_octaves += span.octaves;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start += exact(m.duration);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (std::exception const& error) {
|
||||||
|
return std::unexpected(make_error(error_code::INVALID_ARGUMENT, (source_path.empty() ? std::string{} : source_path + ": ") + "score notation: " + error.what()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+519
-197
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -60,6 +60,19 @@ struct vamp_zero::score_draw_cache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::unordered_map<std::string, entry> entries{};
|
std::unordered_map<std::string, entry> entries{};
|
||||||
|
std::shared_ptr<vamp_zero::score_document const> page_document{};
|
||||||
|
std::size_t page_index{};
|
||||||
|
std::optional<vamp_zero::score_page_layout> page{};
|
||||||
|
|
||||||
|
auto draw(luma::draw_context& context, luma::rect bounds, std::shared_ptr<vamp_zero::score_document const> document, std::size_t index) -> void {
|
||||||
|
if (!page || page_document != document || index != page_index
|
||||||
|
|| page->bounds.x != bounds.x || page->bounds.y != bounds.y || page->bounds.width != bounds.width || page->bounds.height != bounds.height) {
|
||||||
|
page = vamp_zero::layout_score_page(bounds, *document, index);
|
||||||
|
page_document = std::move(document);
|
||||||
|
page_index = index;
|
||||||
|
}
|
||||||
|
vamp_zero::draw_score_layout(context, *page);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -353,7 +366,7 @@ auto draw_score(luma::draw_context& context, luma::rect bounds, vamp_zero::app_s
|
|||||||
.width = bounds.width - 48.0F,
|
.width = bounds.width - 48.0F,
|
||||||
.height = bounds.height - 36.0F,
|
.height = bounds.height - 36.0F,
|
||||||
};
|
};
|
||||||
vamp_zero::draw_score_page(context, paper_bounds, **parsed, state.page_index);
|
score_cache.draw(context, paper_bounds, *parsed, state.page_index);
|
||||||
}
|
}
|
||||||
|
|
||||||
class vamp_root_control final : public luma::control {
|
class vamp_root_control final : public luma::control {
|
||||||
|
|||||||
@@ -11,15 +11,19 @@ target_link_libraries(product-vamp-test PRIVATE
|
|||||||
target_compile_features(product-vamp-test PRIVATE cxx_std_23)
|
target_compile_features(product-vamp-test PRIVATE cxx_std_23)
|
||||||
target_compile_definitions(product-vamp-test PRIVATE
|
target_compile_definitions(product-vamp-test PRIVATE
|
||||||
VAMP_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
|
VAMP_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
|
||||||
|
VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated-assets"
|
||||||
)
|
)
|
||||||
add_test(NAME product-vamp COMMAND product-vamp-test)
|
add_test(NAME product-vamp COMMAND product-vamp-test)
|
||||||
|
add_executable(vamp-render-score EXCLUDE_FROM_ALL render_score.cpp)
|
||||||
|
target_link_libraries(vamp-render-score PRIVATE vamp-zero-core)
|
||||||
|
target_compile_features(vamp-render-score PRIVATE cxx_std_23)
|
||||||
set_tests_properties(product-vamp PROPERTIES TIMEOUT 60)
|
set_tests_properties(product-vamp PROPERTIES TIMEOUT 60)
|
||||||
add_test(
|
add_test(
|
||||||
NAME product-vamp-generator
|
NAME product-vamp-generator
|
||||||
COMMAND ${Python3_EXECUTABLE} -m unittest tests.test_score_generator
|
COMMAND ${Python3_EXECUTABLE} -m unittest discover -s tests -p test_*.py
|
||||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||||
)
|
)
|
||||||
set_tests_properties(product-vamp-generator PROPERTIES TIMEOUT 60)
|
set_tests_properties(product-vamp-generator PROPERTIES TIMEOUT 180)
|
||||||
add_custom_target(check
|
add_custom_target(check
|
||||||
COMMAND ${CMAKE_CTEST_COMMAND}
|
COMMAND ${CMAKE_CTEST_COMMAND}
|
||||||
--test-dir ${CMAKE_BINARY_DIR}
|
--test-dir ${CMAKE_BINARY_DIR}
|
||||||
@@ -28,3 +32,4 @@ add_custom_target(check
|
|||||||
--timeout 60
|
--timeout 60
|
||||||
USES_TERMINAL
|
USES_TERMINAL
|
||||||
)
|
)
|
||||||
|
add_dependencies(check product-vamp-test)
|
||||||
|
|||||||
Vendored
+41
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
|
||||||
|
<score-partwise version="4.0">
|
||||||
|
<work><work-title>Pitch and Rhythm Regression</work-title></work>
|
||||||
|
<part-list><score-part id="P1"><part-name>Notation</part-name></score-part></part-list>
|
||||||
|
<part id="P1">
|
||||||
|
<measure number="1">
|
||||||
|
<attributes><divisions>16</divisions><key><fifths>0</fifths></key><time><beats>4</beats><beat-type>4</beat-type></time><clef><sign>G</sign><line>2</line></clef></attributes>
|
||||||
|
<harmony><root><root-step>C</root-step></root><kind>diminished</kind></harmony>
|
||||||
|
<note><grace slash="yes"/><pitch><step>G</step><octave>5</octave></pitch><type>16th</type></note>
|
||||||
|
<note><pitch><step>C</step><alter>1</alter><octave>5</octave></pitch><duration>28</duration><type>quarter</type><dot/><dot/><accidental cautionary="yes">sharp</accidental></note>
|
||||||
|
<note><chord/><pitch><step>E</step><octave>5</octave></pitch><duration>8</duration><type>eighth</type></note>
|
||||||
|
<note><rest/><duration>4</duration><type>16th</type></note>
|
||||||
|
<note><pitch><step>B</step><octave>4</octave></pitch><duration>32</duration><tie type="start"/><type>half</type><notations><tied type="start"/></notations><lyric><syllabic>single</syllabic><text>held</text><extend type="start"/></lyric></note>
|
||||||
|
</measure>
|
||||||
|
<measure number="2">
|
||||||
|
<note><pitch><step>B</step><octave>4</octave></pitch><duration>16</duration><tie type="stop"/><type>quarter</type><notations><tied type="stop"/></notations></note>
|
||||||
|
<note><cue/><pitch><step>D</step><octave>5</octave></pitch><duration>16</duration><type>quarter</type></note>
|
||||||
|
<attributes><key><fifths>-1</fifths><mode>minor</mode></key><clef><sign>C</sign><line>3</line></clef></attributes>
|
||||||
|
<note><pitch><step>A</step><octave>3</octave></pitch><duration>32</duration><type>half</type><notations><ornaments><tremolo type="single">2</tremolo></ornaments></notations></note>
|
||||||
|
</measure>
|
||||||
|
<measure number="3">
|
||||||
|
<attributes><divisions>24</divisions><time><beats>6</beats><beat-type>8</beat-type></time><clef><sign>G</sign><line>2</line></clef></attributes>
|
||||||
|
<direction><direction-type><octave-shift type="down" size="8"/></direction-type></direction>
|
||||||
|
<note><pitch><step>G</step><octave>5</octave></pitch><duration>12</duration><type>eighth</type><beam number="1">begin</beam></note>
|
||||||
|
<note><pitch><step>A</step><octave>5</octave></pitch><duration>12</duration><type>eighth</type><beam number="1">continue</beam></note>
|
||||||
|
<note><pitch><step>B</step><alter>-1</alter><octave>5</octave></pitch><duration>12</duration><type>eighth</type><beam number="1">end</beam></note>
|
||||||
|
<note><pitch><step>C</step><octave>6</octave></pitch><duration>12</duration><type>eighth</type><beam number="1">begin</beam></note>
|
||||||
|
<note><pitch><step>D</step><octave>6</octave></pitch><duration>12</duration><type>eighth</type><beam number="1">continue</beam></note>
|
||||||
|
<note><pitch><step>E</step><octave>6</octave></pitch><duration>12</duration><type>eighth</type><beam number="1">end</beam></note>
|
||||||
|
<direction><direction-type><octave-shift type="stop" size="8"/></direction-type></direction>
|
||||||
|
</measure>
|
||||||
|
<measure number="4">
|
||||||
|
<attributes><divisions>3</divisions><time><beats>2</beats><beat-type>4</beat-type></time></attributes>
|
||||||
|
<note><pitch><step>C</step><octave>5</octave></pitch><duration>2</duration><type>quarter</type><time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification><notations><tuplet type="start" bracket="yes"/></notations></note>
|
||||||
|
<note><pitch><step>D</step><octave>5</octave></pitch><duration>2</duration><type>quarter</type><time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification></note>
|
||||||
|
<note><pitch><step>E</step><octave>5</octave></pitch><duration>2</duration><type>quarter</type><time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification><notations><tuplet type="stop"/></notations></note>
|
||||||
|
<barline location="right"><bar-style>light-heavy</bar-style></barline>
|
||||||
|
</measure>
|
||||||
|
</part>
|
||||||
|
</score-partwise>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#include <cstdlib>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iostream>
|
||||||
|
#include <iterator>
|
||||||
|
#include <memory>
|
||||||
|
#include <raylib.h>
|
||||||
|
#include <rlgl.h>
|
||||||
|
#include <luma.hpp>
|
||||||
|
#include <vamp_zero/library.hpp>
|
||||||
|
#include <vamp_zero/score.hpp>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
class score_view final : public luma::control {
|
||||||
|
public:
|
||||||
|
score_view(luma::application& app, vamp_zero::score_document score, std::string output, std::size_t page, bool& saved)
|
||||||
|
: app_{app}, score_{std::move(score)}, output_{std::move(output)}, page_{page}, saved_{saved} {}
|
||||||
|
auto measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size override { return {976,484}; }
|
||||||
|
auto draw(luma::draw_context& context, luma::theme const&) const -> void override {
|
||||||
|
vamp_zero::draw_score_page(context, bounds(), score_, page_);
|
||||||
|
rlDrawRenderBatchActive();
|
||||||
|
auto image = LoadImageFromScreen();
|
||||||
|
saved_ = ExportImage(image, output_.c_str());
|
||||||
|
UnloadImage(image);
|
||||||
|
app_.request_stop();
|
||||||
|
}
|
||||||
|
private:
|
||||||
|
luma::application& app_;
|
||||||
|
vamp_zero::score_document score_;
|
||||||
|
std::string output_;
|
||||||
|
std::size_t page_;
|
||||||
|
bool& saved_;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
if (argc < 3) { std::cerr << "Usage: vamp-render-score input.json output.png [page]\n"; return 2; }
|
||||||
|
auto input = std::ifstream{argv[1]};
|
||||||
|
auto json = std::string{std::istreambuf_iterator<char>{input},std::istreambuf_iterator<char>{}};
|
||||||
|
auto score = vamp_zero::parse_score_document(json,argv[1]);
|
||||||
|
if (!score) { std::cerr << score.error().describe() << '\n'; return 1; }
|
||||||
|
auto app = luma::application{{.title="Notation Regression",.display={.width=976,.height=484,.target_fps=30,.fullscreen=false}}};
|
||||||
|
auto saved = false;
|
||||||
|
auto root = app.set_root(std::make_unique<score_view>(app,std::move(*score),argv[2],argc>3 ? std::stoul(argv[3]) : 0,saved));
|
||||||
|
if (!root) return 1;
|
||||||
|
auto ran = app.run();
|
||||||
|
return ran && saved ? 0 : 1;
|
||||||
|
}
|
||||||
+199
-104
@@ -24,6 +24,8 @@
|
|||||||
#include <boost/asio/use_future.hpp>
|
#include <boost/asio/use_future.hpp>
|
||||||
#include <boost/json/object.hpp>
|
#include <boost/json/object.hpp>
|
||||||
#include <boost/json/parse.hpp>
|
#include <boost/json/parse.hpp>
|
||||||
|
#include <boost/json/serialize.hpp>
|
||||||
|
#include <boost/json/array.hpp>
|
||||||
|
|
||||||
#include <noto.hpp>
|
#include <noto.hpp>
|
||||||
|
|
||||||
@@ -275,45 +277,24 @@ auto has_value(noto::query_result const& result, std::size_t column, std::string
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto score_asset_json(std::string title, std::string key = "C") -> std::string {
|
auto score_asset_json(std::string title, std::string key = "C") -> std::string {
|
||||||
return std::string{R"json({
|
auto value = boost::json::parse(R"json({
|
||||||
"metadata": {
|
"schema_version":2,
|
||||||
"title": ")json"} + title + R"json(",
|
"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"},
|
||||||
"composer": "Composer",
|
"staves":[{"id":"staff-lead","name":"Lead","role":"lead","clef":{"sign":"G","line":2,"octave_change":0},
|
||||||
"key_signature": ")json" + key + R"json(",
|
"measures":[{"id":"measure-1","duration":{"numerator":4,"denominator":1},"key_signature":"C","time_signature":"4/4",
|
||||||
"time_signature": "4/4"
|
"key":{"alterations":[0,0,0,0,0,0,0]},"meter":{"groups":[{"beats":[4],"unit":4}]},
|
||||||
},
|
"clef":{"sign":"G","line":2,"octave_change":0},
|
||||||
"staves": [
|
"voices":[{"id":"voice-1","notes":[{"id":"note-1","kind":"note",
|
||||||
{
|
"onset":{"numerator":0,"denominator":1},"duration":{"numerator":4,"denominator":1},
|
||||||
"id": "staff-lead",
|
"written":{"type":"whole","dots":0},
|
||||||
"name": "Lead",
|
"pitches":[{"id":"pitch-1","letter":"c","octave":4,"alter":0,
|
||||||
"clef": "treble",
|
"duration":{"numerator":4,"denominator":1},"written":{"type":"whole","dots":0}}]}]}]}]}]
|
||||||
"role": "lead",
|
})json");
|
||||||
"measures": [
|
auto& root = value.as_object();
|
||||||
{
|
root["metadata"].as_object()["title"] = title;
|
||||||
"id": "measure-1",
|
root["metadata"].as_object()["key_signature"] = key;
|
||||||
"time_signature": "4/4",
|
root["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["key_signature"] = key;
|
||||||
"key_signature": ")json" + key + R"json(",
|
return boost::json::serialize(value);
|
||||||
"voices": [
|
|
||||||
{
|
|
||||||
"id": "voice-1",
|
|
||||||
"notes": [
|
|
||||||
{
|
|
||||||
"id": "note-1",
|
|
||||||
"kind": "note",
|
|
||||||
"duration_ticks": 4096,
|
|
||||||
"pitches": [
|
|
||||||
{"letter": "c", "octave": 4, "accidental": "n"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)json";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
auto navigation_state() -> vamp_zero::app_state {
|
auto navigation_state() -> vamp_zero::app_state {
|
||||||
@@ -504,75 +485,180 @@ BOOST_AUTO_TEST_CASE(main_menu_metronome_setting_survives_score_selection) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
BOOST_AUTO_TEST_CASE(score_parser_preserves_notation_preamble) {
|
BOOST_AUTO_TEST_CASE(score_parser_preserves_notation_preamble) {
|
||||||
auto parsed = vamp_zero::parse_score_document(R"json(
|
auto parsed = vamp_zero::parse_score_document(score_asset_json("Standard Tune", "Eb"), "standard-tune.json");
|
||||||
{
|
BOOST_REQUIRE(static_cast<bool>(parsed));
|
||||||
"metadata": {
|
|
||||||
"title": "Standard Tune",
|
|
||||||
"composer": "Composer",
|
|
||||||
"key_signature": "Eb",
|
|
||||||
"time_signature": "3/4"
|
|
||||||
},
|
|
||||||
"staves": [
|
|
||||||
{
|
|
||||||
"id": "staff-lead",
|
|
||||||
"name": "Lead",
|
|
||||||
"clef": "treble",
|
|
||||||
"role": "lead",
|
|
||||||
"measures": [
|
|
||||||
{
|
|
||||||
"id": "measure-1",
|
|
||||||
"time_signature": "3/4",
|
|
||||||
"key_signature": "Eb",
|
|
||||||
"voices": [
|
|
||||||
{
|
|
||||||
"id": "voice-1",
|
|
||||||
"notes": [
|
|
||||||
{
|
|
||||||
"id": "note-1",
|
|
||||||
"kind": "note",
|
|
||||||
"duration_ticks": 4096,
|
|
||||||
"tuplet_id": "triplet-1",
|
|
||||||
"pitches": [
|
|
||||||
{
|
|
||||||
"letter": "b",
|
|
||||||
"octave": 4,
|
|
||||||
"accidental": "b"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tuplets": [
|
|
||||||
{
|
|
||||||
"id": "triplet-1",
|
|
||||||
"note_count": 3,
|
|
||||||
"space_count": 2
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)json", "standard-tune.json");
|
|
||||||
|
|
||||||
BOOST_REQUIRE(static_cast<std::bool_t>(parsed));
|
|
||||||
BOOST_TEST(parsed->title == "Standard Tune");
|
BOOST_TEST(parsed->title == "Standard Tune");
|
||||||
BOOST_TEST(parsed->composer == "Composer");
|
BOOST_TEST(parsed->composer == "Composer");
|
||||||
BOOST_TEST(parsed->key_signature == "Eb");
|
BOOST_TEST(parsed->key_signature == "Eb");
|
||||||
BOOST_TEST(parsed->time_signature == "3/4");
|
BOOST_TEST(parsed->time_signature == "4/4");
|
||||||
BOOST_TEST(parsed->clef == "treble");
|
BOOST_TEST(parsed->clef == "G:2:0");
|
||||||
BOOST_TEST(parsed->part == "Lead");
|
BOOST_TEST(parsed->part == "Lead");
|
||||||
BOOST_REQUIRE_EQUAL(parsed->measures.size(), 1U);
|
BOOST_REQUIRE_EQUAL(parsed->measures.size(), 1U);
|
||||||
BOOST_TEST(parsed->measures.front().key_signature == "Eb");
|
BOOST_TEST(parsed->measures.front().duration.numerator == 4);
|
||||||
BOOST_TEST(parsed->measures.front().time_signature == "3/4");
|
BOOST_TEST(parsed->measures.front().notes.front().written.type == "whole");
|
||||||
BOOST_REQUIRE_EQUAL(parsed->measures.front().notes.size(), 1U);
|
}
|
||||||
BOOST_TEST(parsed->measures.front().notes.front().tuplet_id == "triplet-1");
|
|
||||||
BOOST_REQUIRE_EQUAL(parsed->measures.front().tuplets.size(), 1U);
|
BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) {
|
||||||
BOOST_TEST(parsed->measures.front().tuplets.front().id == "triplet-1");
|
auto count = std::size_t{};
|
||||||
BOOST_TEST(parsed->measures.front().tuplets.front().note_count == 3);
|
for (auto const& entry : std::filesystem::recursive_directory_iterator{VAMP_TEST_ASSETS_DIR}) {
|
||||||
BOOST_TEST(parsed->measures.front().tuplets.front().space_count == 2);
|
if (entry.path().extension() != ".json") continue;
|
||||||
|
auto input = std::ifstream{entry.path()};
|
||||||
|
auto json = std::string{std::istreambuf_iterator<char>{input}, std::istreambuf_iterator<char>{}};
|
||||||
|
auto parsed = vamp_zero::parse_score_document(json, entry.path().string());
|
||||||
|
BOOST_TEST_CONTEXT(entry.path().string()) {
|
||||||
|
BOOST_REQUIRE(parsed.has_value());
|
||||||
|
BOOST_TEST(vamp_zero::score_page_count(*parsed) > 0U);
|
||||||
|
}
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
BOOST_TEST(count > 0U);
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(score_parser_rejects_old_schema_and_extra_voices) {
|
||||||
|
BOOST_TEST(!vamp_zero::parse_score_document(R"({"metadata":{"title":"Old"},"staves":[]})").has_value());
|
||||||
|
auto source = boost::json::parse(score_asset_json("Fixture"));
|
||||||
|
auto& voices = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["voices"].as_array();
|
||||||
|
auto copy = voices[0];
|
||||||
|
voices.push_back(copy);
|
||||||
|
BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(score_parser_rejects_invalid_rational_and_dangling_tie) {
|
||||||
|
auto source = boost::json::parse(score_asset_json("Fixture"));
|
||||||
|
auto& n = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["voices"].as_array()[0].as_object()["notes"].as_array()[0].as_object();
|
||||||
|
n["duration"].as_object()["denominator"] = 0;
|
||||||
|
BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value());
|
||||||
|
n["duration"].as_object()["denominator"] = 1;
|
||||||
|
n["pitches"].as_array()[0].as_object()["tie_to"] = "missing";
|
||||||
|
BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(score_layout_supports_all_written_durations_and_multiple_dots) {
|
||||||
|
auto names = std::vector<std::string>{"maxima","long","breve","whole","half","quarter","eighth","16th","32nd","64th","128th","256th","512th","1024th"};
|
||||||
|
for (auto const& name : names) {
|
||||||
|
auto n = note({pitch('c',"n",4)});
|
||||||
|
n.written = {.type=name,.dots=2};
|
||||||
|
n.pitches.front().written = n.written;
|
||||||
|
auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484},score_with_notes({n}),0);
|
||||||
|
auto dots = layout_items(layout,vamp_zero::score_layout_item_role::DOT,0,0,0);
|
||||||
|
BOOST_TEST(dots.size()==2U);
|
||||||
|
for (auto const& item : layout.items) if (item.kind == vamp_zero::score_layout_item_kind::GLYPH) {
|
||||||
|
BOOST_TEST(item.bounds.width > 0);
|
||||||
|
BOOST_TEST(item.bounds.height > 0);
|
||||||
|
}
|
||||||
|
n.kind = "rest";
|
||||||
|
n.pitches.clear();
|
||||||
|
layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484},score_with_notes({n}),0);
|
||||||
|
auto rests = layout_items(layout,vamp_zero::score_layout_item_role::REST,0,0,0);
|
||||||
|
BOOST_REQUIRE_EQUAL(rests.size(),1U);
|
||||||
|
BOOST_TEST(rests[0]->bounds.width > 0);
|
||||||
|
BOOST_TEST(rests[0]->bounds.height > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(score_layout_paginates_dense_measures_without_losing_events) {
|
||||||
|
auto notes = std::vector<vamp_zero::score_note>{};
|
||||||
|
for (int i = 0; i < 128; ++i) {
|
||||||
|
auto n = note_with_ticks({pitch('c',"#",4)},128);
|
||||||
|
n.written = {.type="128th"};
|
||||||
|
n.duration = {1,32};
|
||||||
|
n.onset = {i,32};
|
||||||
|
notes.push_back(n);
|
||||||
|
}
|
||||||
|
auto score = score_with_notes(notes);
|
||||||
|
auto total = std::size_t{};
|
||||||
|
auto pages = vamp_zero::score_page_count(score);
|
||||||
|
BOOST_TEST(pages>1U);
|
||||||
|
for (auto page = std::size_t{}; page < pages; ++page) {
|
||||||
|
auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484},score,page);
|
||||||
|
for (auto const& item : layout.items) if (item.role == vamp_zero::score_layout_item_role::NOTEHEAD) {
|
||||||
|
++total;
|
||||||
|
BOOST_TEST(item.bounds.x >= 0);
|
||||||
|
BOOST_TEST(item.bounds.x+item.bounds.width <= 976);
|
||||||
|
BOOST_TEST(item.bounds.y >= 0);
|
||||||
|
BOOST_TEST(item.bounds.y+item.bounds.height <= 484);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BOOST_TEST(total==notes.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(score_layout_uses_independent_harmony_onsets) {
|
||||||
|
auto source = boost::json::parse(score_asset_json("Fixture"));
|
||||||
|
auto& m = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object();
|
||||||
|
m["harmonies"] = boost::json::parse(R"([{"id":"h1","onset":{"numerator":2,"denominator":1},"kind":"major","symbol":"F"}])");
|
||||||
|
auto score = vamp_zero::parse_score_document(boost::json::serialize(source));
|
||||||
|
BOOST_REQUIRE(score.has_value());
|
||||||
|
auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484},*score,0);
|
||||||
|
auto heads = layout_items(layout,vamp_zero::score_layout_item_role::NOTEHEAD,0,0);
|
||||||
|
auto chords = layout_items(layout,vamp_zero::score_layout_item_role::CHORD_SYMBOL,0,0);
|
||||||
|
BOOST_REQUIRE_EQUAL(heads.size(),1U);
|
||||||
|
BOOST_REQUIRE_EQUAL(chords.size(),1U);
|
||||||
|
BOOST_TEST(chords[0]->origin.x > heads[0]->origin.x);
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(score_layout_keeps_section_measure_and_harmony_labels_separate) {
|
||||||
|
auto score = score_with_notes({note({pitch('e', "n", 5)})});
|
||||||
|
score.measures[0].section_label = "Melody";
|
||||||
|
score.measures[0].harmonies.push_back({"h1", {0,1}, "Cmaj7", "major-seventh"});
|
||||||
|
for (auto bounds : {luma::rect{.x=0,.y=0,.width=976,.height=484}, luma::rect{.x=0,.y=0,.width=480,.height=320}}) {
|
||||||
|
auto layout = vamp_zero::layout_score_page(bounds, score, 0);
|
||||||
|
auto sections = layout_items(layout, vamp_zero::score_layout_item_role::SECTION_LABEL);
|
||||||
|
auto numbers = layout_items(layout, vamp_zero::score_layout_item_role::MEASURE_NUMBER);
|
||||||
|
auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL);
|
||||||
|
BOOST_REQUIRE_EQUAL(sections.size(), 1U);
|
||||||
|
BOOST_REQUIRE_EQUAL(numbers.size(), 1U);
|
||||||
|
BOOST_REQUIRE_EQUAL(chords.size(), 1U);
|
||||||
|
BOOST_TEST(sections[0]->bounds.y >= 0);
|
||||||
|
BOOST_TEST(bottom_edge(*sections[0]) < numbers[0]->bounds.y);
|
||||||
|
BOOST_TEST(bottom_edge(*numbers[0]) < chords[0]->bounds.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(score_layout_ties_only_the_linked_chord_tone) {
|
||||||
|
auto source = boost::json::parse(score_asset_json("Partial tie"));
|
||||||
|
auto& ns = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["voices"].as_array()[0].as_object()["notes"].as_array();
|
||||||
|
auto first = ns[0].as_object();
|
||||||
|
first["id"] = "a";
|
||||||
|
first["duration"].as_object()["numerator"] = 2;
|
||||||
|
first["written"].as_object()["type"] = "half";
|
||||||
|
auto& p = first["pitches"].as_array()[0].as_object();
|
||||||
|
p["id"] = "a-c";
|
||||||
|
p["duration"] = first["duration"];
|
||||||
|
p["written"] = first["written"];
|
||||||
|
auto other = p;
|
||||||
|
other["id"] = "a-e";
|
||||||
|
other["letter"] = "e";
|
||||||
|
first["pitches"].as_array().push_back(other);
|
||||||
|
auto second = first;
|
||||||
|
second["id"] = "b";
|
||||||
|
second["onset"].as_object()["numerator"] = 2;
|
||||||
|
second["pitches"].as_array()[0].as_object()["id"] = "b-c";
|
||||||
|
second["pitches"].as_array()[0].as_object()["tie_from"] = "a-c";
|
||||||
|
second["pitches"].as_array()[1].as_object()["id"] = "b-g";
|
||||||
|
second["pitches"].as_array()[1].as_object()["letter"] = "g";
|
||||||
|
first["pitches"].as_array()[0].as_object()["tie_to"] = "b-c";
|
||||||
|
ns = {first, second};
|
||||||
|
auto parsed = vamp_zero::parse_score_document(boost::json::serialize(source));
|
||||||
|
BOOST_REQUIRE(parsed.has_value());
|
||||||
|
auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484}, *parsed, 0);
|
||||||
|
BOOST_TEST(layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD).size() == 4U);
|
||||||
|
BOOST_TEST(layout_items(layout, vamp_zero::score_layout_item_role::TIE).size() == 1U);
|
||||||
|
ns[0].as_object()["pitches"].as_array()[1].as_object()["duration"].as_object()["numerator"] = 0;
|
||||||
|
BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOST_AUTO_TEST_CASE(score_layout_clears_secondary_chord_stems_above_notation) {
|
||||||
|
auto n = note({pitch('c', "#", 5), pitch('e', "n", 5)});
|
||||||
|
n.written = {.type="quarter", .dots=2};
|
||||||
|
n.pitches[0].written = n.written;
|
||||||
|
n.pitches[1].written = {.type="eighth"};
|
||||||
|
auto score = score_with_notes({n});
|
||||||
|
score.measures[0].harmonies.push_back({"h",{0,1},"Cdim","diminished"});
|
||||||
|
auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484}, score, 0);
|
||||||
|
auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL);
|
||||||
|
BOOST_REQUIRE_EQUAL(chords.size(), 1U);
|
||||||
|
for (auto role : {vamp_zero::score_layout_item_role::STEM, vamp_zero::score_layout_item_role::FLAG})
|
||||||
|
for (auto item : layout_items(layout, role)) BOOST_TEST(bottom_edge(*chords[0]) < item->bounds.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
BOOST_AUTO_TEST_CASE(accidentals_are_suppressed_when_the_key_signature_already_covers_the_pitch) {
|
BOOST_AUTO_TEST_CASE(accidentals_are_suppressed_when_the_key_signature_already_covers_the_pitch) {
|
||||||
@@ -813,6 +899,15 @@ BOOST_AUTO_TEST_CASE(database_import_upserts_catalog_rows_and_keeps_unmanaged_ro
|
|||||||
{noto::sqlite_value{score_asset_json("Custom Tune")}});
|
{noto::sqlite_value{score_asset_json("Custom Tune")}});
|
||||||
BOOST_REQUIRE(inserted_custom);
|
BOOST_REQUIRE(inserted_custom);
|
||||||
|
|
||||||
|
write_text(assets / "songs" / "custom.json", score_asset_json("Catalog Collision"));
|
||||||
|
BOOST_REQUIRE(run_import(config, assets));
|
||||||
|
auto custom = query_database(database_path, "SELECT title, catalog_managed FROM songs WHERE id = 'custom'");
|
||||||
|
BOOST_REQUIRE(custom);
|
||||||
|
BOOST_REQUIRE_EQUAL(custom->rows.size(), 1U);
|
||||||
|
BOOST_TEST(text_value(custom->rows[0][0]) == "Custom Tune");
|
||||||
|
BOOST_TEST(integer_value(custom->rows[0][1]) == 0);
|
||||||
|
std::filesystem::remove(assets / "songs" / "custom.json");
|
||||||
|
|
||||||
std::filesystem::remove(song_path);
|
std::filesystem::remove(song_path);
|
||||||
BOOST_REQUIRE(run_import(config, assets));
|
BOOST_REQUIRE(run_import(config, assets));
|
||||||
auto counts = query_database(database_path, "SELECT sum(CASE WHEN id = 'tune' THEN 1 ELSE 0 END), sum(CASE WHEN id = 'custom' THEN 1 ELSE 0 END) FROM songs");
|
auto counts = query_database(database_path, "SELECT sum(CASE WHEN id = 'tune' THEN 1 ELSE 0 END), sum(CASE WHEN id = 'custom' THEN 1 ELSE 0 END) FROM songs");
|
||||||
@@ -919,7 +1014,7 @@ BOOST_AUTO_TEST_CASE(score_page_count_uses_forced_notation_breaks) {
|
|||||||
.measures = std::move(measures),
|
.measures = std::move(measures),
|
||||||
};
|
};
|
||||||
|
|
||||||
BOOST_TEST(vamp_zero::score_page_count(score) == 2U);
|
BOOST_TEST(vamp_zero::score_page_count(score) == 3U);
|
||||||
}
|
}
|
||||||
|
|
||||||
BOOST_AUTO_TEST_CASE(score_layout_attaches_stems_to_notehead_anchors) {
|
BOOST_AUTO_TEST_CASE(score_layout_attaches_stems_to_notehead_anchors) {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
|
||||||
|
class BuildBootstrapTests(unittest.TestCase):
|
||||||
|
def test_requirements_fingerprint_invalidates_cached_build_image(self):
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
spec = importlib.util.spec_from_file_location('vamp_bootstrap', root / 'modulus.py')
|
||||||
|
bootstrap = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(bootstrap)
|
||||||
|
digest = hashlib.sha256((root / 'tools/requirements.txt').read_bytes()).hexdigest()
|
||||||
|
self.assertEqual(bootstrap.build_requirements_digest(), digest)
|
||||||
|
self.assertIn(f'build-requirements="{digest}"', bootstrap.product_build_dockerfile())
|
||||||
|
|
||||||
|
package = types.ModuleType('modulus')
|
||||||
|
cli = types.ModuleType('modulus.cli')
|
||||||
|
package.cli = cli
|
||||||
|
available, docker_run = Mock(return_value=True), Mock()
|
||||||
|
cli.docker_image_available, cli.docker_run = available, docker_run
|
||||||
|
cli.main = lambda command: cli.docker_image_available(types.SimpleNamespace(docker_image='test-image'))
|
||||||
|
for label, expected in [('',False), ('outdated',False), (digest,True)]:
|
||||||
|
result = subprocess.CompletedProcess([], 0, label+'\n', '')
|
||||||
|
with self.subTest(label=label), patch.dict(sys.modules, {'modulus':package, 'modulus.cli':cli}), \
|
||||||
|
patch.object(bootstrap, 'ensure_runtime'), patch.object(bootstrap.subprocess, 'run', return_value=result), \
|
||||||
|
patch.object(sys, 'path', list(sys.path)):
|
||||||
|
self.assertEqual(bootstrap.delegate('configure', []), expected)
|
||||||
|
self.assertIs(cli.docker_image_available, available)
|
||||||
|
self.assertIs(cli.docker_run, docker_run)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import argparse
|
||||||
|
import copy
|
||||||
|
from fractions import Fraction
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from generator import harmony
|
||||||
|
from generator.__main__ import generate_assets
|
||||||
|
from generator.arrange.generate import active_chord, arrange_document, generated_voice, propagated_chords
|
||||||
|
from generator.arrange.fretboard import pitches_for_voicing
|
||||||
|
from generator.model import beat_lengths, fraction, key_from_fifths, pitch_number, rational, validate_document
|
||||||
|
from generator.musicxml import load_musicxml
|
||||||
|
from generator.styles import BUILT_IN_STYLES, canonical_style
|
||||||
|
from generator.transposition import concert_document, shift_pitch, transpose_document
|
||||||
|
from tests.test_musicxml import parse, notes, xml_note, xml_score
|
||||||
|
|
||||||
|
|
||||||
|
class MusicModelTests(unittest.TestCase):
|
||||||
|
def test_chord_semantics_are_not_voicing_heuristics(self):
|
||||||
|
cases = {'C':{0,4,7},'Cdim':{0,3,6},'Cdim7':{0,3,6,9},'Csus2':{0,2,7},
|
||||||
|
'C7b5':{0,4,6,10},'G7b9':{7,11,2,5,8},'Fmaj7#11':{5,9,0,4,11}}
|
||||||
|
for label, expected in cases.items():
|
||||||
|
self.assertEqual(set(harmony.pitch_classes(harmony.parse_symbol(label))),expected,label)
|
||||||
|
for label, essentials in [('G7b9',{11,5,8}),('C13',{4,10,9})]:
|
||||||
|
pitches = pitches_for_voicing(label)
|
||||||
|
self.assertTrue(essentials.issubset({pitch_number(p)%12 for p in pitches}),label)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
harmony.parse_symbol('Cunrecognised')
|
||||||
|
|
||||||
|
def test_transposition_preserves_spelling_octaves_modes(self):
|
||||||
|
for p in ({'letter':'b','alter':1,'octave':4},{'letter':'c','alter':-1,'octave':4},{'letter':'f','alter':2,'octave':4}):
|
||||||
|
self.assertEqual(shift_pitch(p,0,0),p)
|
||||||
|
self.assertEqual(pitch_number(shift_pitch(p,1,2)),pitch_number(p)+2)
|
||||||
|
source = parse(xml_score(xml_note(),attributes='<key><fifths>-3</fifths><mode>minor</mode></key>'))
|
||||||
|
result = transpose_document(source,'D')
|
||||||
|
self.assertEqual(result['metadata']['key_signature'],'D minor')
|
||||||
|
self.assertEqual(result['metadata']['key']['alterations'],key_from_fifths(-1,'minor')['alterations'])
|
||||||
|
|
||||||
|
def test_additive_and_compound_grouping(self):
|
||||||
|
self.assertEqual(beat_lengths({'groups':[{'beats':[3,2],'unit':8}]}),[Fraction(3,2),Fraction(1)])
|
||||||
|
self.assertEqual(beat_lengths({'groups':[{'beats':[6],'unit':8}]}),[Fraction(3,2)]*2)
|
||||||
|
self.assertEqual(beat_lengths({'groups':[{'beats':[3],'unit':4}]}),[Fraction(1)]*3)
|
||||||
|
|
||||||
|
def test_generated_rhythm_follows_mid_measure_meter(self):
|
||||||
|
change = '<attributes><time><beats>6</beats><beat-type>8</beat-type></time></attributes>'
|
||||||
|
source = parse(xml_score(xml_note(duration=1,kind='quarter')+change+xml_note(duration=3,kind='half',extra='<dot/>')))
|
||||||
|
measure = source['staves'][0]['measures'][0]
|
||||||
|
events = generated_voice(measure,'slash',None)
|
||||||
|
self.assertEqual([fraction(n['onset']) for n in events],[0,1,Fraction(5,2)])
|
||||||
|
self.assertEqual([fraction(n['duration']) for n in events],[1,Fraction(3,2),Fraction(3,2)])
|
||||||
|
|
||||||
|
def test_harmony_carry_uses_last_event_and_never_future_event(self):
|
||||||
|
c = harmony.parse_symbol('C7') | {'onset':rational(0)}
|
||||||
|
f = harmony.parse_symbol('F7') | {'onset':rational(2)}
|
||||||
|
measures = [{'harmonies':[c,f]},{'harmonies':[]}]
|
||||||
|
self.assertEqual(propagated_chords(measures),[None,f])
|
||||||
|
self.assertIsNone(active_chord({'harmonies':[f]},Fraction(0),None))
|
||||||
|
self.assertEqual(active_chord(measures[0],Fraction(1),None),c)
|
||||||
|
|
||||||
|
def test_tuplet_ratios_and_tie_links_validated(self):
|
||||||
|
document = parse(xml_score(xml_note()))
|
||||||
|
notes(document)[0]['pitches'][0]['tie_to'] = 'missing'
|
||||||
|
with self.assertRaisesRegex(ValueError,'dangling tie'):
|
||||||
|
validate_document(document)
|
||||||
|
document = parse(xml_score(xml_note()))
|
||||||
|
notes(document)[0]['duration'] = {'numerator':8,'denominator':2}
|
||||||
|
with self.assertRaisesRegex(ValueError,'reduced'):
|
||||||
|
validate_document(document)
|
||||||
|
|
||||||
|
def test_optional_turnaround_and_form(self):
|
||||||
|
source = load_musicxml(Path('data/songs/it-could-happen-to-you.musicxml'))
|
||||||
|
for part in ('acoustic','electric','vocal'):
|
||||||
|
result = arrange_document(source,part)
|
||||||
|
validate_document(result)
|
||||||
|
measures = result['staves'][0]['measures']
|
||||||
|
self.assertEqual(sum(bool(m.get('section_label')) for m in measures),7)
|
||||||
|
self.assertFalse(measures[-1]['harmonies'])
|
||||||
|
self.assertTrue(any(h for m in measures[:-1] for h in m['harmonies']))
|
||||||
|
self.assertFalse(any('chord_optional' in h for m in measures for h in m['harmonies']))
|
||||||
|
|
||||||
|
def test_player_styles_preserved(self):
|
||||||
|
self.assertEqual(len(BUILT_IN_STYLES),50)
|
||||||
|
for text in ('Med.','Med. Swing','Medium','Medium Swing'):
|
||||||
|
self.assertEqual(canonical_style(text),'Medium Swing')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
canonical_style('Unrecognised style')
|
||||||
|
|
||||||
|
def test_failure_preserves_generated_assets(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
root = Path(temp)
|
||||||
|
data = root/'data'
|
||||||
|
(data/'songs').mkdir(parents=True)
|
||||||
|
(data/'songs'/'bad.musicxml').write_text(xml_score(xml_note(duration=5)),encoding='utf-8')
|
||||||
|
output = root/'output'
|
||||||
|
output.mkdir()
|
||||||
|
(output/'last-good').write_text('preserved')
|
||||||
|
args = argparse.Namespace(data_dir=data,output_dir=output,static_assets_dir=Path('package/assets'),clean=True,check=False)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
generate_assets(args)
|
||||||
|
self.assertEqual((output/'last-good').read_text(),'preserved')
|
||||||
|
(data/'songs'/'bad.musicxml').write_text(xml_score(xml_note()),encoding='utf-8')
|
||||||
|
args.check = True
|
||||||
|
with self.assertRaisesRegex(ValueError,'summary'):
|
||||||
|
generate_assets(args)
|
||||||
|
self.assertEqual((output/'last-good').read_text(),'preserved')
|
||||||
|
|
||||||
|
def test_instrument_transposition_is_converted_once(self):
|
||||||
|
source = parse(xml_score(xml_note(),attributes='<transpose><diatonic>-1</diatonic><chromatic>-2</chromatic></transpose>'))
|
||||||
|
concert = concert_document(source)
|
||||||
|
p = notes(concert)[0]['pitches'][0]
|
||||||
|
self.assertEqual((p['letter'],p['alter'],p['octave']),('b',-1,3))
|
||||||
|
self.assertEqual(concert_document(concert),concert)
|
||||||
|
self.assertEqual(concert['metadata']['key_signature'],'Bb')
|
||||||
|
|
||||||
|
def test_deterministic_generation_and_removal(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
data,output = Path(temp)/'data',Path(temp)/'output'
|
||||||
|
(data/'songs').mkdir(parents=True)
|
||||||
|
source = data/'songs'/'fixture.musicxml'
|
||||||
|
source.write_text(xml_score(xml_note()),encoding='utf-8')
|
||||||
|
args = argparse.Namespace(data_dir=data,output_dir=output,static_assets_dir=Path('package/assets'),clean=True,check=False)
|
||||||
|
generate_assets(args)
|
||||||
|
first = {p.relative_to(output):p.read_bytes() for p in output.rglob('*') if p.is_file()}
|
||||||
|
generate_assets(args)
|
||||||
|
self.assertEqual(first,{p.relative_to(output):p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||||
|
source.unlink()
|
||||||
|
generate_assets(args)
|
||||||
|
self.assertFalse(list(output.rglob('*.json')))
|
||||||
|
|
||||||
|
def test_pickup_tie_survives_vocal_restoration(self):
|
||||||
|
source = xml_score(xml_note(duration=1,kind='quarter',extra='<tie type="start"/>'),implicit=True)
|
||||||
|
source = source.replace('</measure></part>','</measure><measure number="2">'+xml_note(extra='<tie type="stop"/>')+'</measure></part>')
|
||||||
|
result = arrange_document(parse(source),'vocal')
|
||||||
|
validate_document(result)
|
||||||
|
measures = result['staves'][0]['measures']
|
||||||
|
start = measures[0]['voices'][0]['notes'][-1]['pitches'][0]
|
||||||
|
end = measures[1]['voices'][0]['notes'][0]['pitches'][0]
|
||||||
|
self.assertEqual(start['tie_to'],end['id'])
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
from fractions import Fraction
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from generator.musicxml import MusicXMLError, load_musicxml
|
||||||
|
from generator.model import NOTE_TYPES, TYPE_LENGTHS, fraction, pitch_number
|
||||||
|
|
||||||
|
|
||||||
|
def xml_score(content, attributes='', *, divisions=1, implicit=False):
|
||||||
|
return ('<?xml version="1.0"?>'
|
||||||
|
'<score-partwise version="4.0"><work><work-title>Fixture</work-title></work>'
|
||||||
|
'<part-list><score-part id="P1"><part-name>Lead</part-name></score-part></part-list>'
|
||||||
|
f'<part id="P1"><measure number="1" implicit="{"yes" if implicit else "no"}">'
|
||||||
|
f'<attributes><divisions>{divisions}</divisions><key><fifths>0</fifths></key>'
|
||||||
|
'<time><beats>4</beats><beat-type>4</beat-type></time>'
|
||||||
|
f'<clef><sign>G</sign><line>2</line></clef>{attributes}</attributes>'
|
||||||
|
f'{content}</measure></part></score-partwise>')
|
||||||
|
|
||||||
|
|
||||||
|
def xml_note(step='C', duration=4, kind='whole', extra='', prefix='', alter=0, octave=4):
|
||||||
|
return (f'<note>{prefix}<pitch><step>{step}</step><alter>{alter}</alter><octave>{octave}</octave></pitch>'
|
||||||
|
f'<duration>{duration}</duration><type>{kind}</type>{extra}</note>')
|
||||||
|
|
||||||
|
|
||||||
|
def parse(text):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / 'fixture.musicxml'
|
||||||
|
path.write_text(text, encoding='utf-8')
|
||||||
|
return load_musicxml(path)
|
||||||
|
|
||||||
|
|
||||||
|
def notes(document, measure=0):
|
||||||
|
return document['staves'][0]['measures'][measure]['voices'][0]['notes']
|
||||||
|
|
||||||
|
|
||||||
|
class MusicXMLTests(unittest.TestCase):
|
||||||
|
def test_regression_fixture(self):
|
||||||
|
score = load_musicxml(Path('tests/fixtures/notation.musicxml'))
|
||||||
|
self.assertEqual(len(score['staves'][0]['measures']),4)
|
||||||
|
self.assertEqual(len(score['staves'][0]['octave_shifts']),1)
|
||||||
|
|
||||||
|
def test_catalogue_imports(self):
|
||||||
|
sources = sorted(Path('data').rglob('*.musicxml'))
|
||||||
|
self.assertEqual(len(sources), 6)
|
||||||
|
for path in sources:
|
||||||
|
with self.subTest(path=path):
|
||||||
|
self.assertEqual(load_musicxml(path)['schema_version'], 2)
|
||||||
|
|
||||||
|
def test_all_written_values_and_dots(self):
|
||||||
|
for kind in NOTE_TYPES:
|
||||||
|
for dots in (0, 1, 2, 3):
|
||||||
|
value = TYPE_LENGTHS[kind] * (2-Fraction(1, 2**dots))
|
||||||
|
score = parse(xml_score(xml_note(duration=value.numerator, kind=kind, extra='<dot/>'*dots), divisions=value.denominator, implicit=True))
|
||||||
|
self.assertEqual(fraction(notes(score)[0]['duration']), value)
|
||||||
|
self.assertEqual(notes(score)[0]['written'], {'type':kind, 'dots':dots})
|
||||||
|
|
||||||
|
def test_divisions_do_not_change_music(self):
|
||||||
|
a = parse(xml_score(xml_note(duration=4)))
|
||||||
|
b = parse(xml_score(xml_note(duration=1920), divisions=480))
|
||||||
|
self.assertEqual(a, b)
|
||||||
|
|
||||||
|
def test_doctype_allowed_but_entities_rejected(self):
|
||||||
|
source = xml_score(xml_note())
|
||||||
|
source = source.replace('<score-partwise', '<!DOCTYPE score-partwise SYSTEM "https://invalid.test/partwise.dtd"><score-partwise', 1)
|
||||||
|
self.assertEqual(parse(source)['schema_version'], 2)
|
||||||
|
malicious = xml_score(xml_note()).replace('<score-partwise', '<!DOCTYPE score-partwise [<!ENTITY secret SYSTEM "file:///etc/passwd">]><score-partwise',1)
|
||||||
|
with self.assertRaises(MusicXMLError):
|
||||||
|
parse(malicious)
|
||||||
|
with self.assertRaises(MusicXMLError):
|
||||||
|
parse('<score-partwise')
|
||||||
|
|
||||||
|
def test_harmony_inside_sustained_note_remains_independent(self):
|
||||||
|
h = '<harmony><root><root-step>F</root-step></root><kind>major</kind><offset>-2</offset></harmony>'
|
||||||
|
score = parse(xml_score(xml_note()+h))
|
||||||
|
self.assertEqual(len(notes(score)), 1)
|
||||||
|
self.assertEqual(fraction(score['staves'][0]['measures'][0]['harmonies'][0]['onset']), 2)
|
||||||
|
|
||||||
|
def test_partial_chord_ties_and_unequal_tones(self):
|
||||||
|
source = xml_note(duration=2,kind='half',extra='<tie type="start"/>')
|
||||||
|
source += xml_note('E',1,'quarter',prefix='<chord/>')
|
||||||
|
source += xml_note(duration=2,kind='half',extra='<tie type="stop"/>')
|
||||||
|
source += xml_note('G',2,'half',prefix='<chord/>')
|
||||||
|
result = notes(parse(xml_score(source)))
|
||||||
|
self.assertEqual(fraction(result[0]['pitches'][1]['duration']), 1)
|
||||||
|
self.assertEqual(result[0]['pitches'][0]['tie_to'], result[1]['pitches'][0]['id'])
|
||||||
|
self.assertNotIn('tie_to', result[0]['pitches'][1])
|
||||||
|
|
||||||
|
def test_grace_cue_and_measured_tremolo(self):
|
||||||
|
grace = '<note><grace slash="yes"/><pitch><step>D</step><octave>4</octave></pitch><type>16th</type></note>'
|
||||||
|
regular = xml_note(extra='<notations><ornaments><tremolo type="single">3</tremolo></ornaments></notations>', prefix='<cue/>')
|
||||||
|
result = notes(parse(xml_score(grace+regular)))
|
||||||
|
self.assertEqual(fraction(result[0]['duration']), 0)
|
||||||
|
self.assertTrue(result[0]['grace']['slash'])
|
||||||
|
self.assertTrue(result[1]['cue'])
|
||||||
|
self.assertEqual(result[1]['tremolo']['marks'], 3)
|
||||||
|
|
||||||
|
def test_exact_quintuplets(self):
|
||||||
|
content = ''
|
||||||
|
for i in range(5):
|
||||||
|
tuplet = '<tuplet type="start"/>' if i==0 else '<tuplet type="stop"/>' if i==4 else ''
|
||||||
|
extra = '<time-modification><actual-notes>5</actual-notes><normal-notes>4</normal-notes></time-modification>'
|
||||||
|
extra += '<notations>'+tuplet+'</notations>'
|
||||||
|
content += xml_note(duration=4, kind='quarter',extra=extra)
|
||||||
|
result = parse(xml_score(content,divisions=5))
|
||||||
|
self.assertEqual(sum(fraction(n['duration']) for n in notes(result)),4)
|
||||||
|
self.assertTrue(all(fraction(n['duration'])==Fraction(4,5) for n in notes(result)))
|
||||||
|
self.assertEqual(result['staves'][0]['tuplets'][0]['note_count'],5)
|
||||||
|
|
||||||
|
def test_inconsistent_written_timing_is_rejected(self):
|
||||||
|
modulation = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
|
||||||
|
with self.assertRaisesRegex(MusicXMLError,'disagrees with written duration'):
|
||||||
|
parse(xml_score(xml_note(extra=modulation)))
|
||||||
|
with self.assertRaisesRegex(MusicXMLError,'disagrees with written duration'):
|
||||||
|
parse(xml_score(xml_note(kind='half')))
|
||||||
|
|
||||||
|
def test_paired_tremolo_timing(self):
|
||||||
|
modulation = '<time-modification><actual-notes>2</actual-notes><normal-notes>1</normal-notes></time-modification>'
|
||||||
|
content = ''.join(xml_note(step,2,'whole',extra=modulation+f'<notations><ornaments><tremolo type="{action}">3</tremolo></ornaments></notations>')
|
||||||
|
for step,action in [('C','start'),('G','stop')])
|
||||||
|
result = notes(parse(xml_score(content)))
|
||||||
|
self.assertEqual(result[0]['tremolo']['partner'],result[1]['id'])
|
||||||
|
self.assertEqual(fraction(result[0]['duration']),2)
|
||||||
|
|
||||||
|
def test_mid_measure_clef_and_key_changes(self):
|
||||||
|
change = '<attributes><key><fifths>-3</fifths><mode>minor</mode></key><clef><sign>C</sign><line>3</line></clef></attributes>'
|
||||||
|
result = parse(xml_score(xml_note(duration=2,kind='half')+change+xml_note(duration=2,kind='half')))
|
||||||
|
attr = result['staves'][0]['measures'][0]['attributes'][0]
|
||||||
|
self.assertEqual(fraction(attr['onset']),2)
|
||||||
|
self.assertEqual(attr['key']['mode'],'minor')
|
||||||
|
self.assertEqual(attr['clef']['sign'],'C')
|
||||||
|
|
||||||
|
def test_optional_marker_contract(self):
|
||||||
|
marker = '<direction><direction-type><words>vamp:optional-chord</words></direction-type></direction>'
|
||||||
|
h = '<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>'
|
||||||
|
score = parse(xml_score(marker+h+xml_note()))
|
||||||
|
self.assertTrue(score['staves'][0]['measures'][0]['harmonies'][0]['chord_optional'])
|
||||||
|
for content in (marker+xml_note(),marker+marker+h+xml_note(),marker.replace('optional-chord','unknown')+xml_note()):
|
||||||
|
with self.assertRaisesRegex(MusicXMLError,'measure 1'):
|
||||||
|
parse(xml_score(content))
|
||||||
|
|
||||||
|
def test_rejects_musical_content_outside_contract(self):
|
||||||
|
extras = ['<notations><slur type="start"/></notations>', '<notations><articulations><accent/></articulations></notations>',
|
||||||
|
'<voice>2</voice><staff>2</staff>', '<pitch><unpitched/></pitch>',
|
||||||
|
'<notations><ornaments><trill-mark/></ornaments></notations>']
|
||||||
|
for extra in extras:
|
||||||
|
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
|
||||||
|
parse(xml_score(xml_note(extra=extra)))
|
||||||
|
for extra in ('<barline><repeat direction="backward"/></barline>', '<backup><duration>4</duration></backup>',
|
||||||
|
'<direction><direction-type><dynamics><ff/></dynamics></direction-type></direction>'):
|
||||||
|
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
|
||||||
|
parse(xml_score(xml_note()+extra))
|
||||||
|
with self.assertRaises(MusicXMLError):
|
||||||
|
parse(xml_score(xml_note(alter='0.5')))
|
||||||
|
with self.assertRaises(MusicXMLError):
|
||||||
|
parse(xml_score(xml_note(extra='<tie type="stop"/>')))
|
||||||
|
with self.assertRaises(MusicXMLError):
|
||||||
|
parse(xml_score(xml_note(duration=5)))
|
||||||
|
|
||||||
|
def test_b_sharp_c_flat_preserve_octave(self):
|
||||||
|
b = notes(parse(xml_score(xml_note('B',alter=1))))[0]['pitches'][0]
|
||||||
|
c = notes(parse(xml_score(xml_note('C',alter=-1))))[0]['pitches'][0]
|
||||||
|
self.assertEqual(pitch_number(b),72)
|
||||||
|
self.assertEqual(pitch_number(c),59)
|
||||||
|
|
||||||
|
def test_musicxml_degree_alterations_are_relative(self):
|
||||||
|
h = '<harmony><root><root-step>C</root-step></root><kind>minor</kind><degree><degree-value>3</degree-value><degree-alter>1</degree-alter><degree-type>alter</degree-type></degree></harmony>'
|
||||||
|
from generator.harmony import degrees
|
||||||
|
score = parse(xml_score(h+xml_note()))
|
||||||
|
self.assertEqual(degrees(score['staves'][0]['measures'][0]['harmonies'][0])[3],0)
|
||||||
|
h = h.replace('<kind>minor</kind>','<kind text="maj7">minor</kind>')
|
||||||
|
with self.assertRaisesRegex(MusicXMLError,'contradictory'):
|
||||||
|
parse(xml_score(h+xml_note()))
|
||||||
|
|
||||||
|
def test_nested_and_cross_bar_tuplets(self):
|
||||||
|
modulation = '<time-modification><actual-notes>9</actual-notes><normal-notes>4</normal-notes></time-modification>'
|
||||||
|
def tuplet(number,action):
|
||||||
|
details = '<tuplet-actual><tuplet-number>3</tuplet-number></tuplet-actual><tuplet-normal><tuplet-number>2</tuplet-number></tuplet-normal>' if action=='start' else ''
|
||||||
|
return f'<tuplet number="{number}" type="{action}">{details}</tuplet>'
|
||||||
|
first = xml_note(duration=4,kind='quarter',extra=modulation+'<notations>'+tuplet(1,'start')+tuplet(2,'start')+'</notations>')
|
||||||
|
last = xml_note(duration=4,kind='quarter',extra=modulation+'<notations>'+tuplet(2,'stop')+tuplet(1,'stop')+'</notations>')
|
||||||
|
text = xml_score(first,divisions=9,implicit=True)
|
||||||
|
text = text.replace('</measure></part>', '</measure><measure number="2" implicit="yes">'+last+'</measure></part>')
|
||||||
|
result = parse(text)
|
||||||
|
spans = result['staves'][0]['tuplets']
|
||||||
|
self.assertEqual([t['depth'] for t in spans],[0,1])
|
||||||
|
self.assertEqual(fraction(notes(result,1)[0]['duration']),Fraction(4,9))
|
||||||
|
self.assertTrue(all(t['end']==notes(result,1)[0]['id'] for t in spans))
|
||||||
@@ -8,11 +8,6 @@ import tempfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from generator.__main__ import copy_runtime_fonts
|
from generator.__main__ import copy_runtime_fonts
|
||||||
from generator.arrange.generate import arrange_document
|
|
||||||
from generator.arrange.fretboard import chord_pitch_classes, pitches_for_voicing
|
|
||||||
from generator.styles import BUILT_IN_STYLES
|
|
||||||
from generator.transposition import mod12
|
|
||||||
from generator.score import normalize_score_document
|
|
||||||
from tools import generate_pico_font
|
from tools import generate_pico_font
|
||||||
|
|
||||||
|
|
||||||
@@ -42,217 +37,3 @@ class FontAssetsTests(unittest.TestCase):
|
|||||||
self.assertEqual(before, output.stat().st_mtime_ns)
|
self.assertEqual(before, output.stat().st_mtime_ns)
|
||||||
output.write_text("stale", encoding="utf-8")
|
output.write_text("stale", encoding="utf-8")
|
||||||
self.assertNotEqual(subprocess.run([*command, "--check"], capture_output=True).returncode, 0)
|
self.assertNotEqual(subprocess.run([*command, "--check"], capture_output=True).returncode, 0)
|
||||||
|
|
||||||
|
|
||||||
class CompactScoreJsonTests(unittest.TestCase):
|
|
||||||
def pitch_classes(self, pitches: list[dict]) -> set[int]:
|
|
||||||
natural = {"c": 0, "d": 2, "e": 4, "f": 5, "g": 7, "a": 9, "b": 11}
|
|
||||||
alterations = {"bb": -2, "b": -1, "n": 0, "": 0, "#": 1, "x": 2}
|
|
||||||
result: set[int] = set()
|
|
||||||
for pitch in pitches:
|
|
||||||
letter = str(pitch.get("letter", "c")).lower()
|
|
||||||
accidental = str(pitch.get("accidental", "n"))
|
|
||||||
result.add(mod12(natural.get(letter, 0) + alterations.get(accidental, 0)))
|
|
||||||
return result
|
|
||||||
|
|
||||||
def chord_symbols(self, measure: dict) -> list[str]:
|
|
||||||
return [
|
|
||||||
note["chord_symbol"]
|
|
||||||
for note in measure["voices"][0]["notes"]
|
|
||||||
if isinstance(note.get("chord_symbol"), str) and note["chord_symbol"]
|
|
||||||
]
|
|
||||||
|
|
||||||
def measure_notes(self, measure: dict) -> list[dict]:
|
|
||||||
return measure["voices"][0]["notes"]
|
|
||||||
|
|
||||||
def test_compact_json_normalizes_notation_tokens(self) -> None:
|
|
||||||
document = normalize_score_document({
|
|
||||||
"title": "Compact Tune",
|
|
||||||
"composer": "Composer",
|
|
||||||
"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\"",
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
staff = document["staves"][0]
|
|
||||||
first_note = staff["measures"][0]["voices"][0]["notes"][0]
|
|
||||||
second_measure_first_note = staff["measures"][1]["voices"][0]["notes"][0]
|
|
||||||
|
|
||||||
self.assertNotIn("version", document)
|
|
||||||
self.assertEqual(document["metadata"]["key_signature"], "F")
|
|
||||||
self.assertEqual(document["metadata"]["style"], "Medium Swing")
|
|
||||||
self.assertEqual(staff["name"], "Vocal")
|
|
||||||
self.assertEqual(first_note["duration_ticks"], 16384)
|
|
||||||
self.assertEqual(first_note["chord_symbol"], "G-7")
|
|
||||||
self.assertEqual(first_note["lyric"], "leaves")
|
|
||||||
self.assertTrue(first_note["tie_to_next"])
|
|
||||||
self.assertTrue(second_measure_first_note["tie_from_previous"])
|
|
||||||
|
|
||||||
def test_compact_json_canonicalizes_player_styles(self) -> None:
|
|
||||||
self.assertEqual(len(BUILT_IN_STYLES), 50)
|
|
||||||
|
|
||||||
for style in ("Med.", "Med. Swing", "Medium", "Medium Swing"):
|
|
||||||
document = normalize_score_document({
|
|
||||||
"title": "Style Tune",
|
|
||||||
"style": style,
|
|
||||||
"measures": ["C4:1"],
|
|
||||||
})
|
|
||||||
self.assertEqual(document["metadata"]["style"], "Medium Swing")
|
|
||||||
|
|
||||||
with self.assertRaisesRegex(ValueError, "supported player style"):
|
|
||||||
normalize_score_document({
|
|
||||||
"title": "Bad Style Tune",
|
|
||||||
"style": "Medium Bossa That Does Not Exist",
|
|
||||||
"measures": ["C4:1"],
|
|
||||||
})
|
|
||||||
|
|
||||||
def test_compact_json_preserves_musicxml_like_measure_metadata(self) -> None:
|
|
||||||
document = normalize_score_document({
|
|
||||||
"title": "Metadata Tune",
|
|
||||||
"measures": [
|
|
||||||
{
|
|
||||||
"notes": "C4:1",
|
|
||||||
"attributes": {"divisions": 4},
|
|
||||||
"directions": [{"type": "dynamics", "value": "mf"}],
|
|
||||||
"harmony": [{"root": "C", "kind": "major-seventh"}],
|
|
||||||
"barlines": {"right": "light-heavy"},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
measure = document["staves"][0]["measures"][0]
|
|
||||||
self.assertEqual(measure["attributes"], {"divisions": 4})
|
|
||||||
self.assertEqual(measure["directions"], [{"type": "dynamics", "value": "mf"}])
|
|
||||||
self.assertEqual(measure["harmony"], [{"root": "C", "kind": "major-seventh"}])
|
|
||||||
self.assertEqual(measure["barlines"], {"right": "light-heavy"})
|
|
||||||
|
|
||||||
def test_arrangements_preserve_measure_notation_metadata(self) -> None:
|
|
||||||
document = normalize_score_document({
|
|
||||||
"title": "Directions Tune",
|
|
||||||
"key": "C",
|
|
||||||
"time": "4/4",
|
|
||||||
"measures": [
|
|
||||||
{
|
|
||||||
"notes": "[Cmaj7] C4:1",
|
|
||||||
"directions": [{"type": "rehearsal", "value": "A"}],
|
|
||||||
"barlines": {"left": "heavy-light"},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
arranged = arrange_document(document, "vocal")
|
|
||||||
measure = arranged["staves"][0]["measures"][0]
|
|
||||||
|
|
||||||
self.assertEqual(measure["directions"], [{"type": "rehearsal", "value": "A"}])
|
|
||||||
self.assertEqual(measure["barlines"], {"left": "heavy-light"})
|
|
||||||
|
|
||||||
def test_compact_json_marks_optional_chords(self) -> None:
|
|
||||||
document = normalize_score_document({
|
|
||||||
"title": "Optional Turnaround",
|
|
||||||
"key": "Eb",
|
|
||||||
"time": "4/4",
|
|
||||||
"measures": [
|
|
||||||
"[(F-7)] r:2 [(Bb7)] r:2",
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
notes = document["staves"][0]["measures"][0]["voices"][0]["notes"]
|
|
||||||
|
|
||||||
self.assertEqual(notes[0]["chord_symbol"], "F-7")
|
|
||||||
self.assertTrue(notes[0]["chord_optional"])
|
|
||||||
self.assertEqual(notes[1]["chord_symbol"], "Bb7")
|
|
||||||
self.assertTrue(notes[1]["chord_optional"])
|
|
||||||
|
|
||||||
def test_arrangements_omit_optional_turnaround_from_final_ending(self) -> None:
|
|
||||||
document = normalize_score_document({
|
|
||||||
"title": "Optional Turnaround",
|
|
||||||
"key": "Eb",
|
|
||||||
"time": "4/4",
|
|
||||||
"measures": [
|
|
||||||
"[Ebmaj7] C4:1",
|
|
||||||
"[Ab7] D4:1",
|
|
||||||
"[Ebmaj7] E4:1",
|
|
||||||
"[(F-7)] r:2 [(Bb7)] r:2",
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
for part_type in ("acoustic", "electric", "vocal"):
|
|
||||||
arranged = arrange_document(document, part_type)
|
|
||||||
measures = arranged["staves"][0]["measures"]
|
|
||||||
non_final_turnaround = measures[7]
|
|
||||||
final_turnaround = measures[-1]
|
|
||||||
|
|
||||||
self.assertNotIn("version", arranged)
|
|
||||||
self.assertEqual(self.chord_symbols(non_final_turnaround), ["F-7", "Bb7"])
|
|
||||||
self.assertEqual(self.chord_symbols(final_turnaround), [])
|
|
||||||
self.assertFalse(any("chord_optional" in note for measure in measures for note in self.measure_notes(measure)))
|
|
||||||
|
|
||||||
if part_type in {"acoustic", "electric"}:
|
|
||||||
self.assertTrue(all(note["kind"] == "rest" for note in self.measure_notes(final_turnaround)))
|
|
||||||
|
|
||||||
def test_slash_arrangements_preserve_source_chord_markers(self) -> None:
|
|
||||||
document = normalize_score_document({
|
|
||||||
"title": "Slash Tune",
|
|
||||||
"key": "C",
|
|
||||||
"time": "4/4",
|
|
||||||
"measures": [
|
|
||||||
"[Cmaj7] C4:1",
|
|
||||||
"[F7#11] F4:1",
|
|
||||||
"[B-7b5] B4:1",
|
|
||||||
"[E7b9] E4:1",
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
arranged = arrange_document(document, "acoustic")
|
|
||||||
slash_measure = arranged["staves"][0]["measures"][12]
|
|
||||||
|
|
||||||
self.assertEqual(self.chord_symbols(slash_measure), ["Cmaj7"])
|
|
||||||
self.assertTrue(all(note.get("notehead") == "slash" for note in self.measure_notes(slash_measure)))
|
|
||||||
|
|
||||||
def test_jazz_core_chords_include_color_tones(self) -> None:
|
|
||||||
self.assertEqual(set(chord_pitch_classes("G7b9")), {7, 11, 5, 8})
|
|
||||||
self.assertEqual(set(chord_pitch_classes("C13")), {0, 4, 10, 9})
|
|
||||||
self.assertEqual(set(chord_pitch_classes("Fmaj7#11")), {5, 9, 4, 11})
|
|
||||||
|
|
||||||
self.assertTrue({11, 5, 8}.issubset(self.pitch_classes(pitches_for_voicing("G7b9"))))
|
|
||||||
self.assertTrue({4, 10, 9}.issubset(self.pitch_classes(pitches_for_voicing("C13"))))
|
|
||||||
|
|
||||||
def test_compact_json_normalizes_pitch_groups_and_tuplets(self) -> None:
|
|
||||||
document = normalize_score_document({
|
|
||||||
"title": "Practice",
|
|
||||||
"key": "C",
|
|
||||||
"time": "4/4",
|
|
||||||
"measures": [
|
|
||||||
"[G7] (G4 B4 F5):4 r:4",
|
|
||||||
"{5:2 C4:4 D4:4 E4:4 F4:4 G4:4}",
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
first_measure_notes = document["staves"][0]["measures"][0]["voices"][0]["notes"]
|
|
||||||
second_voice = document["staves"][0]["measures"][1]["voices"][0]
|
|
||||||
tuplet_notes = second_voice["notes"]
|
|
||||||
|
|
||||||
self.assertEqual(len(first_measure_notes[0]["pitches"]), 3)
|
|
||||||
self.assertEqual(first_measure_notes[1]["kind"], "rest")
|
|
||||||
self.assertEqual(second_voice["tuplets"][0]["note_count"], 5)
|
|
||||||
self.assertEqual(second_voice["tuplets"][0]["space_count"], 2)
|
|
||||||
self.assertEqual(sum(note["duration_ticks"] for note in tuplet_notes), 8192)
|
|
||||||
self.assertTrue(all(note["display_ticks"] == 4096 for note in tuplet_notes))
|
|
||||||
|
|
||||||
def test_compact_json_rejects_overfull_measure(self) -> None:
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
normalize_score_document({
|
|
||||||
"title": "Bad",
|
|
||||||
"time": "4/4",
|
|
||||||
"measures": ["C4:1 D4:4"],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Extract notation glyph bounds from the shipped OTF, in staff-space units."""
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fontTools.pens.boundsPen import BoundsPen
|
||||||
|
from fontTools.ttLib import TTFont
|
||||||
|
|
||||||
|
|
||||||
|
def generate(font_path, output):
|
||||||
|
with TTFont(font_path) as font:
|
||||||
|
glyphs, cmap = font.getGlyphSet(), font.getBestCmap()
|
||||||
|
units = font['head'].unitsPerEm / 4
|
||||||
|
ranges = [(0xE050, 0xE06F), (0xE080, 0xE09F), (0xE0A0, 0xE0A5),
|
||||||
|
(0xE101, 0xE102), (0xE1E7, 0xE1E8), (0xE240, 0xE250),
|
||||||
|
(0xE260, 0xE270), (0xE4E0, 0xE4EE)]
|
||||||
|
records = []
|
||||||
|
for start, end in ranges:
|
||||||
|
for codepoint in range(start, end):
|
||||||
|
if codepoint not in cmap:
|
||||||
|
continue
|
||||||
|
pen = BoundsPen(glyphs)
|
||||||
|
glyphs[cmap[codepoint]].draw(pen)
|
||||||
|
if pen.bounds:
|
||||||
|
box = ', '.join(f'{v / units:.8f}F' for v in pen.bounds)
|
||||||
|
records.append(f' {{{codepoint}, {{{box}}}}},')
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_text('#pragma once\n#include <array>\nnamespace vamp_zero::font_data {\n'
|
||||||
|
'struct glyph { int codepoint; std::array<float, 4> bounds; };\n'
|
||||||
|
f'inline constexpr std::array<glyph, {len(records)}> glyphs = {{{{\n'
|
||||||
|
+ '\n'.join(records) + '\n}};\n}\n', encoding='ascii')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--font', type=Path, required=True)
|
||||||
|
parser.add_argument('--output', type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
generate(args.font, args.output)
|
||||||
@@ -1 +1,3 @@
|
|||||||
Pillow==12.3.0
|
Pillow==12.3.0
|
||||||
|
defusedxml==0.7.1
|
||||||
|
fonttools==4.63.0
|
||||||
|
|||||||
Reference in New Issue
Block a user