Data Labels
ci / checks (push) Failing after 3h12m18s

This commit is contained in:
khodowany
2026-09-20 02:07:26 -05:00
parent 5fc2103810
commit 7bb1ed2b13
24 changed files with 2334 additions and 2563 deletions
+5 -3
View File
@@ -82,7 +82,7 @@ Artifacts are published to the khodo generic package registry.
## Score Assets
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:
Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or 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 --check
@@ -91,9 +91,11 @@ python3 -m generator --data-dir data --output-dir /tmp/product-vamp-generated --
The `--check` path compares generation invariants against `data/expected/performance_summary.json`.
The pinned build-only dependencies are in `generator/requirements.txt`. Configure/build automatically refresh the project build image when this file changes; direct builds must install it in their Python environment.
The build-time pipeline is MuseScore -> `.xml` -> musical model -> automatic arranger/transposer -> schema-v3 JSON, engraved PNG pages, and generated-tone audio assets. The Pi displays pages without interpreting notation. Sources in `data/songs/` produce acoustic, electric, and vocal parts in the authored musical key, with no song-key selection. Numbered practice sources `data/001.xml` through `data/366.xml` produce twelve tonic variants each relative to the MusicXML key. Their current C-major placeholders are included; identical titles or contents never merge exercises. Independent voices and exact pitch/rhythm remain in the semantic JSON. Practice preserves the authored voicings, shifting the entire exercise by an octave only when needed for guitar. Practice and generated guitar voicings use standard tuning, frets 0-20 inclusive, and a maximum four-fret span excluding open strings.
The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model -> automatic arranger/transposer -> schema-v3 JSON, engraved PNG pages, and generated-tone audio assets. The Pi displays pages without interpreting notation. Sources in `data/songs/` produce acoustic, electric, and vocal parts in the authored musical key, with no song-key selection. Numbered practice sources `data/001.xml` through `data/366.xml` produce twelve tonic variants each relative to the MusicXML key. Their current C-major placeholders are included; identical titles or contents never merge exercises. Independent voices and exact pitch/rhythm remain in the semantic JSON. Practice preserves the authored voicings, shifting the entire exercise by an octave only when needed for guitar. Practice and generated guitar voicings use standard tuning, frets 0-20 inclusive, and a maximum four-fret span excluding open strings.
Practice notation supports repeat bars and numbered endings. Python expands the performed order for playback and before automatic performance arrangement. The main menu's **Playback** toggle plays the selected key variant or performance part at the menu BPM, suppresses the metronome, and returns to its selection menu after natural completion. Manual page turns do not seek or restart audio. Playback is off by default. Python generates shared PCM tones and exact beat timelines; C++/Sonus only loads, schedules, and mixes them. No SoundFonts or runtime synthesizer are required. Practice opens the numbered exercise list directly, followed by key selection and score view. There are no chord/melody categories or lesson menus. Each exercise retains its filename number alongside an authored title when present.
Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The seven-section performance arrangements remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final arrangement ending.
The main menu's **Playback** toggle plays the selected key variant or performance part once at the menu BPM, suppresses the metronome, and returns to its selection menu after natural completion. Manual page turns do not seek or restart audio. Playback is off by default. Python generates shared PCM tones and exact beat timelines; C++/Sonus only loads, schedules, and mixes them. Audio sample sustain loops hold individual notes for their written duration; they never repeat musical passages. No SoundFonts or runtime synthesizer are required. Practice opens the numbered exercise list directly, followed by key selection and score view. There are no chord/melody categories or lesson menus. Each exercise retains its filename number alongside an authored title when present.
Code style is managed by Modulus. Run `./modulus.py format` to format C++ and Python, or `./modulus.py format --check` to check formatting and prohibited aliases. The project-local Neovim integration routes Conform formatting through the same container tools, including format on save and existing LazyVim toggles. Run configure first when the image or exported tooling needs updating. Use `tooling.editor.clang_format` for C++ overrides and `tooling.format.exclude` for project-relative exclusion globs; do not edit generated `.clang-format` files.
+10 -8
View File
@@ -49,7 +49,7 @@ The build runs `python3 -m generator` against `data/` and writes build-only asse
Practice sources live directly under `data/` with positive three-digit filenames, currently `001.xml` through `366.xml`. For example, `data/001.xml` generates `practice/001/{c,db,d,eb,e,f,gb,g,ab,a,bb,b}.json` and corresponding page directories. Performance sources stay in `data/songs/`. The filename number determines identity and ordering; MusicXML work/movement titles determine labels, falling back to the number when absent. Other root XML names and nested practice sources are rejected. Adding, renaming, or removing a numbered source requires no registry edit; the next build replaces obsolete generated assets and rebuilds the database. The current catalogue has 366 exercises, 4,392 practice variants, and six performance parts.
The pipeline is MuseScore -> uncompressed `.xml` -> musical model -> automatic arranger/transposer -> semantic JSON plus engraved pages. Export MusicXML 3.1 or 4.0, `score-partwise`, with one part and one staff. Independent rhythmic voices remain separate; simultaneous chord tones retain their own spelling, written duration, and tie links. The packaged music describes each transformed part or practice variant, not an archival copy of the source XML.
The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model -> automatic arranger/transposer -> semantic JSON plus engraved pages. Export MusicXML 3.1 or 4.0, `score-partwise`, with one part and one staff. Independent rhythmic voices remain separate; simultaneous chord tones retain their own spelling, written duration, and tie links. The packaged music describes each transformed part or practice variant, not an archival copy of the source XML.
### MusicXML Contract
@@ -65,13 +65,15 @@ The pipeline is MuseScore -> uncompressed `.xml` -> musical model -> automatic a
| 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, multiple lyric verses with syllabification, elisions, and extenders, rehearsal labels at measure starts, and Vamp directives. |
| Tempo | Metronome directions and positive quarter-note BPM from `<sound tempo="...">`, either within a direction or directly in a measure. Playback/navigation attributes on `sound` are rejected. |
| Repeats | Left forward and right backward bar repeats, explicit repeat counts, implicit start at the beginning, sequential/nested repeat groups, and contiguous numbered endings. |
| Reading order | All measures are written out. Repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. Ordinary barlines and contiguous ties remain supported. |
Multiple staves/parts, D.C./D.S./coda/fine navigation, slurs, dynamics, articulations, and expressive ornaments are outside the product contract and rejected. Unknown musical elements also fail. Serialized times and alterations use reduced signed-64-bit numerators and positive denominators; pitch octaves are -4 through 16, clef/octave shifts span at most three octaves, and meter counts are 1-1024 with denominator units 1-4096. These resource bounds produce diagnostics, not clamping or truncation.
Layout/exporter information such as page defaults, credits, print layout, font/color/position attributes, and source pagination does not determine the small-screen layout. Engraving is regenerated. Beam groups and written note values are retained; unrelated engraving metadata does not authorize ignoring musical descendants. `defusedxml==0.7.1` forbids XML entities and external access. Ordinary MusicXML DOCTYPE declarations are allowed without fetching their DTDs.
Chord display text is not a substitute for structured harmony. Some Soundslice exports encode minor chord labels as `<kind text="-7">other</kind>` without any degrees; these fail import. Use `minor-seventh` for `-7`, `half-diminished` for `-7b5`, `major-minor` for `-maj7`, and `minor-sixth` for `-6`, keeping the `text` attribute if desired. MusicXML [`other`](https://www.w3.org/2021/06/musicxml40/musicxml-reference/data-types/kind-value/) requires explicit added `<degree>` elements defining the chord. Correct these exports before placing them in `data/`; the importer never guesses pitches from an incomplete chord label.
Chord labels are generated from structured harmony using `M`, `m`, `aug`, `dim`, and `sus`: for example `CM`, `Cm7`, `CM9`, `Caug`, `Cdim7`, `Csus`, `Csus2`, and `Cm7b5`. `CmM7` is a minor triad with a major seventh, corresponding to MusicXML's `major-minor` kind. Ninths, elevenths, and thirteenths include their underlying seventh; `Cmadd9` does not. Slash basses, degree alterations, added tones, and omissions remain semantic, not display-only. Older recognized spellings such as `-7`, `maj7`, and `+` are accepted but rendered canonically.
Standard MusicXML kinds and explicit degrees remain authoritative. For Soundslice compatibility, a text-only `<kind text="mM7">other</kind>` (also `m7b5`, `7b9`, and other recognized chord spellings) is normalized into a standard kind and degrees. Complete symbols such as `(Fm7)` or `(Bb7)` supply their own root; an exporter placeholder C is accepted only for text-only `other` harmonies. A conflicting non-placeholder root, conflicting bass, contradictory structured chord, or unknown spelling is an error with source/measure context. Raw exporter text is not retained as an override that could become stale during transposition. This compatibility path does not require editing the XML or weakening structured-harmony validation.
### Vamp Metadata
@@ -85,15 +87,15 @@ In MuseScore's score properties, set `vamp:style` to a supported player style. I
The default is Medium Swing; `Med.`, `Med. Swing`, and `Medium` remain aliases. A keyless exercise needs `vamp:tonic` (for example, `C`) as its transposition reference. Modes and local key changes are preserved when generating practice variants. Keyless variants remain unkeyed in the music and show their selected transposition reference in menus, for example `D (no key)`.
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.
Parenthesized chord symbols mark optional turnarounds, including Soundslice's text-only `(Fm7)` and `(Bb7)` exports. Alternatively, place staff text `vamp:optional-chord` at the same onset as exactly one harmony; this text may be hidden. Earlier arrangement sections retain those chords and their parentheses; the final ending omits them. The normalized harmony stores `parenthesized` display metadata separately from the generator-only `chord_optional` flag. Orphaned/duplicate markers and unknown `vamp:` properties are errors; consumed directives never enter runtime JSON.
### Repeats
### Linear Reading Order
`generator/repeats.py` is the shared repeat analyzer and expander. Practice keeps compact authored bars and ending brackets; the engraver draws repeat dots, combined boundaries, repeat-count labels, and brackets continued across systems. Performance sources unfold before the existing seven-section arranger. Playback and guitar-feasibility checks use that same performed order, so skipped endings do not become extra notes.
There is no repeat analyzer, expansion pass, or repeat/ending engraver. Source measures feed practice transposition, guitar validation, and playback in authored order. Performance songs still use the existing seven-section arranger, which writes every section explicitly onto successive pages; neither display nor playback jumps backward through those pages.
Ending numbers are comma-separated positive passes, such as `1,2` followed by `3`. The first ending terminates at the backward repeat; following endings are contiguous, each pass belongs to exactly one ending, and the final ending contains the last pass. `stop` closes the bracket; `discontinue` leaves it open. Hidden brackets still affect performance. Explicit repeat counts must agree with ending passes. Crossing or unterminated groups, overlapping endings, middle-measure repeat marks, winged repeats, and navigation attributes are rejected. Nesting is bounded to 16 levels and unfolding to 10,000 measures.
Repeat signs, numbered endings, D.C./D.S./coda navigation, and `time-only` ties fail import rather than being ignored or expanded. Write the intended notes and rests explicitly, including any alternate ending. Legacy repeat fields in generated musical documents are rejected; regenerate assets instead of retaining a compatibility interpreter. Full leading-rest measures remain full measures, not inferred pickups. Missing titles fall back to filenames and missing composer credits remain empty, as with the current Soundslice exports.
Repeated notation retains separate sounding `<tie>` and printed `<tied>` markers. `time-only` selects passes in the innermost repeat, and each active sounding endpoint must match on the performed path. Use pass-specific ties when tying across a backward jump; dangling or unreachable endpoints fail rather than sustaining the wrong pitch. Expansion assigns fresh IDs and remaps ties, tuplets, and paired tremolos. A jump that splits a paired tremolo is rejected. Backward jumps restore staff and harmony state; skipped endings do not leak key changes into later passes. See the MusicXML references for [repeats](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/repeat/) and [sounding ties](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/tie/).
Ordinary ties still link adjacent matching tones across measures and systems. Tuplets, independent voices, local staff changes, and manual page navigation are unchanged. Audio sample sustain loops remain an implementation detail for holding a single note, not a score-repeat feature.
### Runtime Schema
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -90,8 +90,7 @@ def arrange_document(document: dict, part_type: str) -> dict:
specialized_notes = any('unpitched' in pitch or 'tablature' in pitch for note in measure_notes(measure) for pitch in note['pitches'])
if specialized_staff or specialized_notes:
raise ValueError(f'{measure["id"]}: automatic performance arranging requires a pitched non-TAB lead; percussion/TAB conversion is not qualified')
from generator.repeats import expand_document
document = concert_document(expand_document(document))
document = concert_document(document)
staff = source_staff(document)
source = staff['measures']
sections = full_arrangement_sections(source, part_type)
+1 -19
View File
@@ -58,22 +58,6 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
raise EngravingError(f'{item.sources} and {other.sources}: colliding {item.role}/{other.role}')
if previous_end != index.end:
raise EngravingError('layout omits the end of the score')
from generator.repeats import analyze
_, endings = analyze(index.staff['measures'])
for ending in endings:
if not ending.visible:
continue
identifier = index.measures[ending.start][2]['id'] + ':ending'
if not any(p.role == 'ending-label' and p.value == ending.text for p in primitives.get(identifier, [])):
raise EngravingError(f'{identifier}: numbered ending was omitted')
low, high = index.measures[ending.start][0], index.measures[ending.end - 1][1]
for page in layout.pages:
for system in page.systems:
if system.start < high and system.end > low and not any(p.role == 'ending' and identifier in p.sources for p in system.primitives):
raise EngravingError(f'{identifier}: ending continuation was omitted')
for _, _, measure in index.measures:
if measure.get('repeats') and not any(p.role == 'repeat-dot' for p in primitives.get(measure['id'], [])):
raise EngravingError(f'{measure["id"]}: repeat dots were omitted')
for event in index.events:
note = event.value
for source in note['pitches'] or [note]:
@@ -101,7 +85,7 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
if any(f.visible for f in pieces) and identifier in index.tones and index.accidentals[identifier]:
if not any(p.role == 'accidental' for p in primitives.get(identifier, [])):
raise EngravingError(f'{identifier}: required accidental was omitted')
if identifier in index.tones and (source.get('tie_to') or source.get('printed_ties')) and any(f.visible for f in pieces):
if identifier in index.tones and source.get('tie_to') and any(f.visible for f in pieces):
if not any(p.role == 'tie' for p in primitives.get(identifier, [])):
raise EngravingError(f'{identifier}: tie was omitted')
if not note.get('hidden'):
@@ -113,8 +97,6 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
for _, _, measure in index.measures:
for value in measure['harmonies']:
label = harmony.symbol(value)
if value.get('chord_optional'):
label = '(' + label + ')'
if not any(p.role == 'harmony' and p.value == label for p in primitives.get(value['id'], [])):
raise EngravingError(f'{value["id"]}: harmony was omitted')
for i, _ in enumerate(measure.get('tempos', [])):
+5 -23
View File
@@ -10,7 +10,6 @@ from generator.engraving.attributes import barline, staff_attributes, tempo
from generator.engraving.fonts import Fonts
from generator.engraving.notation import ScoreIndex
from generator.engraving.notes import draw_event
from generator.engraving.repeats import draw_boundary, draw_endings
from generator.engraving.spans import draw_beams, draw_lyric_spans, draw_spans, place_text_lanes
from generator.engraving.types import Anchor, EngravingError, Fragment, LayoutDocument, Page, Primitive, Style, System, bounds, line
@@ -52,8 +51,6 @@ def column(index: ScoreIndex, time: Fraction, start: Fraction, end: Fraction, fo
for item in measure['harmonies']:
if measure_start + fraction(item['onset']) == time:
label = harmony.symbol(item)
if item.get('chord_optional'):
label = '(' + label + ')'
items.append(fonts.text(label, 0, 0, 21, 'harmony', (item['id'],)))
for i, value in enumerate(measure.get('tempos', [])):
if measure_start + fraction(value['onset']) == time:
@@ -71,12 +68,6 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
header = staff_attributes(index, state, start, fonts, style.space, measure['id'] + ':initial', ('clef', 'key', 'meter', 'staff_details'))
items.extend(header)
cursor = max((p.box.right for p in header), default=0) + 22
boundary_positions = {start: cursor - 9}
if start in index.boundaries and measure.get('repeats', {}).get('left'):
initial = draw_boundary(None, measure, cursor, -style.space * (state['staff_details']['lines'] - 1), fonts, style)
boundary_positions[start] = cursor
items.extend(initial)
cursor = max(p.box.right for p in initial) + 18
content_left = cursor - 9
times = sorted({start} | {t for t in index.columns if start < t < end})
placements = []
@@ -88,18 +79,15 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
for measure_start, measure_end, m in index.measures:
if time == measure_start and time > start:
previous = next(previous for a, b, previous in index.measures if b == time)
leading.extend(draw_boundary(previous, m, 0, -style.space * (index.state(time)['staff_details']['lines'] - 1), fonts, style))
leading.extend(barline(previous.get('barlines', {}).get('right', 'regular'), 0, -style.space * (index.state(time)['staff_details']['lines'] - 1), previous['id']))
old_state = index.state(time, before=True)
fields = tuple(k for k in ('clef', 'key', 'meter', 'staff_details') if old_state[k] != index.state(time)[k])
offset = max((p.box.right for p in leading), default=0) + 12 if previous.get('repeats') or m.get('repeats') else 12
leading.extend(p.moved(offset, 0) for p in staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + ':initial', fields))
leading.extend(p.moved(12, 0) for p in staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + ':initial', fields))
for ci, change in enumerate(m.get('attributes', [])):
if measure_start + fraction(change['onset']) == time and time > start:
extra = staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + f':attributes:{ci}', tuple(change))
leading.extend(p.moved(max((p.box.right for p in leading), default=0) + 10, 0) for p in extra)
if leading:
if time in index.boundaries:
boundary_positions[time] = cursor - bounds(leading).left
width = bounds(leading).right - bounds(leading).left + 14
items.extend(p.moved(cursor - bounds(leading).left, 0) for p in leading)
cursor += width
@@ -121,18 +109,13 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
items[first_item:last_item] = [p.moved(i * stretch, 0) for p in items[first_item:last_item]]
anchors[first_anchor:last_anchor] = [a.moved(i * stretch, 0) for a in anchors[first_anchor:last_anchor]]
placements = [(time, x + i * stretch) for i, (time, x) in enumerate(placements)]
boundary_positions = {time: x + times.index(time) * stretch for time, x in boundary_positions.items()}
width = available
right = width - 5
boundary_positions[end] = width - 3
last_measure = next(m for a, b, m in index.measures if a < end <= b)
if end in index.boundaries and last_measure.get('repeats', {}).get('right'):
items.extend(draw_boundary(last_measure, None, width - 3, -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), fonts, style))
items = draw_beams(index, anchors, items, start, end, content_left, right, style)
items = draw_spans(index, anchors, items, start, end, content_left, right, fonts, style)
items = place_text_lanes(items, fonts, style)
items = draw_lyric_spans(index, items, anchors, start, end, content_left, right, fonts)
items = draw_endings(index, items, start, end, boundary_positions, fonts, style)
# Staff lines are interrupted behind TAB numerals, not painted through them.
changes = [(0, state['staff_details']['lines'])]
for time, x in placements:
@@ -151,11 +134,10 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts,
staff_lines.append(line('staff', (), ((cursor, y), (a, y)), style.space * 0.13))
cursor = max(cursor, b)
items = staff_lines + items
if end in index.boundaries and not last_measure.get('repeats', {}).get('right'):
items.extend(draw_boundary(last_measure, None, width - (3 if last_measure.get('repeats') else 1), -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), fonts, style))
if end in index.boundaries:
items.extend(barline(last_measure.get('barlines', {}).get('right', 'regular'), width - 1, -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), last_measure['id']))
if start in index.boundaries:
if not measure.get('repeats', {}).get('left'):
items.extend(barline(measure.get('barlines', {}).get('left', 'none'), 0, -style.space * (state['staff_details']['lines'] - 1), measure['id']))
items.extend(barline(measure.get('barlines', {}).get('left', 'none'), 0, -style.space * (state['staff_details']['lines'] - 1), measure['id']))
else:
items.append(fonts.text(str(measure['number']) + ' (cont.)', 0, min(p.box.top for p in items) - 6, 12, 'continuation', (measure['id'],)))
return System(start, end, tuple(items), tuple(anchors), tuple(fragments), bounds(items))
-42
View File
@@ -1,42 +0,0 @@
from generator.engraving.attributes import barline
from generator.engraving.types import line
from generator.repeats import analyze
def draw_boundary(previous, following, x, top, fonts, style):
backward = previous.get('repeats', {}).get('right') if previous else None
forward = following.get('repeats', {}).get('left') if following else None
source = previous or following
if not backward and not forward:
value = previous.get('barlines', {}).get('right', 'regular') if previous else following.get('barlines', {}).get('left', 'none')
return barline(value, x, top, source['id'])
refs = tuple(m['id'] for m in (previous, following) if m)
result = [line('repeat-bar', refs, ((x, top), (x, 0)), 4)]
for marker, direction in ((backward, -1), (forward, 1)):
if not marker:
continue
result.append(line('repeat-bar', refs, ((x + direction * 6, top), (x + direction * 6, 0)), 1.4))
for offset in (-0.5, 0.5):
result.append(fonts.glyph('augmentationDot', x + direction * 13 - 2, top / 2 + style.space * offset, style.space, 'repeat-dot', refs))
if backward and backward.get('times', 2) != 2:
result.append(fonts.text(f'{backward["times"]} times', x - 10, top - 12, 14, 'repeat-count', refs, True))
return result
def draw_endings(index, items, start, end, positions, fonts, style):
_, endings = analyze(index.staff['measures'])
result = list(items)
boundaries = [a for a, _, _ in index.measures] + [index.end]
top = min((p.box.top for p in items), default=0) - 25
for ending in endings:
low, high = boundaries[ending.start], boundaries[ending.end]
if not ending.visible or high <= start or low >= end:
continue
x1 = positions[max(low, start)]
x2 = positions[min(high, end)]
refs = (index.measures[ending.start][2]['id'] + ':ending',)
result.append(line('ending', refs, ((x1, top), (x2, top))))
if low >= start:
result.append(line('ending', refs, ((x1, top), (x1, top + 10))))
result.append(fonts.text(ending.text, x1 + 5, top - 4, 16, 'ending-label', refs))
if high <= end and ending.closed:
result.append(line('ending', refs, ((x2, top), (x2, top + 10))))
return result
-14
View File
@@ -101,20 +101,6 @@ def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive],
target = tone.get('tie_to')
tone_end = event.start + fraction(tone['duration'])
anchor = by_tone.get(identifier)
if tone.get('printed_ties'):
for action in tone['printed_ties']:
candidates = [(other_id, other) for other_id, (other_event, other) in index.tones.items() if other_event.voice == event.voice and other_event.start == tone_end and 'stop' in other.get('printed_ties', []) and all(other.get(k) == tone.get(k) for k in ('letter', 'alter', 'octave', 'unpitched'))]
if action == 'start' and candidates:
target = candidates[0][0]
continue
if action == 'stop' and any(other_event.voice == event.voice and other_event.start + fraction(other['duration']) == event.start and 'start' in other.get('printed_ties', []) and all(other.get(k) == tone.get(k) for k in ('letter', 'alter', 'octave', 'unpitched')) for other_event, other in index.tones.values()):
continue
if anchor is None:
continue
x1, x2 = (anchor.x + style.space * 1.4, min(right, anchor.x + 36)) if action == 'start' else (max(left, anchor.x - 24), anchor.x - 2)
y = anchor.y - anchor.direction * 8
if x2 > x1:
result.append(curve('tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y - anchor.direction * 9), (x2 - (x2 - x1) / 3, y - anchor.direction * 9), (x2, y)), 1.6))
if anchor and anchor.fragment.end < tone_end:
target = identifier
if target:
+90 -28
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from generator.model import NATURAL, alteration, parse_pitch_name, pitch_name
import re
from generator.model import NATURAL, alteration, parse_pitch_name, pitch_name, rational
# Semantic degrees are independent of guitar voicing omissions.
KINDS = {
@@ -20,11 +22,27 @@ for family, base in (('dominant', 'dominant'), ('major', 'major-seventh'), ('min
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', '-')):
SUFFIXES = {'major': 'M', 'minor': 'm', 'augmented': 'aug', 'diminished': 'dim', 'dominant': '7', 'major-seventh': 'M7', 'minor-seventh': 'm7', 'diminished-seventh': 'dim7', 'augmented-seventh': 'aug7', 'augmented-major-seventh': 'augM7', 'half-diminished': 'm7b5', 'major-minor': 'mM7', 'major-sixth': 'M6', 'minor-sixth': 'm6', 'suspended-second': 'sus2', 'suspended-fourth': 'sus', 'power': '5', 'pedal': 'pedal', 'other': ''}
for family, prefix in (('dominant', ''), ('major', 'M'), ('minor', 'm')):
for suffix, number in (('ninth', '9'), ('11th', '11'), ('13th', '13')):
SUFFIXES[f'{family}-{suffix}'] = prefix + number
SPELLINGS = {suffix: kind for kind, suffix in SUFFIXES.items() if kind != 'other'}
SPELLINGS.update({'': 'major', 'maj': 'major', 'min': 'minor', '-': 'minor', '+': 'augmented', 'o': 'diminished', 'o7': 'diminished-seventh', '6': 'major-sixth', 'sus4': 'suspended-fourth', 'mM': 'major-minor', 'mMaj7': 'major-minor', 'mmaj7': 'major-minor', '-maj7': 'major-minor', '-7b5': 'half-diminished', '+7': 'augmented-seventh', '+maj7': 'augmented-major-seventh'})
for kind, suffix in SUFFIXES.items():
if suffix.startswith('M'):
SPELLINGS['maj' + suffix[1:]] = kind
if suffix.startswith('m') and not suffix.startswith('mM'):
SPELLINGS['-' + suffix[1:]] = kind
SPELLINGS['min' + suffix[1:]] = kind
EXTENSIONS = {f'mM{number}': ('major-minor', number) for number in (9, 11, 13)}
EXTENSIONS.update({f'{number}sus{suffix}': (kind, number) for number in (7, 9, 11, 13) for suffix, kind in (('', 'suspended-fourth'), ('2', 'suspended-second'), ('4', 'suspended-fourth'))})
def extended_chord(kind: str, highest: int) -> dict:
additions = [{'value': degree, 'alter': -1 if degree == 7 else 0, 'type': 'add'} for degree in range(7, highest + 1, 2) if degree not in KINDS[kind]]
return {'kind': kind, 'degrees': additions}
def degrees(harmony: dict) -> dict[int, int]:
kind = harmony['kind']
if kind not in KINDS:
@@ -50,7 +68,7 @@ def degrees(harmony: dict) -> dict[int, int]:
raise ValueError(f'unsupported degree operation: {operation}')
if kind != 'none' and not result:
if kind == 'other':
raise ValueError("harmony kind 'other' requires explicit added degrees; use a standard <kind> or add <degree> elements; display text alone does not define pitches")
raise ValueError("harmony kind 'other' requires explicit degrees or a recognized chord symbol")
raise ValueError('harmony contains no pitch information')
return result
@@ -67,43 +85,87 @@ def pitch_classes(harmony: dict) -> tuple[int, ...]:
values.append((NATURAL[bass['letter']] + alteration(bass['alter'])) % 12)
return tuple(dict.fromkeys(values))
def chord_suffix(harmony: dict) -> str:
values = degrees(harmony)
kind = harmony['kind']
if values == KINDS[kind]:
return SUFFIXES.get(kind, kind)
for name, suffix in SUFFIXES.items():
if name != 'other' and values == KINDS[name]:
return suffix
for suffix, (name, highest) in EXTENSIONS.items():
if values == degrees(extended_chord(name, highest)):
return suffix
suffix = SUFFIXES.get(kind, kind)
for change in sorted(harmony.get('degrees', []), key=lambda change: change['value']):
amount = alteration(change['alter'])
sign = '#' * max(int(amount), 0) + 'b' * max(int(-amount), 0) if amount.denominator == 1 else f'({amount})'
suffix += ('no' if change['type'] == 'subtract' else ('add' if change['type'] == 'add' and not sign else '') + sign) + str(change['value'])
return suffix
def symbol(harmony: dict) -> str:
if harmony['kind'] == 'none':
return 'N.C.'
suffix = harmony.get('kind_text', SUFFIXES.get(harmony['kind'], harmony['kind']))
if 'kind_text' not in harmony:
for d in harmony.get('degrees', []):
amount = alteration(d['alter'])
sign = '#' * max(int(amount), 0) + 'b' * max(int(-amount), 0) if amount.denominator == 1 else f'({amount})'
suffix += ('no' if d['type'] == 'subtract' else 'add' if d['type'] == 'add' and not sign else '') + sign + str(d['value'])
suffix = chord_suffix(harmony)
result = pitch_name(harmony['root']) + suffix
if bass := harmony.get('bass'):
result += '/' + pitch_name(bass)
return result
return '(' + result + ')' if harmony.get('parenthesized') else result
def parse_symbol(text: str) -> dict:
import re
match = re.fullmatch(r'([A-Ga-g][#b]*)([^/]*)(?:/([A-Ga-g][#b]*))?', text.strip())
text = text.strip()
parenthesized = text.startswith('(') and text.endswith(')')
content = text[1:-1] if parenthesized else text
match = re.fullmatch(r'([A-Ga-g][#b]*)([^/()]*)(?:/([A-Ga-g][#b]*))?', content)
if not match:
raise ValueError(f'invalid chord symbol: {text!r}')
root, suffix, bass = match.groups()
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:
core = next(core for core in sorted(SPELLINGS.keys() | EXTENSIONS.keys(), key=lambda value: (-len(value), value)) if suffix.startswith(core))
result = extended_chord(*EXTENSIONS[core]) if core in EXTENSIONS else {'kind': SPELLINGS[core], 'degrees': []}
remainder = suffix[len(core):]
changed = set()
while remainder:
modifier = re.match(r'(add|no)?(b+|#+)?([1-9][0-9]*)', remainder)
if not modifier or not (modifier[1] or modifier[2]):
raise ValueError(f'unsupported chord spelling: {text!r}')
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}
operation, accidental, value = modifier.groups()
accidental = accidental or ''
degree = int(value)
if degree in changed or operation == 'no' and accidental:
raise ValueError(f'contradictory chord degrees: {text!r}')
changed.add(degree)
operation = 'subtract' if operation == 'no' else 'alter' if degree in degrees(result) and operation != 'add' else 'add'
result['degrees'].append({'value': degree, 'alter': accidental.count('#') - accidental.count('b'), 'type': operation})
remainder = remainder[modifier.end():]
result['root'] = parse_pitch_name(root)
for change in result['degrees']:
change['alter'] = rational(change['alter'])
if bass:
result['bass'] = parse_pitch_name(bass)
if parenthesized:
result['parenthesized'] = True
degrees(result)
return result
def normalize_text(harmony: dict, text: str) -> None:
text = text.strip()
parenthesized = text.startswith('(') and text.endswith(')')
content = text[1:-1] if parenthesized else text
try:
displayed = parse_symbol(pitch_name(harmony['root']) + content)
except ValueError:
displayed = parse_symbol(content)
text_only = harmony['kind'] == 'other' and not harmony['degrees']
if displayed['root'] != harmony['root']:
if not text_only or harmony['root'] != parse_pitch_name('C'):
raise ValueError(f'contradictory harmony root in {text!r}')
if 'bass' in displayed and 'bass' in harmony and displayed['bass'] != harmony['bass']:
raise ValueError(f'contradictory harmony bass in {text!r}')
if text_only:
harmony.update(displayed)
elif degrees(displayed) not in (degrees(harmony), KINDS[harmony['kind']]):
raise ValueError(f'contradictory harmony text {text!r} and structured chord degrees')
if 'bass' in displayed:
harmony['bass'] = displayed['bass']
if parenthesized:
harmony.update(parenthesized=True, chord_optional=True)
+4 -3
View File
@@ -146,9 +146,6 @@ def validate_document(document: dict) -> None:
staves = document.get('staves', [])
if len(staves) != 1:
raise ValueError('exactly one staff is currently supported')
if any(m.get('repeats') or m.get('endings') for m in staves[0]['measures']):
from generator.repeats import performed_order
performed_order(staves[0]['measures'])
instruments = staves[0].get('instruments', [])
instrument_ids = {i['id'] for i in instruments}
if len(instruments) != len(instrument_ids) or any(not isinstance(i, str) or not i for i in instrument_ids):
@@ -159,6 +156,8 @@ def validate_document(document: dict) -> None:
ownership: dict[str, str] = {}
absolute = Fraction()
for measure in staves[0]['measures']:
if {'repeats', 'endings', 'initial_attributes'} & measure.keys():
raise ValueError('repeat notation is unsupported; regenerate scores from music written out in reading order')
length = fraction(measure['duration'])
if length <= 0:
raise ValueError('measure duration must be positive')
@@ -167,6 +166,8 @@ def validate_document(document: dict) -> None:
raise ValueError('measure requires uniquely identified voice lanes')
previous_ends = {v['id']: Fraction() for v in voices}
for voice, note in ((v, n) for v in voices for n in v['notes']):
if any({'sound_ties', 'printed_ties'} & pitch.keys() for pitch in note['pitches']):
raise ValueError('pass-specific tie metadata is unsupported; regenerate scores')
if not note['id'] or note['id'] in ids:
raise ValueError(f'duplicate event ID: {note["id"]}')
ids.add(note['id'])
+16 -55
View File
@@ -38,7 +38,6 @@ class Importer:
self.staff_details = {'lines': 5, 'tunings': []}
self.absolute = Fraction()
self.tremolo_starts = {}
self.has_repeats = False
def fail(self, text: str):
raise MusicXMLError(f'{self.location}: {text}')
def children(self, node, allowed: set[str]) -> None:
@@ -178,8 +177,6 @@ class Importer:
self.harmony_sequence += 1
result = {'id': f'harmony-{self.harmony_sequence}', '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:
@@ -204,17 +201,9 @@ class Importer:
elif operation == 'subtract' and degree_alter:
self.fail('cannot subtract a degree different from the one in the chord kind')
result['degrees'].append({'value': value, 'alter': rational(degree_alter), 'type': operation})
if kind.get('text') and result['kind'] != 'none':
harmony.normalize_text(result, kind.get('text'))
chord_degrees = harmony.degrees(result)
if 'kind_text' in result and result['kind'] != 'none':
label = 'C' + 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
@@ -235,6 +224,8 @@ class Importer:
self.children(node, {'direction-type', 'offset', 'staff', 'voice', 'sound'})
onset = self.offset(node, cursor)
for group in node.findall('direction-type'):
if group.find('segno') is not None or group.find('coda') is not None:
self.fail('navigation jumps are unsupported; write the music out in reading order')
self.children(group, {'words', 'octave-shift', 'rehearsal', 'metronome'})
for item in group:
if item.tag == 'words':
@@ -275,6 +266,8 @@ class Importer:
if sound is not None:
self.sound(sound, onset, measure)
def sound(self, node, onset: Fraction, measure: dict) -> None:
if {'dacapo', 'dalsegno', 'segno', 'coda', 'tocoda', 'fine', 'forward-repeat', 'time-only'} & node.attrib.keys():
self.fail('navigation jumps are unsupported; write the music out in reading order')
self.attrs(node, {'tempo'})
self.children(node, set())
if node.get('tempo') and not any(fraction(t['onset']) == onset for t in measure.get('tempos', [])):
@@ -375,8 +368,8 @@ class Importer:
ties = {t.get('type') for t in node.findall('tie')}
for tie in node.findall('tie'):
self.attrs(tie, {'type', 'time-only'})
if tie.get('time-only') and not self.has_repeats:
self.fail('time-only tie requires a repeated section')
if 'time-only' in tie.attrib:
self.fail('pass-specific ties are unsupported; write the music out in reading order')
notations = node.find('notations')
visual_ties = set()
if notations is not None:
@@ -387,22 +380,12 @@ class Importer:
ties |= visual_ties
if ties - {'start', 'stop'}:
self.fail('unsupported tie notation')
if self.has_repeats and ties:
from generator.repeats import pass_numbers
p['sound_ties'] = []
p['printed_ties'] = sorted(visual_ties)
for tie in node.findall('tie'):
self.attrs(tie, {'type', 'time-only'})
marker = {'type': tie.get('type')}
if tie.get('time-only'):
marker['passes'] = pass_numbers(tie.get('time-only'))
p['sound_ties'].append(marker)
if not self.has_repeats and 'stop' in ties:
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 not self.has_repeats and 'start' in ties:
if 'start' in ties:
if value in self.open_ties:
self.fail('ambiguous duplicate tie at the same pitch')
self.open_ties[value] = p
@@ -512,7 +495,6 @@ class Importer:
root = ElementTree.parse(self.path, forbid_entities=True, forbid_external=True).getroot()
if root.tag != 'score-partwise' or root.get('version') not in ('3.1', '4.0'):
self.fail('expected uncompressed score-partwise MusicXML 3.1 or 4.0')
self.has_repeats = bool(root.findall('.//barline/repeat') or root.findall('.//barline/ending'))
self.children(root, IGNORED_TOP | {'part'})
parts = root.findall('part')
definitions = root.findall('part-list/score-part')
@@ -564,8 +546,6 @@ class Importer:
if item.tag == 'attributes':
state = self.attributes(item, cursor)
if cursor == 0:
if self.has_repeats:
measure.setdefault('initial_attributes', {}).update({k: v for k, v in state.items() if k != 'onset'})
for k in ('key', 'meter', 'clef', 'transpose', 'staff_details'):
measure[k] = copy.deepcopy(getattr(self, k))
elif len(state) > 1:
@@ -599,11 +579,13 @@ class Importer:
extent = max(extent, cursor)
previous = None
elif item.tag == 'barline':
if item.find('repeat') is not None or item.find('ending') is not None:
self.fail('repeats and numbered endings are unsupported; write the music out in reading order')
self.attrs(item, {'location'})
self.children(item, {'bar-style', 'repeat', 'ending'})
self.children(item, {'bar-style'})
side = item.get('location', 'right')
if side not in ('left', 'right'):
self.fail('barlines and repeats must be at measure boundaries')
self.fail('barlines must be at measure boundaries')
if side in measure.get('barlines', {}):
self.fail(f'duplicate {side} barline')
if side == 'left' and cursor != 0 or side == 'right' and cursor != extent:
@@ -614,26 +596,6 @@ class Importer:
measure.setdefault('barlines', {})[side] = style
if side == 'right':
measure['_right_barline_onset'] = cursor
for repeat in item.findall('repeat'):
self.attrs(repeat, {'direction', 'times', 'winged'})
self.children(repeat, set())
if side in measure.get('repeats', {}):
self.fail('duplicate repeat marker')
marker = {'direction': repeat.get('direction')}
if repeat.get('times'):
if marker['direction'] != 'backward':
self.fail('repeat times requires a backward repeat')
marker['times'] = self.number(repeat.get('times'), integer=True, positive=True)
if repeat.get('winged', 'none') != 'none':
self.fail('winged repeat engraving is not supported')
measure.setdefault('repeats', {})[side] = marker
for ending in item.findall('ending'):
from generator.repeats import pass_numbers
self.attrs(ending, {'number', 'type', 'print-object', 'end-length', 'text-x', 'text-y'})
self.children(ending, set())
if side in measure.get('endings', {}):
self.fail('duplicate ending marker')
measure.setdefault('endings', {})[side] = {'type': ending.get('type'), 'numbers': pass_numbers(ending.get('number', '')), 'text': ending.text or '', 'visible': ending.get('print-object', 'yes') != 'no'}
if not voices or extent <= 0:
self.fail('empty or zero-duration measure')
cursor = extent
@@ -653,6 +615,8 @@ class Importer:
if len(matches) != 1 or onset in used:
self.fail('optional-chord marker must identify exactly one harmony and occur only once')
matches[0]['chord_optional'] = True
matches[0]['parenthesized'] = True
matches[0]['symbol'] = harmony.symbol(matches[0])
used.add(onset)
measures.append(measure)
self.absolute += cursor
@@ -667,9 +631,6 @@ class Importer:
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}]}
result['staves'][0]['instruments'] = instruments
if self.has_repeats:
from generator.repeats import expand_document
validate_document(expand_document(result))
validate_document(result)
return result
+2 -10
View File
@@ -85,7 +85,7 @@ def write_harmony(parent, value: dict, divisions: int) -> None:
item = element(node, 'root')
element(item, 'root-step', root['letter'].upper())
element(item, 'root-alter', decimal(root['alter']))
element(node, 'kind', value['kind'], text=value.get('kind_text'))
element(node, 'kind', value['kind'], text=harmony.symbol(value) if value.get('parenthesized') else None)
if 'inversion' in value:
element(node, 'inversion', value['inversion'])
if 'bass' in value:
@@ -138,8 +138,6 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
element(item, 'octave', pitch['octave'] if pitch else 4)
if not event.get('grace'):
element(node, 'duration', int(fraction(pitch['duration'] if pitch else event['duration']) * divisions))
for marker in (pitch or {}).get('sound_ties', []):
element(node, 'tie', type=marker['type'], time_only=','.join(map(str, marker['passes'])) if marker.get('passes') else None)
for field, action in (('tie_from', 'stop'), ('tie_to', 'start')):
if pitch and pitch.get(field):
element(node, 'tie', type=action)
@@ -172,8 +170,6 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
for beam in event.get('beams', []):
element(node, 'beam', beam['type'], number=beam['level'])
notations = element(node, 'notations')
for action in (pitch or {}).get('printed_ties', []):
element(notations, 'tied', type=action)
for field, action in (('tie_from', 'stop'), ('tie_to', 'start')):
if pitch and pitch.get(field):
element(notations, 'tied', type=action)
@@ -207,14 +203,10 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
element(lyric, syllable['kind'], syllable['text'], **syllable['attributes'])
def write_barline(bar, measure, location):
if not any(location in measure.get(field, {}) for field in ('barlines', 'repeats', 'endings')):
if location not in measure.get('barlines', {}):
return
node = element(bar, 'barline', location=location)
element(node, 'bar-style', measure.get('barlines', {}).get(location, 'regular'))
if ending := measure.get('endings', {}).get(location):
element(node, 'ending', ending.get('text', ''), number=','.join(map(str, ending['numbers'])), type=ending['type'], print_object='yes' if ending.get('visible', True) else 'no')
if repeat := measure.get('repeats', {}).get(location):
element(node, 'repeat', direction=repeat['direction'], times=repeat.get('times'))
def musicxml_tree(document: dict) -> ElementTree.ElementTree:
validate_document(document)
-2
View File
@@ -10,7 +10,6 @@ import sys
import wave
from generator.model import TYPE_LENGTHS, fraction, pitch_number, rational
from generator.repeats import expand_document
SAMPLE_RATE = 48000
PROFILE = 'soft-struck-1'
@@ -34,7 +33,6 @@ def sounding_pitch(pitch: dict, state: dict, guitar: bool) -> Fraction:
return value
def compile_timeline(document: dict, *, guitar: bool = False) -> dict:
document = expand_document(document)
events, notes, linked = [], {}, {}
absolute = Fraction()
for measure in document['staves'][0]['measures']:
+2
View File
@@ -22,6 +22,8 @@ def practice_document(document: dict) -> dict:
section_label=str(measure.get("section_label", "")),
)
output_measure['voices'] = voices
for chord in output_measure['harmonies']:
chord.pop('chord_optional', None)
measures.append(output_measure)
tuplets = reidentify(measures, staff.get('tuplets', []))
result = make_arranged_document(
-2
View File
@@ -86,8 +86,6 @@ def extend_fingerings(held: dict[str, TabPosition], pending: list[tuple[str, tup
yield from extend_fingerings({**held, identifier: position}, pending[1:])
def validate_guitar_practice(document: dict) -> None:
from generator.repeats import expand_document
document = expand_document(document)
tones = guitar_tones(document)
by_id = {tone.pitch['id']: tone for tone in tones}
options = {tone.pitch['id']: tone_positions(tone) for tone in tones}
-260
View File
@@ -1,260 +0,0 @@
from __future__ import annotations
import copy
from dataclasses import dataclass
from fractions import Fraction
import re
from generator.model import alteration, fraction, key_label, meter_label, rational
MAX_DEPTH = 16
MAX_MEASURES = 10000
def pass_numbers(value: str) -> list[int]:
if not re.fullmatch(r'[1-9][0-9]*(?:,\s*[1-9][0-9]*)*', value.strip()):
raise ValueError('ending/time-only numbers must be comma-separated positive integers')
result = [int(item) for item in value.split(',')]
if len(set(result)) != len(result) or max(result) > MAX_MEASURES:
raise ValueError('duplicate or excessive ending/time-only number')
return sorted(result)
@dataclass
class Ending:
start: int
end: int
numbers: list[int]
text: str
closed: bool
visible: bool
@dataclass
class Region:
start: int
end: int
extent: int
count: int
endings: list[Ending]
@dataclass(frozen=True)
class Occurrence:
measure: int
passes: tuple[int, ...]
def analyze(measures: list[dict]) -> tuple[list[Region], list[Ending]]:
regions, endings, starts = [], [], []
active = None
for index, measure in enumerate(measures):
for side, boundary in (('left', index), ('right', index + 1)):
if marker := measure.get('endings', {}).get(side):
numbers = marker['numbers']
if numbers != pass_numbers(','.join(map(str, numbers))):
raise ValueError(f'measure {index + 1}: invalid ending passes')
if marker['type'] == 'start':
if active is not None:
raise ValueError(f'measure {index + 1}: overlapping numbered endings')
active = (boundary, marker)
elif marker['type'] in ('stop', 'discontinue'):
if active is None or active[1]['numbers'] != numbers or active[0] >= boundary:
raise ValueError(f'measure {index + 1}: ending stop has no matching nonempty start')
first, value = active
endings.append(Ending(first, boundary, numbers, value.get('text') or ', '.join(map(str, numbers)) + '.', marker['type'] == 'stop', value.get('visible', True)))
active = None
else:
raise ValueError(f'measure {index + 1}: invalid ending type')
if marker := measure.get('repeats', {}).get(side):
direction = marker['direction']
if direction == 'forward' and side == 'left':
starts.append(boundary)
if len(starts) > MAX_DEPTH:
raise ValueError('repeat nesting exceeds 16 levels')
elif direction == 'backward' and side == 'right':
first = starts.pop() if starts else 0
count = marker.get('times', 2)
if type(count) is not int or not 1 <= count <= MAX_MEASURES or first >= boundary:
raise ValueError(f'measure {index + 1}: invalid repeat count or empty repeat')
regions.append(Region(first, boundary, boundary, count, []))
else:
raise ValueError(f'measure {index + 1}: repeat direction disagrees with its boundary')
if starts or active is not None:
raise ValueError('unterminated repeat or numbered ending')
assigned = set()
for region in regions:
first = next((ending for ending in endings if region.start <= ending.start < region.end and ending.end == region.end), None)
if first is None:
continue
group = [first]
while following := next((ending for ending in endings if ending.start == group[-1].end), None):
group.append(following)
numbers = [number for ending in group for number in ending.numbers]
count = max(numbers)
if sorted(numbers) != list(range(1, count + 1)) or count not in group[-1].numbers or len(group) < 2:
raise ValueError(f'measure {region.start + 1}: endings must cover each pass exactly once and finish with the last pass')
marker = measures[region.end - 1]['repeats']['right']
if 'times' in marker and marker['times'] != count:
raise ValueError(f'measure {region.end}: repeat count contradicts ending passes')
region.count, region.extent, region.endings = count, group[-1].end, group
assigned.update(id(ending) for ending in group)
if any(id(ending) not in assigned for ending in endings):
raise ValueError('numbered ending is not attached to a repeat group')
for a, region in enumerate(regions):
for other in regions[a + 1:]:
if region.start == other.start and region.extent == other.extent:
raise ValueError('duplicate repeat region')
left, right = sorted((region, other), key=lambda item: (item.start, -item.extent))
if left.start < right.start < left.extent < right.extent:
raise ValueError('crossing repeat regions are ambiguous')
for ending in left.endings:
if right.start < ending.start < right.extent or right.start < ending.end < right.extent:
raise ValueError('nested repeat crosses an ending boundary')
return regions, endings
def performed_order(measures: list[dict]) -> list[Occurrence]:
regions, _ = analyze(measures)
result = []
def walk(start, end, parents, passes, excluded=()):
if len(parents) > MAX_DEPTH:
raise ValueError('repeat nesting exceeds 16 levels')
index = start
while index < end:
skipped = next((ending for ending in excluded if ending.start == index), None)
if skipped:
index = skipped.end
continue
candidates = [r for r in regions if r.start == index and r.extent <= end and id(r) not in parents]
if candidates:
region = max(candidates, key=lambda r: r.extent)
for number in range(1, region.count + 1):
walk(index, region.extent, (*parents, id(region)), (*passes, number), [e for e in region.endings if number not in e.numbers])
index = region.extent
else:
result.append(Occurrence(index, passes))
if len(result) > MAX_MEASURES:
raise ValueError('expanded repeat traversal exceeds 10000 measures')
index += 1
walk(0, len(measures), (), ())
return result
def expand_document(document: dict) -> dict:
staff = document['staves'][0]
source = staff['measures']
if not any(m.get('repeats') or m.get('endings') for m in source):
return copy.deepcopy(document)
order = performed_order(source)
output = copy.deepcopy(document)
target = output['staves'][0]
target['measures'], target['tuplets'], target['octave_shifts'] = [], [], []
starts = [Fraction()]
harmonies_before = []
active_harmony = None
event_measures = {n['id']: i for i, m in enumerate(source) for v in m['voices'] for n in v['notes']}
event_ids = {(oi, n['id']): f'occurrence-{oi + 1}-n{ni + 1}' for oi, o in enumerate(order) for ni, n in enumerate(n for v in source[o.measure]['voices'] for n in v['notes'])}
for measure in source:
starts.append(starts[-1] + fraction(measure['duration']))
harmonies_before.append(active_harmony)
for harmony in measure['harmonies']:
active_harmony = harmony
absolute = Fraction()
open_ties = {}
expected_ties, used_ties = set(), set()
previous_state = {}
previous_index = -1
active_tuplets = {}
for oi, occurrence in enumerate(order):
si = occurrence.measure
measure = copy.deepcopy(source[si])
measure['id'] = f'occurrence-{oi + 1}'
measure.pop('repeats', None)
measure.pop('endings', None)
if active_harmony and oi and si <= previous_index and not any(fraction(h['onset']) == 0 for h in measure['harmonies']):
restored = copy.deepcopy(harmonies_before[si]) if harmonies_before[si] else {'kind': 'none', 'symbol': 'N.C.', 'degrees': []}
restored['onset'] = rational(0)
measure['harmonies'].insert(0, restored)
for side, marker in source[si].get('repeats', {}).items():
measure.get('barlines', {}).pop(side, None)
if oi and si > previous_index:
for field, value in previous_state.items():
if field not in measure.get('initial_attributes', {}):
measure[field] = copy.deepcopy(value)
measure.pop('initial_attributes', None)
if oi and measure['key'].get('cancel') is not None:
measure['key']['cancel'] = previous_state['key'].get('fifths', 0)
measure['key_signature'] = key_label(measure['key'])
measure['time_signature'] = meter_label(measure['meter'])
previous_state = {field: copy.deepcopy(measure[field]) for field in ('key', 'meter', 'clef', 'transpose', 'staff_details')}
for change in measure.get('attributes', []):
previous_state.update({field: copy.deepcopy(value) for field, value in change.items() if field != 'onset'})
event_map, tone_ids = {}, {}
for hi, harmony in enumerate(measure['harmonies']):
harmony['id'] = f'{measure["id"]}-h{hi + 1}'
for ni, note in enumerate(n for v in measure['voices'] for n in v['notes']):
old = note['id']
note['id'] = f'{measure["id"]}-n{ni + 1}'
event_map[old] = note['id']
for pi, pitch in enumerate(note['pitches']):
original = pitch['id']
pitch['id'] = f'{note["id"]}-p{pi + 1}'
tone_ids[pitch['id']] = original
if 'sound_ties' not in pitch:
pitch['sound_ties'] = [{'type': action} for field, action in (('tie_from', 'stop'), ('tie_to', 'start')) if pitch.get(field)]
expected_ties.update((original, marker['type']) for marker in pitch['sound_ties'])
pitch.pop('tie_from', None)
pitch.pop('tie_to', None)
for voice in measure['voices']:
for note in voice['notes']:
onset = absolute + fraction(note['onset'])
for pitch in note['pitches']:
markers = pitch.pop('sound_ties', [])
pitch.pop('printed_ties', None)
unpitched = pitch.get('unpitched', {})
identity = (voice['id'], pitch.get('letter'), alteration(pitch.get('alter', 0)), pitch.get('octave'), unpitched.get('letter'), unpitched.get('octave'), pitch.get('instrument'))
for marker in sorted(markers, key=lambda t: t['type'] == 'start'):
if marker.get('passes') and (not occurrence.passes or occurrence.passes[-1] not in marker['passes']):
continue
used_ties.add((tone_ids[pitch['id']], marker['type']))
if marker['type'] == 'stop':
pending = open_ties.pop(identity, None)
if not pending or pending[1] != onset:
raise ValueError(f'measure {source[si]["number"]}, passes {occurrence.passes}: tie stop has no adjacent start on the performed path; use time-only for pass-specific ties')
old, _ = pending
old['tie_to'], pitch['tie_from'] = pitch['id'], old['id']
else:
if identity in open_ties:
raise ValueError(f'measure {source[si]["number"]}: overlapping tie starts')
open_ties[identity] = (pitch, onset + fraction(pitch['duration']))
if partner := note.get('tremolo', {}).get('partner'):
pi = oi + event_measures[partner] - si
low, high = sorted((oi, pi))
if pi < 0 or pi >= len(order) or any(order[j].measure != si + j - oi for j in range(low, high + 1)):
raise ValueError(f'measure {source[si]["number"]}: repeat jump splits a paired tremolo')
note['tremolo']['partner'] = event_ids[pi, partner]
continued = {}
for span in staff.get('tuplets', []):
members = [n for v in measure['voices'] for n in v['notes'] if span['id'] in n.get('tuplet_ids', [])]
if members:
previous = active_tuplets.get(span['id']) if si == previous_index + 1 else None
if previous and previous['continued_end'] and span['start'] not in event_map:
rendered = previous
rendered['end'] = members[-1]['id']
rendered['continued_end'] = span['end'] not in event_map
else:
rendered = {**copy.deepcopy(span), 'id': f'{measure["id"]}-{span["id"]}', 'start': members[0]['id'], 'end': members[-1]['id'], 'continued_start': span['start'] not in event_map, 'continued_end': span['end'] not in event_map}
target['tuplets'].append(rendered)
identifier = rendered['id']
continued[span['id']] = rendered
for note in members:
note['tuplet_ids'] = [identifier if t == span['id'] else t for t in note['tuplet_ids']]
active_tuplets = continued
for shift in staff.get('octave_shifts', []):
low, high = max(starts[si], fraction(shift['start'])), min(starts[si + 1], fraction(shift['end']))
if low < high:
target['octave_shifts'].append({**shift, 'id': f'{measure["id"]}-{shift["id"]}', 'start': rational(absolute + low - starts[si]), 'end': rational(absolute + high - starts[si])})
target['measures'].append(measure)
absolute += fraction(measure['duration'])
previous_index = si
if open_ties:
raise ValueError('tie start has no matching endpoint on the performed path; use time-only for pass-specific ties')
if expected_ties != used_ties:
raise ValueError('time-only tie refers to a pass that is never performed')
return output
-3
View File
@@ -90,9 +90,6 @@ def transpose_document(document: dict, target_key_name: str, *, octave_shift: in
for change in measure.get('attributes', []):
if 'key' in change:
change['key'] = shift_key(change['key'], steps, semitones)
initial = measure.get('initial_attributes', {})
if 'key' in initial:
initial['key'] = shift_key(initial['key'], steps, semitones)
for h in measure.get('harmonies', []):
for field in ('root', 'bass'):
if field in h:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

+1 -1
View File
@@ -79,7 +79,7 @@ class GuitarPracticeTests(unittest.TestCase):
source = parse(xml_score(harmony + xml_note('E', alter=-1), '<key><fifths>-3</fifths><mode>major</mode></key>'))
result = transpose_guitar_practice(source, 'D')
self.assertEqual(pitch_number(notes(result)[0]['pitches'][0]), 62)
self.assertEqual(result['staves'][0]['measures'][0]['harmonies'][0]['symbol'], 'D/F#')
self.assertEqual(result['staves'][0]['measures'][0]['harmonies'][0]['symbol'], 'DM/F#')
for mode, fifths in [('minor', -3), ('dorian', -2)]:
source = parse(xml_score(xml_note(), f'<key><fifths>{fifths}</fifths><mode>{mode}</mode></key>'))
result = transpose_guitar_practice(source, 'D')
+100
View File
@@ -0,0 +1,100 @@
from pathlib import Path
import tempfile
import unittest
from generator import harmony
from generator.model import rational
from generator.musicxml import MusicXMLError, load_musicxml
from generator.musicxml_writer import write_musicxml
from generator.practice.generate import practice_document
from generator.transposition import transpose_document
from tests.test_musicxml import parse, xml_note, xml_score
def chord_xml(text, kind='other', root='C', extra=''):
return f'<harmony><root><root-step>{root}</root-step></root><kind text="{text}">{kind}</kind>{extra}</harmony>'
class HarmonyTests(unittest.TestCase):
def test_quality_spellings_are_canonical(self):
cases = {'C': 'CM', 'Cmaj': 'CM', 'Cmin': 'Cm', 'C-': 'Cm', 'C+': 'Caug', 'Co': 'Cdim', 'Csus4': 'Csus', 'Csus2': 'Csus2',
'Cmaj7': 'CM7', 'C-7': 'Cm7', 'C+7': 'Caug7', 'C+maj7': 'CaugM7', 'Co7': 'Cdim7', 'C-7b5': 'Cm7b5',
'C-maj7': 'CmM7', 'CmM': 'CmM7', 'C6': 'CM6', 'C-6': 'Cm6', 'Fmaj7#11/A': 'FM7#11/A', 'G7b9': 'G7b9'}
for spelling, canonical in cases.items():
with self.subTest(spelling=spelling):
chord = harmony.parse_symbol(spelling)
self.assertEqual(harmony.symbol(chord), canonical)
self.assertEqual(harmony.degrees(harmony.parse_symbol(canonical)), harmony.degrees(chord))
def test_extended_chords_include_sevenths_but_added_ninths_do_not(self):
for prefix, seventh in (('', -1), ('m', -1), ('M', 0), ('mM', 0)):
for highest in (9, 11, 13):
with self.subTest(prefix=prefix, highest=highest):
chord = harmony.parse_symbol(f'C{prefix}{highest}')
values = harmony.degrees(chord)
self.assertEqual(values[7], seventh)
self.assertTrue(all(values[degree] == 0 for degree in range(9, highest + 1, 2)))
self.assertEqual(harmony.symbol(chord), f'C{prefix}{highest}')
self.assertEqual(harmony.degrees(harmony.parse_symbol('Cmadd9')), {1: 0, 3: -1, 5: 0, 9: 0})
self.assertEqual(harmony.degrees(harmony.parse_symbol('C7no5')), {1: 0, 3: 0, 7: -1})
self.assertEqual(harmony.degrees(harmony.parse_symbol('C7sus')), {1: 0, 4: 0, 5: 0, 7: -1})
def test_soundslice_text_only_chords_become_structured(self):
for text, kind, values in (('mM7', 'major-minor', {1: 0, 3: -1, 5: 0, 7: 0}), ('m7b5', 'half-diminished', {1: 0, 3: -1, 5: -1, 7: -1}), ('7b9', 'dominant', {1: 0, 3: 0, 5: 0, 7: -1, 9: -1})):
with self.subTest(text=text):
source = parse(xml_score(chord_xml(text) + xml_note()))
chord = source['staves'][0]['measures'][0]['harmonies'][0]
self.assertEqual(chord['kind'], kind)
self.assertEqual(harmony.degrees(chord), values)
self.assertEqual(chord['symbol'], 'C' + text)
self.assertNotIn('kind_text', chord)
source = parse(xml_score(chord_xml('m9', 'minor-ninth') + xml_note()))
self.assertEqual(harmony.degrees(source['staves'][0]['measures'][0]['harmonies'][0])[7], -1)
def test_parenthesized_full_symbols_transpose_and_roundtrip(self):
for label, transposed in (('(Fm7)', '(Gm7)'), ('(Bb7)', '(C7)'), ('(Fm7/Ab)', '(Gm7/Bb)')):
with self.subTest(label=label):
source = parse(xml_score(chord_xml(label) + xml_note()))
chord = source['staves'][0]['measures'][0]['harmonies'][0]
self.assertEqual(chord['symbol'], label)
self.assertTrue(chord['chord_optional'])
self.assertTrue(chord['parenthesized'])
practice = practice_document(source)['staves'][0]['measures'][0]['harmonies'][0]
self.assertNotIn('chord_optional', practice)
self.assertEqual(harmony.symbol(practice), label)
shifted = transpose_document(source, 'D')
result = shifted['staves'][0]['measures'][0]['harmonies'][0]
self.assertEqual(result['symbol'], transposed)
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / 'score.xml'
write_musicxml(path, shifted)
restored = load_musicxml(path)['staves'][0]['measures'][0]['harmonies'][0]
self.assertEqual(restored, result)
def test_requested_labels_are_engraved_without_overlaps(self):
from generator.engraving import Fonts, Style, layout_score
labels = ('CM', 'Cm7', 'CM13#11', 'CmM7', 'CaugM7', 'Cdim7', 'Csus', 'Csus2', 'C7sus4', '(Fm7/Ab)')
for label in labels:
with self.subTest(label=label):
source = parse(xml_score(chord_xml(label) + xml_note()))
layout = layout_score(source, Style(), Fonts(Path('package/assets/fonts/petaluma')))
rendered = [p.value for page in layout.pages for p in page.primitives if p.role == 'harmony']
self.assertEqual(rendered, [harmony.symbol(harmony.parse_symbol(label))])
def test_unknown_or_contradictory_symbols_are_rejected(self):
for label in ('Cunknown', 'Cm79', 'C7b9#9', 'C7add7', 'C7no3no3', 'C7no#5', '(Cm7', 'Cm7)', 'C7/G/Ab'):
with self.subTest(label=label), self.assertRaises(ValueError):
harmony.parse_symbol(label)
for content in (chord_xml('m7', 'major-seventh'), chord_xml('(Fm7)', 'minor-seventh'), chord_xml('(Fm7)', root='D'), chord_xml('m7/G', extra='<bass><bass-step>A</bass-step></bass>')):
with self.subTest(content=content), self.assertRaisesRegex(MusicXMLError, 'contradictory'):
parse(xml_score(content + xml_note()))
def test_kind_text_cannot_override_canonical_output(self):
chord = harmony.parse_symbol('Cm7') | {'kind_text': '-7'}
self.assertEqual(harmony.symbol(chord), 'Cm7')
source = parse(xml_score(chord_xml('7', 'dominant', extra='<degree><degree-value>9</degree-value><degree-alter>-1</degree-alter><degree-type>add</degree-type></degree>') + xml_note()))
self.assertEqual(source['staves'][0]['measures'][0]['harmonies'][0]['symbol'], 'C7b9')
def test_current_exercise_and_song_exports_import_without_edits(self):
exercise = load_musicxml(Path('data/001.xml'))
measures = exercise['staves'][0]['measures']
self.assertEqual(len(measures), 20)
self.assertEqual(sum(len(n['pitches']) for m in measures for v in m['voices'] for n in v['notes']), 70)
self.assertEqual(measures[1]['harmonies'][0]['symbol'], 'Bm9')
self.assertEqual(measures[3]['harmonies'][0]['symbol'], 'DmM7')
self.assertEqual(measures[0]['voices'][0]['notes'][0]['duration'], rational(1))
for name, count, key in (('autumn-leaves', 33, 'F'), ('it-could-happen-to-you', 32, 'Eb')):
source = load_musicxml(Path('data/songs') / f'{name}.xml')
self.assertEqual(len(source['staves'][0]['measures']), count)
self.assertEqual(source['metadata']['key_signature'], key)
+14
View File
@@ -80,6 +80,20 @@ class MusicModelTests(unittest.TestCase):
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']))
labels = [h['symbol'] for m in measures[:-1] for h in m['harmonies']]
self.assertIn('(Fm7)', labels)
self.assertIn('(Bb7)', labels)
def test_legacy_repeat_model_fields_are_rejected(self):
for field in ('repeats', 'endings', 'initial_attributes'):
source = parse(xml_score(xml_note()))
source['staves'][0]['measures'][0][field] = {}
with self.assertRaisesRegex(ValueError, 'repeat notation'):
validate_document(source)
for field in ('sound_ties', 'printed_ties'):
source = parse(xml_score(xml_note()))
notes(source)[0]['pitches'][0][field] = []
with self.assertRaisesRegex(ValueError, 'pass-specific tie'):
validate_document(source)
def test_player_styles_preserved(self):
self.assertEqual(len(BUILT_IN_STYLES), 50)
for text in ('Med.', 'Med. Swing', 'Medium', 'Medium Swing'):
+23 -7
View File
@@ -91,18 +91,18 @@ class MusicXMLTests(unittest.TestCase):
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_standard_minor_harmonies_preserve_display_and_pitches(self):
def test_standard_minor_harmonies_normalize_display_and_preserve_pitches(self):
from generator.harmony import pitch_classes
for text, kind, expected in (('-7', 'minor-seventh', (0, 3, 7, 10)), ('-7b5', 'half-diminished', (0, 3, 6, 10)), ('-maj7', 'major-minor', (0, 3, 7, 11)), ('-6', 'minor-sixth', (0, 3, 7, 9))):
for text, kind, label, expected in (('-7', 'minor-seventh', 'm7', (0, 3, 7, 10)), ('-7b5', 'half-diminished', 'm7b5', (0, 3, 6, 10)), ('-maj7', 'major-minor', 'mM7', (0, 3, 7, 11)), ('-6', 'minor-sixth', 'm6', (0, 3, 7, 9))):
with self.subTest(text=text):
content = f'<harmony><root><root-step>C</root-step></root><kind text="{text}">{kind}</kind></harmony>'
score = parse(xml_score(content + xml_note()))
chord = score['staves'][0]['measures'][0]['harmonies'][0]
self.assertEqual(chord['symbol'], 'C' + text)
self.assertEqual(chord['symbol'], 'C' + label)
self.assertEqual(pitch_classes(chord), expected)
def test_other_harmony_requires_explicit_degrees(self):
for kind in ('<kind>other</kind>', '<kind text="">other</kind>', '<kind text="-7">other</kind>', '<kind text="unknown">other</kind>'):
with self.subTest(kind=kind), self.assertRaisesRegex(MusicXMLError, r"fixture.xml: part P1, measure 1: harmony kind 'other' requires explicit added degrees"):
def test_other_harmony_requires_degrees_or_recognized_text(self):
for kind in ('<kind>other</kind>', '<kind text="">other</kind>', '<kind text="unknown">other</kind>'):
with self.subTest(kind=kind), self.assertRaisesRegex(MusicXMLError, r'fixture.xml: part P1, measure 1:'):
parse(xml_score('<harmony><root><root-step>C</root-step></root>' + kind + '</harmony>' + xml_note()))
def test_other_harmony_with_explicit_degrees_remains_supported(self):
from generator.harmony import pitch_classes
@@ -111,7 +111,7 @@ class MusicXMLTests(unittest.TestCase):
score = parse(xml_score(content + xml_note()))
chord = score['staves'][0]['measures'][0]['harmonies'][0]
self.assertEqual(chord['kind'], 'other')
self.assertEqual(chord['symbol'], 'C-7')
self.assertEqual(chord['symbol'], 'Cm7')
self.assertEqual(pitch_classes(chord), (0, 3, 7, 10))
with self.assertRaisesRegex(MusicXMLError, 'contradictory harmony text'):
parse(xml_score(content.replace('text="-7"', 'text="maj7"') + xml_note()))
@@ -162,6 +162,22 @@ class MusicXMLTests(unittest.TestCase):
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_repeat_navigation_and_pass_specific_ties_are_rejected(self):
rejected = [
'<barline location="left"><repeat direction="forward"/></barline>',
'<barline><repeat direction="backward" times="3"/></barline>',
'<barline location="left"><ending number="1" type="start"/></barline>',
'<barline><ending number="2" type="discontinue" print-object="no"/></barline>',
'<direction><direction-type><segno/></direction-type></direction>',
'<direction><direction-type><coda/></direction-type></direction>',
'<sound dacapo="yes"/>', '<sound dalsegno="segno"/>', '<sound tocoda="coda"/>',
]
for content in rejected:
with self.subTest(content=content), self.assertRaisesRegex(MusicXMLError, 'measure 1'):
parse(xml_score(content + xml_note()))
for passes in ('1', ''):
with self.assertRaisesRegex(MusicXMLError, 'pass-specific ties'):
parse(xml_score(xml_note(extra=f'<tie type="start" time-only="{passes}"/>')))
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>',
-139
View File
@@ -1,139 +0,0 @@
import argparse
import json
import unittest
from pathlib import Path
import tempfile
from unittest.mock import patch
from generator.model import fraction, validate_document
from generator.musicxml import MusicXMLError
from generator.playback import compile_timeline
from generator.repeats import expand_document, performed_order
from tests.test_musicxml import parse, xml_note, xml_score
def score_bars(bars):
return xml_score(bars[0]).replace('</measure></part>', '</measure>' + ''.join(f'<measure number="{i + 2}">{bar}</measure>' for i, bar in enumerate(bars[1:])) + '</part>')
def boundary(direction, times=None):
side = 'left' if direction == 'forward' else 'right'
count = f' times="{times}"' if times else ''
return f'<barline location="{side}"><repeat direction="{direction}"{count}/></barline>'
def ending(numbers, action, repeat=False):
side = 'left' if action == 'start' else 'right'
return f'<barline location="{side}"><ending number="{numbers}" type="{action}"/>' + ('<repeat direction="backward"/>' if repeat else '') + '</barline>'
class RepeatTests(unittest.TestCase):
def test_repeat_count_and_implicit_start(self):
source = parse(score_bars([xml_note(), xml_note('D') + boundary('backward', 3)]))
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1] * 3)
expanded = expand_document(source)
validate_document(expanded)
self.assertEqual(len(compile_timeline(source)['events']), 6)
def test_combined_endings(self):
source = parse(score_bars([boundary('forward') + xml_note(), ending('1,2', 'start') + xml_note('D') + ending('1,2', 'stop', True), ending('3', 'start') + xml_note('E') + ending('3', 'discontinue')]))
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1, 0, 1, 0, 2])
self.assertIn('endings', source['staves'][0]['measures'][1])
self.assertNotIn('endings', expand_document(source)['staves'][0]['measures'][1])
def test_nested_repeat(self):
source = parse(score_bars([boundary('forward') + xml_note(), boundary('forward') + xml_note('D') + boundary('backward'), xml_note('E') + boundary('backward')]))
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1, 1, 2] * 2)
def test_sequential_and_expansion_limit(self):
source = parse(score_bars([xml_note() + boundary('backward'), boundary('forward') + xml_note('D') + boundary('backward', 3)]))
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 0, 1, 1, 1])
with self.assertRaisesRegex(MusicXMLError, '10000'):
parse(score_bars([boundary('forward') + xml_note(), xml_note() + boundary('backward', 10000)]))
def test_bad_endings_and_open_repeat(self):
for content in (boundary('forward') + xml_note(), ending('1', 'start') + xml_note(), ending('1', 'start') + xml_note() + ending('1', 'stop')):
with self.assertRaises(MusicXMLError):
parse(xml_score(content))
def test_ties_reidentify_each_pass(self):
source = parse(score_bars([boundary('forward') + xml_note(extra='<tie type="start"/>'), xml_note(extra='<tie type="stop"/>') + boundary('backward')]))
validate_document(expand_document(source))
self.assertEqual(len(compile_timeline(source)['events']), 2)
def test_ties_across_repeat_jump_and_unreachable_pass(self):
first = xml_note(extra='<tie type="stop" time-only="2"/>')
last = xml_note(extra='<tie type="start" time-only="1"/>')
source = parse(score_bars([boundary('forward') + first, last + boundary('backward')]))
self.assertEqual([fraction(e['duration']) for e in compile_timeline(source)['events']], [4, 8, 4])
for bad in (first.replace('time-only="2"', ''), first.replace('time-only="2"', 'time-only="3"')):
with self.assertRaisesRegex(MusicXMLError, 'tie'):
parse(score_bars([boundary('forward') + bad, last + boundary('backward')]))
def test_printed_tie_does_not_create_sound_tie(self):
source = parse(score_bars([boundary('forward') + xml_note(extra='<notations><tied type="start"/></notations>'), xml_note(extra='<notations><tied type="stop"/></notations>') + boundary('backward')]))
self.assertEqual(len(compile_timeline(source)['events']), 4)
def test_ties_into_both_endings(self):
source = parse(score_bars([boundary('forward') + xml_note(extra='<tie type="start"/>'), ending('1', 'start') + xml_note(extra='<tie type="stop"/>') + ending('1', 'stop', True), ending('2', 'start') + xml_note(extra='<tie type="stop"/>') + ending('2', 'discontinue')]))
self.assertEqual([fraction(e['duration']) for e in compile_timeline(source)['events']], [8, 8])
def test_cross_measure_tuplets_and_tremolos_remap(self):
extra = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
bars = [boundary('forward'), '']
for index in range(3):
bars[0] += xml_note(duration=4, kind='half', extra=extra + ('<notations><tuplet type="start"/></notations>' if index == 0 else ''))
bars[1] += xml_note(duration=4, kind='half', extra=extra + ('<notations><tuplet type="stop"/></notations>' if index == 2 else ''))
bars[1] += boundary('backward')
source = parse(score_bars(bars).replace('<divisions>1</divisions>', '<divisions>3</divisions>'))
expanded = expand_document(source)
validate_document(expanded)
self.assertEqual(len(expanded['staves'][0]['tuplets']), 2)
modulation = '<time-modification><actual-notes>2</actual-notes><normal-notes>1</normal-notes></time-modification>'
bars = [xml_note(step, kind='breve', extra=modulation + f'<notations><ornaments><tremolo type="{action}">3</tremolo></ornaments></notations>') for step, action in [('C', 'start'), ('G', 'stop')]]
source = parse(score_bars([boundary('forward') + bars[0], bars[1] + boundary('backward')]))
validate_document(expand_document(source))
self.assertEqual(len(compile_timeline(source)['events']), 128)
def test_no_repeat_is_unchanged(self):
source = parse(xml_score(xml_note()))
self.assertEqual(source, expand_document(source))
def test_roundtrip_and_engraving(self):
from generator.musicxml_writer import write_musicxml
from generator.musicxml import load_musicxml
from generator.engraving import Fonts, Style, layout_score
source = parse(score_bars([boundary('forward') + xml_note(), ending('1', 'start') + xml_note('D') + ending('1', 'stop', True), ending('2', 'start') + xml_note('E') + ending('2', 'discontinue')]))
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'repeat.xml'
write_musicxml(path, source)
restored = load_musicxml(path)
self.assertEqual(performed_order(source['staves'][0]['measures']), performed_order(restored['staves'][0]['measures']))
layout = layout_score(source, Style(), Fonts(Path('package/assets/fonts/petaluma')))
items = [p for page in layout.pages for p in page.primitives]
self.assertEqual(sum(p.role == 'repeat-dot' for p in items), 4)
self.assertEqual([p.value for p in items if p.role == 'ending-label'], ['1.', '2.'])
def test_printed_tie_continues_across_system_boundary(self):
from generator.engraving import Fonts, Style
from generator.engraving.layout import make_system
from generator.engraving.notation import ScoreIndex
source = parse(score_bars([boundary('forward') + xml_note(extra='<notations><tied type="start"/></notations>'), xml_note(extra='<notations><tied type="stop"/></notations>') + boundary('backward')]))
index = ScoreIndex(source)
fonts = Fonts(Path('package/assets/fonts/petaluma'))
for start, end in [(0, 4), (4, 8)]:
system = make_system(index, start, end, fonts, Style())
self.assertTrue(any(p.role == 'tie' and len(p.sources) == 2 for p in system.primitives))
def test_build_pipeline_keeps_compact_practice_and_compiles_performed_order(self):
from generator.__main__ import generate_assets
from generator.arrange.generate import arrange_document
text = score_bars([boundary('forward') + xml_note(), xml_note('D') + boundary('backward')])
source = parse(text)
self.assertEqual(arrange_document(source, 'vocal'), arrange_document(expand_document(source), 'vocal'))
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
data = root / 'data'
(data / 'songs').mkdir(parents=True)
(data / '001.xml').write_text(text)
output = root / 'assets'
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False)
generate_assets(args)
bundle = json.loads((output / 'practice/001/c.json').read_text())
self.assertEqual(len(bundle['music']['staves'][0]['measures']), 2)
self.assertEqual(len(bundle['playback']['events']), 4)
self.assertEqual(fraction(bundle['playback']['duration']), 16)
before = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
generate_assets(args)
self.assertEqual(before, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
with patch('generator.__main__.compile_tree', side_effect=ValueError('invalid generated audio')):
with self.assertRaisesRegex(ValueError, 'audio'):
generate_assets(args)
self.assertEqual(before, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
def test_alternate_ending_does_not_inherit_skipped_key_change(self):
source = parse(score_bars([boundary('forward') + xml_note(), ending('1', 'start') + '<attributes><key><fifths>2</fifths></key></attributes>' + xml_note('D') + ending('1', 'stop', True), ending('2', 'start') + xml_note('E') + ending('2', 'discontinue')]))
expanded = expand_document(source)
self.assertEqual([m['key']['fifths'] for m in expanded['staves'][0]['measures']], [0, 2, 0, 0])
+25
View File
@@ -6,6 +6,7 @@ import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from xml.etree import ElementTree
from generator.arrange.generate import arrange_document
@@ -34,6 +35,14 @@ class ScoreBundleTests(unittest.TestCase):
self.assertTrue(list(output.rglob('*.png')))
generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
with patch('generator.__main__.compile_tree', side_effect=ValueError('invalid generated audio')):
with self.assertRaisesRegex(ValueError, 'audio'):
generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
source.write_text(xml_score(xml_note() + '<barline><repeat direction="backward"/></barline>'))
with self.assertRaisesRegex(ValueError, 'write the music out'):
generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
source.write_text(xml_score(xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
with self.assertRaisesRegex(EngravingError, 'unavailableGlyph'):
generate_assets(args)
@@ -68,6 +77,22 @@ class ScoreBundleTests(unittest.TestCase):
restored = self.round_trip(document)
validate_document(restored)
self.assertTrue(any(len(m['voices']) == 2 for m in restored['staves'][0]['measures']))
def test_contiguous_ties_continue_across_systems_without_repeat_state(self):
from generator.engraving.layout import make_system
from generator.engraving.notation import ScoreIndex
from generator.playback import compile_timeline
text = xml_score(xml_note(extra='<tie type="start"/>'))
text = text.replace('</measure></part>', '</measure><measure number="2">' + xml_note(extra='<tie type="stop"/>') + '<barline><bar-style>light-heavy</bar-style></barline></measure></part>')
source = parse(text)
index = ScoreIndex(source)
fonts = Fonts(Path('package/assets/fonts/petaluma'))
for start, end in ((0, 4), (4, 8)):
system = make_system(index, start, end, fonts, Style())
self.assertTrue(any(p.role == 'tie' and len(p.sources) == 2 for p in system.primitives))
self.assertEqual([fraction(e['duration']) for e in compile_timeline(source)['events']], [8])
restored = self.round_trip(source)
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
self.assertEqual(restored['staves'][0]['measures'][1]['barlines'], {'right': 'light-heavy'})
def test_microtones_remain_exact_after_transposition_and_export(self):
source = parse(xml_score(xml_note(alter='0.5', extra='<accidental>quarter-sharp</accidental>')))
transposed = transpose_document(source, 'D')