Music XML
ci / checks (push) Successful in 42m24s

This commit is contained in:
khodowany
2026-09-10 22:01:18 -05:00
parent ea4e0bbba1
commit f84717990b
47 changed files with 16739 additions and 8391 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
- name: Test native release
run: python3 modulus.py test --target x86_64_gcc12_release
- 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
if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }}
env:
+4 -20
View File
@@ -70,34 +70,18 @@ Artifacts are published to the khodo generic package registry.
## Score Assets
Source score JSON lives in `data/`. Generated score JSON is not committed. To inspect generated assets without building the full package, run:
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
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 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
{
"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`.
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.
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
View File
@@ -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.
- `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.
- `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.
- `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.
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.
+21 -1
View File
@@ -61,6 +61,7 @@ set(VAMP_CORE_SOURCES
src/vamp_zero/errors.cpp
src/vamp_zero/library.cpp
src/vamp_zero/navigation.cpp
src/vamp_zero/notation.cpp
src/vamp_zero/page_control.cpp
src/vamp_zero/portal.cpp
src/vamp_zero/score.cpp
@@ -69,6 +70,17 @@ set(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
${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)
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}/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/**/*
)
set(VAMP_GENERATED_ASSETS_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated-assets)
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(
OUTPUT ${VAMP_GENERATED_ASSETS_STAMP}
COMMAND ${Python3_EXECUTABLE}
@@ -123,10 +140,13 @@ add_custom_command(
--check
COMMAND ${CMAKE_COMMAND} -E touch ${VAMP_GENERATED_ASSETS_STAMP}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
DEPENDS ${VAMP_GENERATOR_INPUTS}
DEPENDS ${VAMP_GENERATOR_INPUTS} ${CMAKE_CURRENT_BINARY_DIR}/generated-assets.inputs
VERBATIM
)
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
RUNTIME DESTINATION zero/bin
+6
View File
@@ -22,9 +22,15 @@ class product_vamp_conan(conan.ConanFile):
exports = "modulus.json"
exports_sources = (
"CMakeLists.txt",
"modulus.py",
"modulus.json",
"cmake/*.cmake",
"data/*.json",
"data/**/*.json",
"data/*.musicxml",
"data/**/*.musicxml",
"tests/fixtures/*",
"tests/fixtures/**/*",
"generator/*.py",
"generator/**/*.py",
"include/*.hpp",
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>
-45
View File
@@ -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
-44
View File
@@ -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
View File
@@ -2,12 +2,14 @@ from __future__ import annotations
import argparse
import shutil
import tempfile
from pathlib import Path
from generator.arrange.generate import generate_performance_scores
from generator.practice.generate import generate_practice_scores
from generator.score import count_json_files, validate_generated_tree
from generator.summary import validate_expected_performance_summary, validate_practice_variants
from generator.musicxml import load_musicxml
RUNTIME_FONT_FILES = ("Petaluma.otf", "PetalumaScript.otf", "OFL.txt")
@@ -30,33 +32,43 @@ def parse_args() -> argparse.Namespace:
return parser.parse_args()
def main() -> int:
args = parse_args()
def generate_assets(args: argparse.Namespace) -> None:
data_dir = args.data_dir.resolve()
output_dir = args.output_dir.resolve()
static_assets_dir = args.static_assets_dir.resolve()
if args.clean and output_dir.exists():
shutil.rmtree(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
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."
)
for source in sorted(data_dir.rglob('*.musicxml')):
load_musicxml(source)
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:
validate_expected_performance_summary(data_dir / "expected" / "performance_summary.json", output_dir / "songs")
validate_practice_variants(output_dir / "practice")
print("Generator check passed.")
validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
validate_practice_variants(staged / 'practice')
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
+41 -152
View File
@@ -4,6 +4,8 @@ from itertools import product
from typing import NamedTuple
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))
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
@@ -15,134 +17,8 @@ class TabPosition(NamedTuple):
midi: int
class ChordSymbol(NamedTuple):
root: str
quality: str
bass: str
ROOTS = {
"C": 0,
"C#": 1,
"Db": 1,
"D": 2,
"D#": 3,
"Eb": 3,
"E": 4,
"F": 5,
"F#": 6,
"Gb": 6,
"G": 7,
"G#": 8,
"Ab": 8,
"A": 9,
"A#": 10,
"Bb": 10,
"B": 11,
}
def parse_root(symbol: str) -> tuple[str, str] | None:
symbol = symbol.strip()
if not symbol or symbol[0].upper() not in "ABCDEFG":
return None
length = 1
if len(symbol) > 1 and symbol[1] in {"#", "b"}:
length = 2
return symbol[:length], symbol[length:]
def parse_chord_symbol(symbol: str) -> ChordSymbol | None:
clean = symbol.strip().replace(" ", "")
base, _, slash = clean.partition("/")
parsed = parse_root(base)
if parsed is None:
return None
root, quality = parsed
bass = ""
if slash:
bass_parsed = parse_root(slash)
if bass_parsed is not None:
bass = bass_parsed[0]
return ChordSymbol(root, quality, bass)
def unique_intervals(values: tuple[int, ...]) -> tuple[int, ...]:
result: list[int] = []
seen: set[int] = set()
for value in values:
interval = mod12(value)
if interval in seen:
continue
seen.add(interval)
result.append(interval)
return tuple(result)
def color_intervals(value: str) -> tuple[int, ...]:
colors: list[int] = []
if "alt" in value:
colors.append(1)
if "b9" in value:
colors.append(1)
if "#9" in value:
colors.append(3)
if "9" in value and "b9" not in value and "#9" not in value:
colors.append(2)
if "#11" in value:
colors.append(6)
elif "11" in value:
colors.append(5)
if "b13" in value:
colors.append(8)
elif "13" in value:
colors.append(9)
return tuple(colors)
def chord_intervals(quality: str) -> tuple[int, ...]:
value = quality.lower().replace(" ", "")
if "7b5" in value or "ø" in value or "half" in value:
return (0, 3, 6, 10)
if "dim" in value or "o" in value:
return (0, 3, 6, 9)
colors = color_intervals(value)
if "aug" in value or "+" in value:
return unique_intervals((0, 4, 8, 10) + colors) if ("7" in value or colors) else (0, 4, 8)
if "sus" in value:
return unique_intervals((0, 5, 10) + colors) if ("7" in value or colors) else (0, 5, 7)
minor = value.startswith("-") or (value.startswith("m") and not value.startswith(("maj", "ma")))
if minor:
if "6" in value and "7" not in value:
return unique_intervals((0, 3, 7, 9) + colors)
if "7" in value or colors:
return unique_intervals((0, 3, 10) + colors)
return (0, 3, 7)
if "maj" in value or value.startswith("ma") or "Δ" in value:
return unique_intervals((0, 4, 11) + colors)
if "6" in value and "7" not in value:
return unique_intervals((0, 4, 7, 9) + colors)
if "7" in value or colors:
return unique_intervals((0, 4, 10) + colors)
return (0, 4, 7)
def pitch_class_for_root(root: str) -> int | None:
return ROOTS.get(root[0].upper() + root[1:], ROOTS.get(root.upper()))
def chord_pitch_classes(symbol: str) -> tuple[int, ...]:
parsed = parse_chord_symbol(symbol)
if parsed is None:
return ()
root_pc = pitch_class_for_root(parsed.root)
if root_pc is None:
return ()
pitch_classes = [mod12(root_pc + interval) for interval in chord_intervals(parsed.quality)]
bass_pc = pitch_class_for_root(parsed.bass) if parsed.bass else None
if bass_pc is not None and bass_pc not in pitch_classes:
pitch_classes.append(bass_pc)
return tuple(pitch_classes)
def chord_pitch_classes(symbol: str | dict) -> tuple[int, ...]:
return harmony.pitch_classes(harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol)
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)
def find_playable_voicing(symbol: str) -> list[TabPosition]:
pitch_classes = chord_pitch_classes(symbol)
def find_playable_voicing(symbol: str | dict) -> list[TabPosition]:
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:
return []
@@ -182,11 +73,13 @@ def find_playable_voicing(symbol: str) -> list[TabPosition]:
if any(not item for item in options):
continue
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]
if fretted and max(fretted) - min(fretted) > 4:
continue
covered = {mod12(position.midi) for position in candidate}
if len(covered & set(pitch_classes)) < min(3, len(set(pitch_classes))):
if not set(pitch_classes).issubset(covered):
continue
score = candidate_score(candidate, pitch_classes)
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)
def pitch_from_midi(midi: int) -> dict[str, int | str]:
names = (
("c", "n"),
("c", "#"),
("d", "n"),
("e", "b"),
("e", "n"),
("f", "n"),
("f", "#"),
("g", "n"),
("a", "b"),
("a", "n"),
("b", "b"),
("b", "n"),
)
letter, accidental = names[mod12(midi)]
return {"letter": letter, "accidental": accidental, "octave": midi // 12 - 1}
def pitches_for_voicing(symbol: str) -> list[dict[str, int | str]]:
return [pitch_from_midi(position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET) for position in find_playable_voicing(symbol)]
def pitches_for_voicing(symbol: str | dict) -> list[dict[str, int | str]]:
definition = harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol
from generator.transposition import shift_pitch
root = definition.get('root', {'letter':'c', 'alter':0})
spellings = {}
for degree, alter in harmony.degrees(definition).items():
p = shift_pitch(root, degree-1, harmony.degree_semitones(degree, alter))
spellings[(NATURAL[p['letter']]+p['alter']) % 12] = p
if bass := definition.get('bass'):
spellings[(NATURAL[bass['letter']]+bass['alter']) % 12] = bass
result = []
for position in find_playable_voicing(definition):
midi = position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET
p = spellings[midi % 12].copy()
p['octave'] = (midi - NATURAL[p['letter']] - p['alter']) // 12 - 1
result.append(p)
return result
+150 -249
View File
@@ -1,279 +1,180 @@
from __future__ import annotations
import copy
import warnings
from fractions import Fraction
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.score import (
QUARTER_TICKS,
apply_source_chord_markers,
active_chord_at_offset,
chord_markers,
chord_symbol_at_offset,
copy_source_voice,
first_chord_symbol,
first_voice,
leading_pickup_measure_index,
load_json,
make_arranged_document,
make_measure,
measure_capacity_ticks,
renderable_duration,
renderable_rest_notes,
rest_note,
sanitized_note,
slash_note,
slugify,
source_staff,
source_note_attack_at,
write_json,
)
PART_TYPES = ("acoustic", "electric", "vocal")
from generator.model import beat_lengths, fraction, key_label, make_event, meter_label, rational
from generator.musicxml import load_musicxml
from generator.transposition import concert_document, shift_pitch
from generator.score import copy_source_voice, make_arranged_document, make_measure, measure_notes, reidentify, slugify, source_staff, write_json
PART_TYPES = ('acoustic','electric','vocal')
FINAL_SECTION_INDEX = 6
def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> list[str]:
result: list[str] = []
current = ""
def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> list[dict | None]:
result = []
current = None
for measure in measures:
current = first_chord_symbol(measure, current, include_optional=include_optional)
result.append(current)
for h in measure.get('harmonies', []):
if include_optional or not h.get('chord_optional'):
current = h
return result
def playable_chord_note(note_id: str, duration_ticks: int, voicing_chord_symbol: str, visible_chord_symbol: str = "") -> dict:
note = {
"id": note_id,
"kind": "note",
"duration_ticks": duration_ticks,
"pitches": pitches_for_voicing(voicing_chord_symbol),
"hidden": False,
}
if not note["pitches"]:
return rest_note(note_id, duration_ticks, visible_chord_symbol)
if visible_chord_symbol:
note["chord_symbol"] = visible_chord_symbol
return note
def rhythm_notes(measure: dict, chord_symbol: str, sequence: int, *, 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:
def active_chord(measure: dict, onset: Fraction, previous: dict | None) -> dict | None:
active = previous
for h in measure['harmonies']:
if fraction(h['onset']) > onset:
break
if offset > cursor:
rests, sequence = renderable_rest_notes("arr-rest", offset - cursor, sequence)
notes.extend(rests)
cursor = offset
active_chord = active_chord_at_offset(measure, offset, chord_symbol, 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
active = h
return active if active and active['kind'] != 'none' else None
def accompaniment_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
notes: list[dict] = []
lead_rest = min(QUARTER_TICKS // 2, capacity)
if lead_rest:
rests, sequence = renderable_rest_notes("arr-rest", lead_rest, sequence, chord_symbol_at_offset(measure, 0, include_optional=include_optional))
notes.extend(rests)
if lead_rest < capacity:
hit = min(QUARTER_TICKS // 2, capacity - lead_rest)
visible_chord = chord_symbol_at_offset(measure, lead_rest, include_optional=include_optional)
active_chord = active_chord_at_offset(measure, lead_rest, chord_symbol, include_optional=include_optional)
sequence += 1
if source_note_attack_at(measure, lead_rest):
notes.append(rest_note(f"arr-rest-{sequence}", hit, visible_chord))
def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dict]:
length = fraction(measure['duration'])
if role == 'rest':
return [make_event('rest', Fraction(), length, 'rest', written={'type':'whole','dots':0}) | {'measure_rest':True}]
if role == 'rhythm' and measure['time_signature']=='4/4' and not measure.get('attributes'):
attacks = [(Fraction(n,2), Fraction(1,2)) for n in (0,3,4,5,7)]
elif role == 'accompaniment' and measure['time_signature']=='4/4' and not measure.get('attributes'):
attacks = [(Fraction(1,2),Fraction(1,2))]
else:
notes.append(playable_chord_note(f"arr-note-{sequence}", hit, active_chord, visible_chord))
cursor = lead_rest + hit
if cursor < capacity:
markers = [marker for marker in chord_markers(measure, include_optional=include_optional) if marker[0] >= cursor]
if markers:
segment_cursor = cursor
for index, (start, _, chord) in enumerate(markers):
if start > segment_cursor:
rests, sequence = renderable_rest_notes("arr-rest", start - segment_cursor, sequence)
notes.extend(rests)
next_start = markers[index + 1][0] if index + 1 < len(markers) else capacity
rests, sequence = renderable_rest_notes("arr-rest", next_start - start, sequence, chord)
notes.extend(rests)
segment_cursor = next_start
if segment_cursor < capacity:
rests, sequence = renderable_rest_notes("arr-rest", capacity - segment_cursor, sequence)
notes.extend(rests)
attacks = []
states = [(Fraction(), measure['meter'])]
states.extend((fraction(a['onset']), a['meter']) for a in measure.get('attributes', []) if 'meter' in a)
for index, (onset, meter) in enumerate(states):
end = states[index+1][0] if index+1 < len(states) else length
beats = beat_lengths(meter)
beat_index = 0
while onset < end:
beat = min(beats[beat_index % len(beats)] if beats else end-onset, end-onset)
duration = beat if role == 'slash' or not beats else min(beat/2, Fraction(1,2))
attacks.append((onset, duration))
onset += beat
beat_index += 1
events = []
cursor = Fraction()
for onset, duration in attacks:
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:
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 slash_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
notes: list[dict] = []
cursor = 0
while cursor < capacity:
sequence += 1
duration = min(QUARTER_TICKS, capacity - cursor)
notes.append(slash_note(f"arr-slash-{sequence}", duration))
cursor += duration
return 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
pitches = pitches_for_voicing(chord)
event = make_event(f'chord-{len(events)}',onset,duration,'note',pitches)
if not pitches:
warnings.warn(f'{measure["id"]}: no playable voicing for {chord["symbol"]}; using labelled rhythmic slashes', stacklevel=2)
event['notehead'] = 'slash'
events.append(event)
cursor = onset + duration
if cursor < length:
events.append(make_event(f'rest-{len(events)}',cursor,length-cursor,'rest'))
return events
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)
measures = staff.get("measures")
if not isinstance(measures, list) or not measures:
raise ValueError("performance arrangement requires at least one source measure")
output = make_arranged_document(
document,
staff,
staff_id="staff-arranged-vocal" if part_type == "vocal" else "staff-arranged-guitar",
staff_name=part_type.capitalize(),
role="vocal" if part_type == "vocal" else "guitar",
arrangement_part_type=part_type,
measures=arranged_measures([measure for measure in measures if isinstance(measure, dict)], part_type),
)
metadata = output.setdefault("metadata", {})
if isinstance(metadata, dict):
metadata.setdefault("tempo", 124)
return output
source = staff['measures']
sections = full_arrangement_sections(source,part_type)
output, tuplets, octave_shifts = [], [], []
absolute = Fraction()
source_starts = []
source_time = Fraction()
for m in source:
source_starts.append(source_time)
source_time += fraction(m['duration'])
for section in sections:
previous = propagated_chords(source, include_optional=section.section_index!=FINAL_SECTION_INDEX)
segment = []
for si in range(section.source_measure_index,section.source_measure_index+section.measure_count):
m = copy.deepcopy(source[si])
m['harmonies'] = [h for h in m['harmonies'] if section.section_index!=FINAL_SECTION_INDEX or not h.get('chord_optional')]
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:
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
for source_path in sorted(source_dir.glob("*.json")):
document = load_json(source_path)
for part_type in PART_TYPES:
output = arrange_document(document, part_type)
target = output_dir / f"{slugify(source_path.stem)}-{part_type}.json"
write_json(target, output)
for path in sorted(source_dir.glob('*.musicxml')):
document = load_musicxml(path)
for part in PART_TYPES:
write_json(output_dir/f'{slugify(path.stem)}-{part}.json',arrange_document(document,part))
count += 1
return count
+111
View File
@@ -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
+243
View File
@@ -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')
+562
View File
@@ -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
+10 -8
View File
@@ -2,7 +2,8 @@ from __future__ import annotations
from pathlib import Path
from generator.score import copy_source_voice, load_json, make_arranged_document, make_measure, slugify, source_staff, write_json
from generator.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
@@ -19,10 +20,10 @@ def practice_document(document: dict) -> dict:
measure,
voice["notes"],
section_label=str(measure.get("section_label", "")),
tuplets=voice["tuplets"],
)
output_measure["voices"][0]["id"] = str(voice.get("id", output_measure["voices"][0]["id"]))
measures.append(output_measure)
tuplets = reidentify(measures, staff.get('tuplets', []))
return make_arranged_document(
document,
staff,
@@ -31,15 +32,17 @@ def practice_document(document: dict) -> dict:
role="vocal",
arrangement_part_type="practice",
measures=measures,
tuplets=tuplets,
octave_shifts=staff.get('octave_shifts', []),
)
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:
document = practice_document(load_json(source_path))
document = practice_document(load_musicxml(source_path))
metadata = document.get("metadata", {})
key = str(metadata.get("key_signature", "C") if isinstance(metadata, dict) else "C")
relative = source_path.relative_to(source_root)
@@ -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:
base_document = practice_document(load_json(source_path))
base_document = practice_document(load_musicxml(source_path))
relative = source_path.relative_to(source_root)
lesson_group = relative.parent.name
exercise_id = slugify(source_path.stem)
@@ -67,11 +70,10 @@ def generate_practice_scores(source_dir: Path, output_dir: Path) -> int:
count = 0
chord_root = source_dir / "chord"
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)
melody_root = source_dir / "melody"
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)
return count
+77 -772
View File
@@ -4,812 +4,117 @@ import copy
import json
import re
from pathlib import Path
from typing import Any
from generator.styles import canonical_style
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",
}
from generator.model import SCHEMA_VERSION, validate_document
def load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as stream:
document = json.load(stream)
if not isinstance(document, dict):
raise ValueError(f"{path} is not a score document")
return normalize_score_document(document, path)
def load_json(path: Path) -> dict:
document = json.loads(path.read_text(encoding='utf-8'))
validate_document(document)
return document
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)
with path.open("w", encoding="utf-8") as stream:
json.dump(document, stream, indent=2, ensure_ascii=False)
stream.write("\n")
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def slugify(value: str) -> str:
value = value.strip().lower()
value = re.sub(r"[^a-z0-9]+", "-", value)
value = value.strip("-")
return value or "score"
return re.sub(r'[^a-z0-9]+', '-', value.strip().lower()).strip('-') or 'score'
def human_label(value: str) -> str:
text = value.replace("_", " ").replace("-", " ").strip()
return " ".join(part.capitalize() for part in text.split()) or "General"
return ' '.join(v.capitalize() for v in value.replace('_',' ').replace('-',' ').split()) or 'General'
class CompactScoreError(ValueError):
pass
def source_staff(document: dict) -> dict:
if len(document.get('staves', [])) != 1:
raise ValueError('exactly one source staff is required')
return document['staves'][0]
DURATION_TICKS = {
"1": WHOLE_TICKS,
"2": HALF_TICKS,
"4": QUARTER_TICKS,
"8": EIGHTH_TICKS,
"16": SIXTEENTH_TICKS,
"32": THIRTY_SECOND_TICKS,
}
def first_voice(measure: dict) -> dict:
if len(measure.get('voices', [])) != 1:
raise ValueError('exactly one source voice is required')
return measure['voices'][0]
def score_field(document: dict[str, Any], metadata: dict[str, Any], *names: str, fallback: str = "") -> str:
for name in names:
value = document.get(name)
if isinstance(value, str):
return value
value = metadata.get(name)
if isinstance(value, str):
return value
return fallback
def measure_notes(measure: dict) -> list[dict]:
return first_voice(measure)['notes']
def canonicalize_score_metadata(metadata: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
output = sanitize_json(metadata)
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 leading_pickup_measure_index(measures: list[dict]) -> int | None:
return 0 if len(measures)>1 and measures[0].get('implicit') else None
def normalize_full_score_document(document: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
output = copy.deepcopy(document)
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 body_start_measure_index(measures: list[dict]) -> int:
return 1 if leading_pickup_measure_index(measures) is not None else 0
def parse_duration_token(text: str, label: str) -> int:
dotted = text.endswith(".")
base_text = text[:-1] if dotted else text
if base_text not in DURATION_TICKS:
raise CompactScoreError(f"{label}: unsupported duration {text!r}")
ticks = DURATION_TICKS[base_text]
return ticks + ticks // 2 if dotted else ticks
def compact_pitch(letter: str, accidental: str, octave: int) -> dict[str, Any]:
return {
"letter": letter.lower(),
"accidental": accidental or "n",
"octave": octave,
}
class CompactMeasureParser:
def __init__(self, text: str, *, measure_number: int, time_signature: str) -> None:
self.text = text
self.index = 0
self.measure_number = measure_number
self.time_signature = time_signature
self.sequence = 0
self.tuplet_sequence = 0
self.pending_chord = ""
self.pending_chord_optional = False
self.last_note: dict[str, Any] | None = None
self.last_octave = 4
self.tuplets: list[dict[str, Any]] = []
def label(self) -> str:
return f"measure {self.measure_number}"
def error(self, message: str) -> CompactScoreError:
return CompactScoreError(f"{self.label()}, column {self.index + 1}: {message}")
def peek(self) -> str:
return self.text[self.index] if self.index < len(self.text) else ""
def skip_space(self) -> None:
while self.peek() and self.peek().isspace():
self.index += 1
def parse_until(self, close: str, label: str) -> str:
start = self.index
escaped = False
while self.index < len(self.text):
char = self.text[self.index]
if escaped:
escaped = False
self.index += 1
continue
if char == "\\":
escaped = True
self.index += 1
continue
if char == close:
value = self.text[start:self.index]
self.index += 1
return bytes(value, "utf-8").decode("unicode_escape")
self.index += 1
raise self.error(f"unterminated {label}")
def parse_chord(self) -> None:
self.index += 1
chord = self.parse_until("]", "chord symbol").strip()
optional = chord.startswith("(") and chord.endswith(")")
if optional:
chord = chord[1:-1].strip()
if not chord:
raise self.error("chord symbol must not be empty")
self.pending_chord = chord
self.pending_chord_optional = optional
def parse_lyric(self) -> None:
if self.last_note is None:
raise self.error("lyric must follow a note")
self.index += 1
lyric = self.parse_until('"', "lyric")
self.last_note["lyric"] = lyric
def parse_tuplet_header(self) -> tuple[int, int]:
self.skip_space()
match = re.match(r"(\d+)\s*:\s*(\d+)", self.text[self.index:])
if match is None:
raise self.error("tuplet must start with N:M")
self.index += match.end()
note_count = int(match.group(1))
space_count = int(match.group(2))
if note_count <= 0 or space_count <= 0:
raise self.error("tuplet counts must be positive")
return note_count, space_count
def parse_tuplet(self) -> list[dict[str, Any]]:
self.index += 1
note_count, space_count = self.parse_tuplet_header()
self.tuplet_sequence += 1
tuplet_id = f"tuplet-{self.measure_number}-{self.tuplet_sequence}"
notes: list[dict[str, Any]] = []
while True:
self.skip_space()
if not self.peek():
raise self.error("unterminated tuplet")
if self.peek() == "}":
self.index += 1
break
if self.peek() == "{":
raise self.error("nested tuplets are not supported")
notes.extend(self.parse_item())
if len(notes) != note_count:
raise self.error(f"tuplet {tuplet_id} contains {len(notes)} notes/rests, expected {note_count}")
self.apply_tuplet_duration(notes, note_count, space_count)
for note in notes:
note["tuplet_id"] = tuplet_id
self.tuplets.append({
"id": tuplet_id,
"note_count": note_count,
"space_count": space_count,
})
return notes
def apply_tuplet_duration(self, notes: list[dict[str, Any]], note_count: int, space_count: int) -> None:
numerators = [int(note["duration_ticks"]) * space_count for note in notes]
floors = [value // note_count for value in numerators]
target_total = sum(numerators) // note_count
remainder = target_total - sum(floors)
order = sorted(range(len(notes)), key=lambda index: numerators[index] % note_count, reverse=True)
actual = floors[:]
for index in order[:remainder]:
actual[index] += 1
for note, ticks in zip(notes, actual):
display = int(note["duration_ticks"])
note["duration_ticks"] = max(1, ticks)
note["display_ticks"] = display
def parse_pitch_atom(self) -> dict[str, Any]:
start = self.index
char = self.peek()
if char.lower() not in "abcdefg":
raise self.error("expected pitch")
self.index += 1
accidental = ""
if self.peek() in {"#", "b", "n"}:
accidental = self.peek()
self.index += 1
octave_start = self.index
while self.peek().isdigit():
self.index += 1
if self.index > octave_start:
self.last_octave = int(self.text[octave_start:self.index])
if self.index == start:
raise self.error("expected pitch")
return compact_pitch(char, accidental, self.last_octave)
def parse_pitch_group(self) -> list[dict[str, Any]]:
self.index += 1
pitches: list[dict[str, Any]] = []
while True:
self.skip_space()
if not self.peek():
raise self.error("unterminated pitch group")
if self.peek() == ")":
self.index += 1
break
pitches.append(self.parse_pitch_atom())
if not pitches:
raise self.error("pitch group must contain at least one pitch")
return pitches
def parse_note(self, tie_from_previous: bool = False) -> dict[str, Any]:
start = self.index
char = self.peek()
if not char:
raise self.error("expected note or rest")
kind = "rest" if char.lower() == "r" else "note"
if kind == "rest":
self.index += 1
pitches = [copy.deepcopy(REST_PITCH)]
elif char == "(":
pitches = self.parse_pitch_group()
else:
pitches = [self.parse_pitch_atom()]
if self.peek() != ":":
raise self.error(f"expected ':' after {self.text[start:self.index]!r}")
self.index += 1
duration_start = self.index
while self.peek().isdigit():
self.index += 1
if self.peek() == ".":
self.index += 1
duration_text = self.text[duration_start:self.index]
if not duration_text:
raise self.error("duration is required")
self.sequence += 1
note: dict[str, Any] = {
"id": f"note-{self.measure_number}-{self.sequence}",
"kind": kind,
"duration_ticks": parse_duration_token(duration_text, self.label()),
"pitches": pitches,
"hidden": False,
}
if self.pending_chord:
note["chord_symbol"] = self.pending_chord
if self.pending_chord_optional:
note["chord_optional"] = True
self.pending_chord = ""
self.pending_chord_optional = False
if tie_from_previous:
note["tie_from_previous"] = True
if self.peek() == "~":
note["tie_to_next"] = True
self.index += 1
self.last_note = note
return note
def parse_item(self) -> list[dict[str, Any]]:
self.skip_space()
char = self.peek()
if not char:
return []
if char == "[":
self.parse_chord()
return []
if char == '"':
self.parse_lyric()
return []
if char == "{":
return self.parse_tuplet()
if char == "}":
return []
if char == "~":
self.index += 1
self.skip_space()
next_char = self.peek()
if next_char and (next_char.lower() in "abcdefgr" or next_char == "("):
return [self.parse_note(tie_from_previous=True)]
if self.last_note is None:
raise self.error("tie marker must attach to a note")
self.last_note["tie_to_next"] = True
return []
if char.lower() in "abcdefgr" or char == "(":
return [self.parse_note()]
raise self.error("expected note, rest, chord, lyric, tie, or tuplet")
def parse(self) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
notes: list[dict[str, Any]] = []
while True:
self.skip_space()
if not self.peek():
break
notes.extend(self.parse_item())
if self.pending_chord:
raise self.error(f"chord {self.pending_chord!r} is not followed by a note or rest")
total = sum(int(note.get("duration_ticks", 0)) for note in notes)
capacity = measure_capacity_ticks(self.time_signature)
if total > capacity:
raise CompactScoreError(f"{self.label()}: duration total {total} exceeds {self.time_signature} capacity {capacity}")
return notes, self.tuplets
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)
def copy_source_voice(measure: dict, *, strip_lyrics: bool) -> dict:
voice = copy.deepcopy(first_voice(measure))
if strip_lyrics:
output.pop("lyric", None)
if not include_optional and note.get("chord_optional") is True:
output.pop("chord_symbol", None)
output.setdefault("hidden", False)
return output
for note in voice['notes']:
note.pop('lyric', None)
note.pop('lyric_notation', None)
return voice
def copy_source_voice(measure: dict[str, Any], *, strip_lyrics: bool, include_optional: bool = True) -> dict[str, Any]:
voice = first_voice(measure)
tuplets = sanitize_json(voice.get("tuplets", []))
notes = [sanitized_note(note, strip_lyrics=strip_lyrics, include_optional=include_optional) for note in measure_notes(measure)]
return {
"id": str(voice.get("id", "voice-source")),
"tuplets": tuplets if isinstance(tuplets, list) else [],
"notes": notes or [rest_note("arr-rest-empty", measure_capacity_ticks(str(measure.get("time_signature", "4/4"))))],
}
def make_measure(measure_id: str, source: dict, notes: list[dict], *, section_label: str = '') -> dict:
result = copy.deepcopy(source)
result.update({'id':measure_id, 'section_label':section_label, 'voices':[{'id':'voice-arranged', 'notes':notes}]})
return result
def measure_notation_metadata(source: dict[str, Any]) -> dict[str, Any]:
return {
key: sanitize_json(value)
for key, value in source.items()
if key not in GENERATED_MEASURE_FIELDS
}
def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
"""Clone-local links survive; links cut by an arrangement boundary become attacks."""
tone_map, event_map = {}, {}
tuplets = []
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]:
measure: dict[str, Any] = {
"id": measure_id,
"time_signature": str(source.get("time_signature", "4/4")),
"key_signature": str(source.get("key_signature", "C")),
"voices": [{
"id": f"voice-{measure_id}",
"tuplets": tuplets or [],
"notes": notes,
}],
}
if section_label:
measure["section_label"] = section_label
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 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:
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 []}]}
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:
for path in root.rglob("*.json"):
text = path.read_text(encoding="utf-8")
for field in FORBIDDEN_OUTPUT_FIELDS:
if f'"{field}"' in text:
raise ValueError(f"{path} contains forbidden generated field {field}")
json.loads(text)
for path in root.rglob('*.json'):
load_json(path)
+7 -7
View File
@@ -6,6 +6,7 @@ from typing import Any
from generator.score import load_json, measure_notes
from generator.transposition import TARGET_KEYS
from generator.model import fraction, pitch_name
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] = {
"measure": measure_number,
"section_label": str(measure.get("section_label", "")),
"chord_symbols": [str(note.get("chord_symbol", "")) for note in notes if isinstance(note.get("chord_symbol"), str) and note.get("chord_symbol")],
"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"),
}
if staff.get("arrangement_part_type") == "vocal":
summary["vocal_skeleton"] = [
{
"kind": str(note.get("kind", "note")),
"duration_ticks": int(note.get("duration_ticks", 0)),
"display_ticks": int(note["display_ticks"]) if isinstance(note.get("display_ticks"), int) else None,
"has_lyric": isinstance(note.get("lyric"), str) and bool(note.get("lyric")),
}
f'{fraction(note["onset"])}+{fraction(note["duration"])} '
+ note['written']['type'] + '.'*note['written'].get('dots',0)
+ ' ' + note['kind'] + ' ' + ','.join(pitch_name(p)+str(p['octave']) for p in note['pitches'])
+ ' ' + note.get('lyric','')
for note in notes
]
return summary
+120 -166
View File
@@ -2,34 +2,20 @@ from __future__ import annotations
import copy
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)
class KeyProfile:
name: str
root: int
degrees: tuple[str, str, str, str, str, str, str]
prefer_flats: bool
fifths: int
KEY_PROFILES = (
KeyProfile("C", 0, ("C", "D", "E", "F", "G", "A", "B"), False),
KeyProfile("Db", 1, ("Db", "Eb", "F", "Gb", "Ab", "Bb", "C"), True),
KeyProfile("D", 2, ("D", "E", "F#", "G", "A", "B", "C#"), False),
KeyProfile("Eb", 3, ("Eb", "F", "G", "Ab", "Bb", "C", "D"), True),
KeyProfile("E", 4, ("E", "F#", "G#", "A", "B", "C#", "D#"), False),
KeyProfile("F", 5, ("F", "G", "A", "Bb", "C", "D", "E"), True),
KeyProfile("Gb", 6, ("Gb", "Ab", "Bb", "Cb", "Db", "Eb", "F"), True),
KeyProfile("G", 7, ("G", "A", "B", "C", "D", "E", "F#"), False),
KeyProfile("Ab", 8, ("Ab", "Bb", "C", "Db", "Eb", "F", "G"), True),
KeyProfile("A", 9, ("A", "B", "C#", "D", "E", "F#", "G#"), False),
KeyProfile("Bb", 10, ("Bb", "C", "D", "Eb", "F", "G", "A"), True),
KeyProfile("B", 11, ("B", "C#", "D#", "E", "F#", "G#", "A#"), False),
)
TARGET_KEYS = tuple(profile.name for profile in KEY_PROFILES)
NATURAL_PITCH_CLASSES = {"c": 0, "d": 2, "e": 4, "f": 5, "g": 7, "a": 9, "b": 11}
LETTER_DEGREES = {"c": 0, "d": 1, "e": 2, "f": 3, "g": 4, "a": 5, "b": 6}
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))))
TARGET_KEYS = tuple(p.name for p in KEY_PROFILES)
def mod12(value: int) -> int:
@@ -37,158 +23,126 @@ def mod12(value: int) -> int:
def find_key(name: str) -> KeyProfile:
normalized = name.lower()
aliases = {"c#": "db", "d#": "eb", "f#": "gb", "g#": "ab", "a#": "bb"}
normalized = aliases.get(normalized, normalized)
for profile in KEY_PROFILES:
if profile.name.lower() == normalized:
return profile
raise ValueError(f"unsupported key signature: {name}")
tonic = parse_pitch_name(name)
for fifths in range(-14, 15):
key = key_from_fifths(fifths)
if key['tonic'] == tonic:
return KeyProfile(name, (NATURAL[tonic['letter']] + tonic['alter']) % 12, fifths)
raise ValueError(f'unsupported key signature: {name}')
def signed_interval(source: KeyProfile, target: KeyProfile) -> int:
interval = mod12(target.root - source.root)
return interval - 12 if interval > 6 else interval
value = (target.root-source.root) % 12
return value-12 if value > 6 else value
def split_spelling(value: str) -> tuple[str, str]:
if not value:
return "C", ""
return value[0], value[1:]
def interval_between(source: dict, target: dict) -> tuple[int, int]:
semitones = (NATURAL[target['letter']] + target['alter'] - NATURAL[source['letter']] - source['alter']) % 12
if semitones > 6:
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:
value = accidental.lower()
if value in {"#", "sharp"}:
return 1
if value in {"b", "flat"}:
return -1
return 0
def spelling_pitch_class(spelling: str) -> int:
letter, accidental = split_spelling(spelling)
return mod12(NATURAL_PITCH_CLASSES.get(letter.lower(), 0) + accidental_offset(accidental))
def pitch_class(pitch: dict[str, Any]) -> int:
letter = str(pitch.get("letter", "c")).lower()
accidental = str(pitch.get("accidental", "n"))
return mod12(NATURAL_PITCH_CLASSES.get(letter, 0) + accidental_offset(accidental))
def pitch_midi(pitch: dict[str, Any]) -> int:
octave = int(pitch.get("octave", 4))
return octave * 12 + pitch_class(pitch)
def octave_for(midi: int, pitch_class_value: int) -> int:
octave = (midi - pitch_class_value) // 12
while octave * 12 + pitch_class_value > midi:
octave -= 1
while (octave + 1) * 12 + pitch_class_value <= midi:
octave += 1
return octave
def matching_source_degree_for_pitch(pitch: dict[str, Any], source: KeyProfile) -> int | None:
actual = pitch_class(pitch)
source_letter_degree = LETTER_DEGREES.get(str(pitch.get("letter", "c")).lower(), 0)
for index, spelling in enumerate(source.degrees):
letter, _ = split_spelling(spelling)
if LETTER_DEGREES.get(letter.lower(), 0) == source_letter_degree and spelling_pitch_class(spelling) == actual:
return index
return None
def chromatic_spelling(pitch_class_value: int, prefer_flats: bool) -> str:
flats = ("C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B")
sharps = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")
return flats[pitch_class_value] if prefer_flats else sharps[pitch_class_value]
def transpose_pitch(pitch: dict[str, Any], source: KeyProfile, target: KeyProfile, interval: int) -> dict[str, Any]:
transposed_midi = pitch_midi(pitch) + interval
degree = matching_source_degree_for_pitch(pitch, source)
spelling = target.degrees[degree] if degree is not None else chromatic_spelling(mod12(transposed_midi), target.prefer_flats)
letter, accidental = split_spelling(spelling)
pc = spelling_pitch_class(spelling)
return {
"letter": letter.lower(),
"accidental": accidental or "n",
"octave": octave_for(transposed_midi, pc),
}
def parse_chord_root(value: str, offset: int) -> tuple[int, str] | None:
if offset >= len(value) or value[offset].upper() not in "ABCDEFG":
return None
length = 1
if offset + length < len(value) and value[offset + length] in {"#", "b"}:
length += 1
return length, value[offset:offset + length]
def matching_source_degree_for_root(root: str, source: KeyProfile) -> int | None:
root_letter, _ = split_spelling(root)
actual = spelling_pitch_class(root)
for index, spelling in enumerate(source.degrees):
letter, _ = split_spelling(spelling)
if LETTER_DEGREES.get(letter.lower(), 0) == LETTER_DEGREES.get(root_letter.lower(), 0) and spelling_pitch_class(spelling) == actual:
return index
return None
def transpose_root(root: str, source: KeyProfile, target: KeyProfile, interval: int) -> str:
degree = matching_source_degree_for_root(root, source)
if degree is not None:
return target.degrees[degree]
return chromatic_spelling(mod12(spelling_pitch_class(root) + interval), target.prefer_flats)
def transpose_chord_symbol(symbol: str, source: KeyProfile, target: KeyProfile, interval: int) -> str:
root = parse_chord_root(symbol, 0)
if root is None:
return symbol
root_length, root_text = root
result = transpose_root(root_text, source, target, interval) + symbol[root_length:]
slash = result.find("/")
if slash < 0:
return result
bass = parse_chord_root(result, slash + 1)
if bass is None:
return result
bass_length, bass_text = bass
return result[:slash + 1] + transpose_root(bass_text, source, target, interval) + result[slash + 1 + bass_length:]
def transpose_document(document: dict[str, Any], target_key_name: str) -> dict[str, Any]:
output = copy.deepcopy(document)
metadata = output.setdefault("metadata", {})
if not isinstance(metadata, dict):
raise ValueError("score metadata must be an object")
source_key = find_key(str(metadata.get("key_signature", "C") or "C"))
target_key = find_key(target_key_name)
interval = signed_interval(source_key, target_key)
metadata["key_signature"] = target_key.name
for staff in output.get("staves", []):
if not isinstance(staff, dict):
continue
for measure in staff.get("measures", []):
if not isinstance(measure, dict):
continue
measure["key_signature"] = target_key.name
for voice in measure.get("voices", []):
if not isinstance(voice, dict):
continue
for note in voice.get("notes", []):
if not isinstance(note, dict):
continue
chord = note.get("chord_symbol")
if isinstance(chord, str) and chord:
note["chord_symbol"] = transpose_chord_symbol(chord, source_key, target_key, interval)
pitches = note.get("pitches")
if isinstance(pitches, list):
note["pitches"] = [transpose_pitch(pitch, source_key, target_key, interval) if isinstance(pitch, dict) else pitch for pitch in pitches]
def shift_pitch(pitch: dict, steps: int, semitones: int) -> dict:
output = copy.deepcopy(pitch)
original_octave = pitch.get('octave', 4)
diatonic = original_octave * 7 + LETTERS.index(pitch['letter']) + steps
octave, index = divmod(diatonic, 7)
output['letter'] = LETTERS[index]
output['octave'] = octave
output['alter'] = pitch_number({**pitch, 'octave':original_octave}) + semitones - (12 * (octave+1) + NATURAL[output['letter']])
if 'octave' not in pitch:
output.pop('octave')
if 'accidental_display' in output:
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')
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
+12
View File
@@ -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;
}
+10
View File
@@ -18,6 +18,10 @@ enum class score_accidental_mark {
FLAT,
NATURAL,
SHARP,
DOUBLE_FLAT,
DOUBLE_SHARP,
TRIPLE_FLAT,
TRIPLE_SHARP,
};
enum class score_layout_item_kind {
@@ -48,6 +52,8 @@ enum class score_layout_item_role {
TIE,
TUPLET,
TUPLET_BRACKET,
OCTAVE_SHIFT,
TREMOLO,
TITLE,
COMPOSER,
PART,
@@ -71,6 +77,7 @@ struct score_layout_item {
std::float32_t staff_spacing{};
std::int32_t codepoint{};
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 measure_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 {
std::size_t first_measure{};
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};
@@ -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_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 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;
}
+86 -2
View File
@@ -2,6 +2,8 @@
#include <cstddef>
#include <cstdint>
#include <array>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
@@ -10,6 +12,41 @@
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 {
FAKE,
REAL,
@@ -19,14 +56,23 @@ struct score_pitch {
std::char_t letter{'c'};
std::int32_t octave{};
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 {
std::string id{};
std::string kind{};
std::bool_t hidden{};
std::int64_t duration_ticks{};
std::int64_t display_ticks{};
double duration_ticks{};
double display_ticks{};
std::string notehead{};
std::string chord_symbol{};
std::string lyric{};
@@ -34,12 +80,34 @@ struct score_note {
std::bool_t tie_from_previous{};
std::string tuplet_id{};
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 {
std::string id{};
std::int32_t note_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 {
@@ -49,6 +117,21 @@ struct score_measure {
std::string key_signature{};
std::vector<vamp_zero::score_note> notes{};
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 {
@@ -63,6 +146,7 @@ struct score_document {
std::string source_path{};
std::string source_json{};
std::vector<vamp_zero::score_measure> measures{};
std::vector<score_octave_shift> octave_shifts{};
};
struct library_part {
+21
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import json
import hashlib
import os
import pathlib
import shlex
@@ -136,6 +137,9 @@ RUN python -m pip install --no-cache-dir conan=={CONAN_VERSION}
WORKDIR /workspace
"""
def build_requirements_digest():
return hashlib.sha256((repo_root() / "tools/requirements.txt").read_bytes()).hexdigest()
def product_build_dockerfile():
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 \\
conan=={CONAN_VERSION} -r /tmp/vamp-build-requirements.txt
LABEL org.khodo.product-vamp.build-requirements="{build_requirements_digest()}"
WORKDIR /workspace
RUN mkdir -p /opt && touch /opt/modulus-build-image
@@ -482,6 +488,19 @@ def delegate(command, argv):
ensure_runtime(root)
sys.path.insert(0, str(root / RUNTIME_DIR))
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
def docker_run_with_host_socket(context, command, docker_args=None, env_overrides=None):
@@ -502,6 +521,8 @@ def delegate(command, argv):
try:
return modulus.cli.main(command)
finally:
modulus.cli.docker_image_available = original_image_available
modulus.cli.docker_run = original_docker_run
os.chdir(previous_cwd)
sys.argv = previous_argv
+2
View File
@@ -362,6 +362,7 @@ ON CONFLICT(id) DO UPDATE SET
THEN excluded.updated_at_ms
ELSE songs.updated_at_ms
END
WHERE songs.catalog_managed = 1
)sql",
std::move(params));
if (!inserted) {
@@ -417,6 +418,7 @@ ON CONFLICT(id) DO UPDATE SET
THEN excluded.updated_at_ms
ELSE practice_exercises.updated_at_ms
END
WHERE practice_exercises.catalog_managed = 1
)sql",
std::move(params));
if (!inserted) {
+4 -309
View File
@@ -20,6 +20,7 @@
#include <vamp_zero/errors.hpp>
#include <vamp_zero/score.hpp>
#include <vamp_zero/notation.hpp>
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));
}
auto required_string(boost::json::object const& object, std::char_t const* field) -> std::expected<std::string, modulus::error> {
auto found = object.find(field);
if (found == object.end() || !found->value().is_string()) {
return std::unexpected(invalid(std::string{field} + " must be a string"));
}
auto value = std::string{found->value().as_string()};
if (value.empty()) {
return std::unexpected(invalid(std::string{field} + " must not be empty"));
}
return value;
}
auto optional_string_value(boost::json::object const& object, std::char_t const* field, std::string fallback = {}) -> std::expected<std::string, modulus::error> {
auto found = object.find(field);
if (found == object.end()) {
return fallback;
}
if (!found->value().is_string()) {
return std::unexpected(invalid(std::string{field} + " must be a string"));
}
return std::string{found->value().as_string()};
}
auto optional_bool_value(boost::json::object const& object, std::char_t const* field, std::bool_t fallback = false) -> std::expected<std::bool_t, modulus::error> {
auto found = object.find(field);
if (found == object.end()) {
return fallback;
}
if (!found->value().is_bool()) {
return std::unexpected(invalid(std::string{field} + " must be a boolean"));
}
return found->value().as_bool();
}
auto optional_int64_value(boost::json::object const& object, std::char_t const* field, std::int64_t fallback = 0) -> std::expected<std::int64_t, modulus::error> {
auto found = object.find(field);
if (found == object.end()) {
return fallback;
}
if (found->value().is_int64()) {
return found->value().as_int64();
}
if (found->value().is_uint64()) {
auto value = found->value().as_uint64();
if (value <= static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max())) {
return static_cast<std::int64_t>(value);
}
}
return std::unexpected(invalid(std::string{field} + " must be an integer"));
}
auto require_object(boost::json::value const& value, std::string_view label) -> std::expected<boost::json::object const*, modulus::error> {
if (!value.is_object()) {
return std::unexpected(invalid(std::string{label} + " must be an object"));
}
return &value.as_object();
}
auto require_array(boost::json::object const& object, std::char_t const* field) -> std::expected<boost::json::array const*, modulus::error> {
auto found = object.find(field);
if (found == object.end() || !found->value().is_array()) {
return std::unexpected(invalid(std::string{field} + " must be an array"));
}
return &found->value().as_array();
}
auto human_label(std::string value) -> std::string {
if (value.empty()) {
return "Score";
@@ -116,164 +51,6 @@ auto practice_type_label(std::string const& value) -> std::string {
return human_label(value) + " Practice";
}
auto generated_source_id(std::string const& source_path, std::string const& title, std::string const& part) -> std::string {
if (!source_path.empty()) {
auto stem = std::filesystem::path{source_path}.stem().string();
if (!stem.empty()) {
return stem;
}
}
auto value = title + "-" + part;
for (auto& ch : value) {
if (ch >= 'A' && ch <= 'Z') {
ch = static_cast<std::char_t>(ch + ('a' - 'A'));
} else if (!((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9'))) {
ch = '-';
}
}
return value.empty() ? "score" : value;
}
auto parse_pitch(boost::json::value const& value) -> std::expected<vamp_zero::score_pitch, modulus::error> {
auto object = require_object(value, "pitch");
if (!object) {
return std::unexpected(object.error());
}
auto letter = optional_string_value(**object, "letter", "c");
auto octave = optional_int64_value(**object, "octave", 4);
auto accidental = optional_string_value(**object, "accidental", {});
if (!letter || !octave || !accidental) {
return std::unexpected(!letter ? letter.error() : (!octave ? octave.error() : accidental.error()));
}
return vamp_zero::score_pitch{
.letter = letter->empty() ? 'c' : (*letter)[0],
.octave = static_cast<std::int32_t>(*octave),
.accidental = std::move(*accidental),
};
}
auto parse_note(boost::json::value const& value) -> std::expected<vamp_zero::score_note, modulus::error> {
auto object = require_object(value, "note");
if (!object) {
return std::unexpected(object.error());
}
auto id = optional_string_value(**object, "id", {});
auto kind = optional_string_value(**object, "kind", "note");
auto hidden = optional_bool_value(**object, "hidden", false);
auto duration = optional_int64_value(**object, "duration_ticks", 0);
auto display = std::expected<std::int64_t, modulus::error>{0};
if (duration) {
display = optional_int64_value(**object, "display_ticks", *duration);
}
auto notehead = optional_string_value(**object, "notehead", {});
auto chord = optional_string_value(**object, "chord_symbol", {});
auto lyric = optional_string_value(**object, "lyric", {});
auto tie_to_next = optional_bool_value(**object, "tie_to_next", false);
auto tie_from_previous = optional_bool_value(**object, "tie_from_previous", false);
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 error = std::error_code{};
if (!std::filesystem::exists(directory, error)) {
@@ -334,7 +111,8 @@ auto key_rank(std::string const& key) -> std::size_t {
"Bb",
"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));
}
@@ -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 error = boost::system::error_code{};
auto parsed = boost::json::parse(json, error);
if (error) {
return std::unexpected(invalid("score JSON is invalid: " + error.message()));
}
auto root = require_object(parsed, "score root");
if (!root) {
return std::unexpected(root.error());
}
auto metadata_value = (*root)->find("metadata");
if (metadata_value == (*root)->end()) {
return std::unexpected(invalid("score metadata is required"));
}
auto metadata = require_object(metadata_value->value(), "metadata");
if (!metadata) {
return std::unexpected(metadata.error());
}
auto title = required_string(**metadata, "title");
auto composer = optional_string_value(**metadata, "composer", {});
auto key = optional_string_value(**metadata, "key_signature", {});
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;
return vamp_zero::parse_notation_document(json, std::move(source_path));
}
auto vamp_zero::group_library_parts(std::vector<vamp_zero::library_part> parts) -> vamp_zero::library_state {
+347
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -60,6 +60,19 @@ struct vamp_zero::score_draw_cache {
}
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 {
@@ -353,7 +366,7 @@ auto draw_score(luma::draw_context& context, luma::rect bounds, vamp_zero::app_s
.width = bounds.width - 48.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 {
+7 -2
View File
@@ -11,15 +11,19 @@ target_link_libraries(product-vamp-test PRIVATE
target_compile_features(product-vamp-test PRIVATE cxx_std_23)
target_compile_definitions(product-vamp-test PRIVATE
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_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)
add_test(
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}
)
set_tests_properties(product-vamp-generator PROPERTIES TIMEOUT 60)
set_tests_properties(product-vamp-generator PROPERTIES TIMEOUT 180)
add_custom_target(check
COMMAND ${CMAKE_CTEST_COMMAND}
--test-dir ${CMAKE_BINARY_DIR}
@@ -28,3 +32,4 @@ add_custom_target(check
--timeout 60
USES_TERMINAL
)
add_dependencies(check product-vamp-test)
+41
View File
@@ -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>
+47
View File
@@ -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
View File
@@ -24,6 +24,8 @@
#include <boost/asio/use_future.hpp>
#include <boost/json/object.hpp>
#include <boost/json/parse.hpp>
#include <boost/json/serialize.hpp>
#include <boost/json/array.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 {
return std::string{R"json({
"metadata": {
"title": ")json"} + title + R"json(",
"composer": "Composer",
"key_signature": ")json" + key + R"json(",
"time_signature": "4/4"
},
"staves": [
{
"id": "staff-lead",
"name": "Lead",
"clef": "treble",
"role": "lead",
"measures": [
{
"id": "measure-1",
"time_signature": "4/4",
"key_signature": ")json" + key + R"json(",
"voices": [
{
"id": "voice-1",
"notes": [
{
"id": "note-1",
"kind": "note",
"duration_ticks": 4096,
"pitches": [
{"letter": "c", "octave": 4, "accidental": "n"}
]
}
]
}
]
}
]
}
]
}
)json";
auto value = boost::json::parse(R"json({
"schema_version":2,
"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"},
"staves":[{"id":"staff-lead","name":"Lead","role":"lead","clef":{"sign":"G","line":2,"octave_change":0},
"measures":[{"id":"measure-1","duration":{"numerator":4,"denominator":1},"key_signature":"C","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},
"voices":[{"id":"voice-1","notes":[{"id":"note-1","kind":"note",
"onset":{"numerator":0,"denominator":1},"duration":{"numerator":4,"denominator":1},
"written":{"type":"whole","dots":0},
"pitches":[{"id":"pitch-1","letter":"c","octave":4,"alter":0,
"duration":{"numerator":4,"denominator":1},"written":{"type":"whole","dots":0}}]}]}]}]}]
})json");
auto& root = value.as_object();
root["metadata"].as_object()["title"] = title;
root["metadata"].as_object()["key_signature"] = key;
root["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["key_signature"] = key;
return boost::json::serialize(value);
}
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) {
auto parsed = vamp_zero::parse_score_document(R"json(
{
"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));
auto parsed = vamp_zero::parse_score_document(score_asset_json("Standard Tune", "Eb"), "standard-tune.json");
BOOST_REQUIRE(static_cast<bool>(parsed));
BOOST_TEST(parsed->title == "Standard Tune");
BOOST_TEST(parsed->composer == "Composer");
BOOST_TEST(parsed->key_signature == "Eb");
BOOST_TEST(parsed->time_signature == "3/4");
BOOST_TEST(parsed->clef == "treble");
BOOST_TEST(parsed->time_signature == "4/4");
BOOST_TEST(parsed->clef == "G:2:0");
BOOST_TEST(parsed->part == "Lead");
BOOST_REQUIRE_EQUAL(parsed->measures.size(), 1U);
BOOST_TEST(parsed->measures.front().key_signature == "Eb");
BOOST_TEST(parsed->measures.front().time_signature == "3/4");
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_TEST(parsed->measures.front().tuplets.front().id == "triplet-1");
BOOST_TEST(parsed->measures.front().tuplets.front().note_count == 3);
BOOST_TEST(parsed->measures.front().tuplets.front().space_count == 2);
BOOST_TEST(parsed->measures.front().duration.numerator == 4);
BOOST_TEST(parsed->measures.front().notes.front().written.type == "whole");
}
BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) {
auto count = std::size_t{};
for (auto const& entry : std::filesystem::recursive_directory_iterator{VAMP_TEST_ASSETS_DIR}) {
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) {
@@ -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")}});
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);
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");
@@ -919,7 +1014,7 @@ BOOST_AUTO_TEST_CASE(score_page_count_uses_forced_notation_breaks) {
.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) {
+34
View File
@@ -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)
+139
View File
@@ -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'])
+188
View File
@@ -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))
-219
View File
@@ -8,11 +8,6 @@ import tempfile
from pathlib import Path
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
@@ -42,217 +37,3 @@ class FontAssetsTests(unittest.TestCase):
self.assertEqual(before, output.stat().st_mtime_ns)
output.write_text("stale", encoding="utf-8")
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()
+38
View File
@@ -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)
+2
View File
@@ -1 +1,3 @@
Pillow==12.3.0
defusedxml==0.7.1
fonttools==4.63.0