Sizing
ci / checks (push) Successful in 47m22s

This commit is contained in:
khodowany
2026-09-30 02:49:54 -05:00
parent 923b7f14e2
commit 7cad6db980
17 changed files with 1443 additions and 1298 deletions
+6 -4
View File
@@ -79,7 +79,7 @@ Pickups use the corresponding tail of a full-meter rhythm bar. Harmonic state ca
| 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. | | 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. | | Small notes | Grace notes with zero metric duration, cue notes, and measured single/paired tremolos. |
| Staff state | G/F/C, percussion, and no-clef staves; octave clefs and octave-shift spans; major/minor/modal keys or custom seven-letter alterations; timed key, clef, and meter changes. Authored TAB and technical fingering instructions are rejected; TAB is generated from pitched guitar parts. | | Staff state | G/F/C, percussion, and no-clef staves; octave clefs and octave-shift spans; major/minor/modal keys or custom seven-letter alterations; timed key, clef, and meter changes. Authored TAB and technical fingering instructions are rejected; TAB is generated from pitched guitar parts. |
| Meter | Simple, compound, additive, mixed-denominator, and unmetered timelines. A partial measure must be marked `implicit="yes"`; an initial implicit measure is the arrangement pickup. Leading rests are retained. | | Meter | Simple, compound, additive, mixed-denominator, and unmetered timelines. A partial measure must be marked `implicit="yes"`; an initial implicit measure is the arrangement pickup, including a full measure padded with leading rests. |
| Harmony | Structured roots, kinds, inversions, slash basses, and degree additions/alterations/subtractions. Harmony events have their own offsets, including inside held notes. Electric melody generation intentionally reattacks eligible long notes at changed harmonies; import preserves the authored notes. | | Harmony | Structured roots, kinds, inversions, slash basses, and degree additions/alterations/subtractions. Harmony events have their own offsets, including inside held notes. Electric melody generation intentionally reattacks eligible long notes at changed harmonies; import preserves the authored notes. |
| Text | Title, composer, part name, multiple lyric verses with syllabification, elisions, and extenders, rehearsal labels at measure starts, and Vamp directives. | | 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. | | 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. |
@@ -105,6 +105,8 @@ There is no repeat analyzer, expansion pass, or repeat/ending engraver. Performa
Repeat signs, numbered endings, D.C./D.S./coda navigation, and `time-only` ties fail import rather than being ignored or expanded. Write the intended notes and rests explicitly, including any alternate ending. Legacy repeat fields in generated musical documents are rejected; regenerate assets instead of retaining a compatibility interpreter. Full leading-rest measures remain full measures, not inferred pickups. Missing titles fall back to filenames and missing composer credits remain empty, as with the current Soundslice exports. Repeat signs, numbered endings, D.C./D.S./coda navigation, and `time-only` ties fail import rather than being ignored or expanded. Write the intended notes and rests explicitly, including any alternate ending. Legacy repeat fields in generated musical documents are rejected; regenerate assets instead of retaining a compatibility interpreter. Full leading-rest measures remain full measures, not inferred pickups. Missing titles fall back to filenames and missing composer credits remain empty, as with the current Soundslice exports.
An explicitly marked opening pickup is excluded from the repeated song body. At each vocal or electric melody entry, it is right-aligned within the preceding arrangement bar. Outgoing music continues until the pickup's first sounding note, then releases as the incoming melody takes over; pickup rests do not erase earlier accompaniment. Lyrics, ties into the following downbeat, tuplets, and octave spans retain their timing. Pickup harmony changes also apply to the accompanying parts at their relocated offsets. All parts share the same bar count, and a pickup longer than its destination bar fails generation. Autumn Leaves uses a four-beat padded pickup and a 32-bar body, yielding 176 arrangement bars rather than 181.
Ordinary ties still link adjacent matching tones across measures and systems. Tuplets, independent voices, local staff changes, and manual page navigation are unchanged. Audio sample sustain loops remain an implementation detail for holding a single note, not a score-repeat feature. Ordinary ties still link adjacent matching tones across measures and systems. Tuplets, independent voices, local staff changes, and manual page navigation are unchanged. Audio sample sustain loops remain an implementation detail for holding a single note, not a score-repeat feature.
### Runtime Schema ### Runtime Schema
@@ -165,7 +167,7 @@ When grace groups steal from both ends of one donor, their adjustments accumulat
At runtime the shared on-demand score loader serves both display and audio. A background worker validates and loads only the selected score's samples, converts absolute beat positions to rounded sample frames at the menu BPM, and prepares a Sonus sequence. Pitch does not change with BPM, and rounding absolute positions avoids cumulative timing drift. The I/O loop publishes the prepared sequence and polls completion without doing disk I/O or musical interpretation. Sonus mixes preallocated voices from immutable PCM and retires them on control threads; the callback never synthesizes tones or loads files. Limits are one million events, 512 distinct samples, 64 MiB decoded samples per selection, 64 simultaneous voices including releases, and 24 hours at the selected BPM. Exceeding them reports an error rather than dropping notes. At runtime the shared on-demand score loader serves both display and audio. A background worker validates and loads only the selected score's samples, converts absolute beat positions to rounded sample frames at the menu BPM, and prepares a Sonus sequence. Pitch does not change with BPM, and rounding absolute positions avoids cumulative timing drift. The I/O loop publishes the prepared sequence and polls completion without doing disk I/O or musical interpretation. Sonus mixes preallocated voices from immutable PCM and retires them on control threads; the callback never synthesizes tones or loads files. Limits are one million events, 512 distinct samples, 64 MiB decoded samples per selection, 64 simultaneous voices including releases, and 24 hours at the selected BPM. Exceeding them reports an error rather than dropping notes.
`audio.playback_enabled` defaults to false. The obsolete `track_enabled` setting is rejected with a diagnostic naming its replacement. Playback suppresses the metronome without changing its stored toggle. Authored tempo marks remain notation; menu BPM is constant throughout a session. Selecting a part starts a new session. Manual page changes never restart or seek. Natural completion, including trailing silence/release, returns to the corresponding part menu with its selection preserved. Leaving the score cancels playback; stale results cannot close a newer score. Errors remain visible rather than being mistaken for completion. `audio.playback_enabled` defaults to false. The obsolete `track_enabled` setting is rejected with a diagnostic naming its replacement. Playback suppresses the metronome without changing its stored toggle. Authored tempo data remains in the musical document but is not printed; menu BPM is constant throughout a session. Selecting a part starts a new session. Manual page changes never restart or seek. Natural completion, including trailing silence/release, returns to the corresponding part menu with its selection preserved. Leaving the score cancels playback; stale results cannot close a newer score. Errors remain visible rather than being mistaken for completion.
`--once` additionally validates referenced audio without opening display/audio hardware. Runtime packages contain WAVs alongside pages and the SQLite catalog; the source XML and Python dependencies remain build-only. `--once` additionally validates referenced audio without opening display/audio hardware. Runtime packages contain WAVs alongside pages and the SQLite catalog; the source XML and Python dependencies remain build-only.
@@ -183,11 +185,11 @@ Voices retain separate stems and collision offsets; chord tones retain their own
Generated guitar tones carry `tablature: {string, fret}`; their staff is marked `tablature: true`. `engraving/tablature.py` draws a six-line TAB view below standard notation, sharing onset columns, barlines, and page breaks. Both views belong to one system and never duplicate the semantic timeline or playback events. Standard notation carries rhythm, rests, and tuplets; TAB carries positions, solo slashes, and tied continuations without rest glyphs. Strings and frets are validated against sounding pitch, and the layout audit checks each visible TAB numeral's position and alignment. Generated guitar tones carry `tablature: {string, fret}`; their staff is marked `tablature: true`. `engraving/tablature.py` draws a six-line TAB view below standard notation, sharing onset columns, barlines, and page breaks. Both views belong to one system and never duplicate the semantic timeline or playback events. Standard notation carries rhythm, rests, and tuplets; TAB carries positions, solo slashes, and tied continuations without rest glyphs. Strings and frets are validated against sounding pitch, and the layout audit checks each visible TAB numeral's position and alignment.
Every part uses two music systems per page; only an odd final system occupies a row alone. Pagination reserves two rows below the header, keeping a fixed gap and the footer clear. Both systems on a page share a uniform scale when their measured height exceeds a row; notation, TAB, text, and anchors scale together without changing musical content. Guitar layout starts with 10-pixel notation spacing, 10.5-pixel TAB spacing, 12-pixel fret labels, and a 14-pixel gap between systems. The unscaled TAB spacing accommodates Petaluma numerals up to 9.75 pixels tall. The layout audit accounts for each system's scale when verifying pitch and TAB alignment. Existing `.pages/*.png` preview files are the exact packaged page textures. Every part uses two music systems per page; only an odd final system occupies a row alone. A compact title/composer/part header reserves the same height on every page, wrapping credits if necessary. Pagination fits the two systems using their individual heights and distributes extra whitespace around them while retaining minimum system and header/footer clearances. One uniform scale applies throughout the entire part, reducing the ordinary notation size only when required by the tallest pair. Notation, TAB, text, and anchors scale together without changing musical content. Guitar layout starts with 10-pixel notation spacing, 10.5-pixel TAB spacing, 12-pixel fret labels, and a 14-pixel minimum gap between systems. The unscaled TAB spacing accommodates Petaluma numerals up to 9.75 pixels tall. Tempo markings consume neither ink nor spacing; their semantic data is preserved. Existing `.pages/*.png` preview files are the exact packaged page textures.
Line breaks occur only at measure boundaries. Each line fits as many complete measures as readable spacing allows; it never fills remaining space with a partial measure. A complete measure exceeding the available width or height fails with source and measure context instead of being split or squeezed to fit. Authored ties, beams, tuplets, and lyric spans can continue across whole-measure line breaks. Column measurement and fit trials reuse a score index and bounded column cache. Page placement then re-spaces columns for the final height-fitting scale, keeping every notation/TAB staff at the same physical left and right margins without stretching glyphs. Non-final lines are justified; a short final line retains natural note spacing while staff lines extend to the same right margin. Layout audits enforce these margins as well as musical coverage. Line breaks occur only at measure boundaries. Each line fits as many complete measures as readable spacing allows; it never fills remaining space with a partial measure. A complete measure exceeding the available width or height fails with source and measure context instead of being split or squeezed to fit. Authored ties, beams, tuplets, and lyric spans can continue across whole-measure line breaks. Column measurement and fit trials reuse a score index and bounded column cache. Page placement then re-spaces columns for the final height-fitting scale, keeping every notation/TAB staff at the same physical left and right margins without stretching glyphs. Non-final lines are justified; a short final line retains natural note spacing while staff lines extend to the same right margin. Layout audits enforce these margins as well as musical coverage.
`audit.py` checks the final layout against the musical model: timeline and fragment coverage, exact fragment durations, actual glyph bounds, pitch positions, visible note primitives, required accidentals, ties, lyrics, harmonies, tempos and spans. It rejects out-of-page geometry and collisions among unrelated noteheads, accidentals and same-lane text. Staff, ledger, stem, beam and tie contacts are intentional. This is not a proof of publication-quality engraving for every possible MusicXML document; notation changes need targeted semantic, geometry and reviewed image tests. The writer's XML round-trip audit is retained separately in the test suite. `audit.py` checks the final layout against the musical model: timeline and fragment coverage, exact fragment durations, actual glyph bounds, pitch positions, visible note primitives, required accidentals, ties, lyrics, harmonies and spans. It enforces consistent notation size throughout a part and header/footer clearance, and rejects out-of-page geometry and collisions among unrelated noteheads, accidentals and same-lane text. Staff, ledger, stem, beam and tie contacts are intentional. This is not a proof of publication-quality engraving for every possible MusicXML document; notation changes need targeted semantic, geometry and reviewed image tests. The writer's XML round-trip audit is retained separately in the test suite.
Bundles preserve the complete `music` object. Presentation identifies the `Vamp Python` engraver; its style fingerprint includes the style settings, both font files, font-specific metadata and the glyph-name mapping. Scores, playback manifests, and the catalog have no numbered schema labels. Required fields, types, relationships, dimensions, and checksums determine whether generated data is usable. Incompatible data is rejected, never migrated or guessed. Bundles preserve the complete `music` object. Presentation identifies the `Vamp Python` engraver; its style fingerprint includes the style settings, both font files, font-specific metadata and the glyph-name mapping. Scores, playback manifests, and the catalog have no numbered schema labels. Required fields, types, relationships, dimensions, and checksums determine whether generated data is usable. Incompatible data is rejected, never migrated or guessed.
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -15,7 +15,7 @@
</score-part> </score-part>
</part-list> </part-list>
<part id="P1"> <part id="P1">
<measure number="1"> <measure number="1" implicit="yes">
<attributes> <attributes>
<divisions>4</divisions> <divisions>4</divisions>
<key> <key>
+23 -36
View File
@@ -7,6 +7,7 @@ import pathlib
import generator.accompaniment import generator.accompaniment
import generator.acoustic import generator.acoustic
import generator.arrange.form import generator.arrange.form
import generator.arrange.pickup
import generator.electric import generator.electric
import generator.guitar import generator.guitar
import generator.melody import generator.melody
@@ -23,21 +24,25 @@ def arrange_document(document: dict, part_type: str, *, rhythms: generator.rhyth
plans = plan_arrangement(document, rhythms) if part_type != 'vocal' else {} plans = plan_arrangement(document, rhythms) if part_type != 'vocal' else {}
return _arrange_document(document, part_type, plans) return _arrange_document(document, part_type, plans)
def section_source(source: list[dict], final: bool) -> list[dict]: def section_source(source: list[dict], section, next_section=None) -> list[dict]:
result = copy.deepcopy(source) result = copy.deepcopy(source)
for measure in result: for measure in result:
measure['harmonies'] = [h for h in measure['harmonies'] if not final or not h.get('chord_optional')] measure['harmonies'] = [h for h in measure['harmonies'] if section.section_index != FINAL_SECTION_INDEX or not h.get('chord_optional')]
for chord in measure['harmonies']: for chord in measure['harmonies']:
chord.pop('chord_optional', None) chord.pop('chord_optional', None)
if generator.arrange.pickup.enters(next_section, source):
destination = result[section.source_measure_index + section.measure_count - 1]
generator.arrange.pickup.harmonies(destination, result[0])
return result return result
def plan_arrangement(document: dict, rhythms: generator.rhythm.Library) -> dict[int, generator.accompaniment.Plan]: def plan_arrangement(document: dict, rhythms: generator.rhythm.Library) -> dict[int, generator.accompaniment.Plan]:
source = generator.score.source_staff(document)['measures'] source = generator.score.source_staff(document)['measures']
result = {} result = {}
for section in generator.arrange.form.full_arrangement_sections(source, 'acoustic'): sections = generator.arrange.form.full_arrangement_sections(source, 'acoustic')
for index, section in enumerate(sections):
if section.role == 'slash': if section.role == 'slash':
continue continue
measures = section_source(source, section.section_index == FINAL_SECTION_INDEX) measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None)
previous = generator.accompaniment.carried_chords(measures) previous = generator.accompaniment.carried_chords(measures)
start = section.source_measure_index start = section.source_measure_index
result[section.section_index] = generator.accompaniment.plan_section(measures[start:start + section.measure_count], rhythms, initial=previous[start], electric=section.section_index in (1, 5, 6)) result[section.section_index] = generator.accompaniment.plan_section(measures[start:start + section.measure_count], rhythms, initial=previous[start], electric=section.section_index in (1, 5, 6))
@@ -56,8 +61,8 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
for m in source: for m in source:
source_starts.append(source_time) source_starts.append(source_time)
source_time += generator.model.fraction(m['duration']) source_time += generator.model.fraction(m['duration'])
for section in sections: for index, section in enumerate(sections):
section_measures = section_source(source, section.section_index == FINAL_SECTION_INDEX) section_measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None)
previous = generator.accompaniment.carried_chords(section_measures) previous = generator.accompaniment.carried_chords(section_measures)
segment = [] segment = []
for si in range(section.source_measure_index, section.source_measure_index + section.measure_count): for si in range(section.source_measure_index, section.source_measure_index + section.measure_count):
@@ -70,7 +75,6 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
segment.append(result) segment.append(result)
if part_type == 'electric' and section.role in ('melody', 'accompaniment'): if part_type == 'electric' and section.role in ('melody', 'accompaniment'):
voicing_origins.append(generator.electric.MeasureOrigin(source_starts[si], section.role, result)) voicing_origins.append(generator.electric.MeasureOrigin(source_starts[si], section.role, result))
identity_segment = segment
segment_length = sum((generator.model.fraction(m['duration']) for m in segment), fractions.Fraction()) segment_length = sum((generator.model.fraction(m['duration']) for m in segment), fractions.Fraction())
if section.role in ('vocal', 'melody'): if section.role in ('vocal', 'melody'):
start = source_starts[section.source_measure_index] start = source_starts[section.source_measure_index]
@@ -78,42 +82,25 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
low, high = max(start, generator.model.fraction(shift['start'])), min(start + segment_length, generator.model.fraction(shift['end'])) low, high = max(start, generator.model.fraction(shift['start'])), min(start + segment_length, generator.model.fraction(shift['end']))
if low < high: if low < high:
octave_shifts.append({**shift, 'id': f'section-{section.section_index}-{shift["id"]}', 'start': generator.model.rational(absolute + low - start), 'end': generator.model.rational(absolute + high - start)}) octave_shifts.append({**shift, 'id': f'section-{section.section_index}-{shift["id"]}', 'start': generator.model.rational(absolute + low - start), 'end': generator.model.rational(absolute + high - start)})
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 = None
pickup = generator.parts.build_measure(source[0], source[0]['id'], 'vocal', part_type, None) if section.role in ('vocal', 'melody') and generator.arrange.pickup.enters(section, source) and output:
pickup = generator.parts.build_measure(source[0], f'pickup-{section.section_index}', section.role, part_type, None)
tuplets.extend(generator.score.reidentify(([pickup] if pickup else []) + segment, staff.get('tuplets', [])))
if pickup:
destination = output[-1] destination = output[-1]
pad = generator.model.fraction(destination['duration']) - generator.model.fraction(pickup['duration']) pad = generator.arrange.pickup.offset(destination, pickup)
if pad < 0: onset = generator.arrange.pickup.splice(destination, pickup)
raise ValueError('pickup is longer than the preceding arrangement measure') if part_type == 'electric':
voices = generator.score.copy_source_voices(pickup, strip_lyrics=False) voicing_origins = [generator.electric.MeasureOrigin(o.onset, o.role, o.measure, o.start, onset if o.measure is destination else o.end) for o in voicing_origins]
for voice in voices: voicing_origins.append(generator.electric.MeasureOrigin(-pad, 'melody', destination, onset))
for n in voice['notes']:
n['onset'] = generator.model.rational(generator.model.fraction(n['onset']) + pad)
if pad:
voice['notes'].insert(0, generator.model.make_event(f'pickup-rest-{voice["id"]}', fractions.Fraction(), pad, 'rest') | {'voice': voice['id']})
destination['voices'] = voices
identity_segment = [destination, *segment]
if pickup['harmonies']:
destination['harmonies'] = [h for h in destination['harmonies'] if generator.model.fraction(h['onset']) < pad]
for h in pickup['harmonies']:
h['onset'] = generator.model.rational(generator.model.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': generator.model.rational(pad), **state})
else:
destination.update(state)
destination['key_signature'] = generator.model.key_label(state['key'])
destination['time_signature'] = generator.model.meter_label(state['meter'])
for change in pickup.get('attributes', []):
destination['attributes'].append({**change, 'onset': generator.model.rational(generator.model.fraction(change['onset']) + pad)})
for shift in staff.get('octave_shifts', []): for shift in staff.get('octave_shifts', []):
low, high = generator.model.fraction(shift['start']), min(generator.model.fraction(pickup['duration']), generator.model.fraction(shift['end'])) low, high = max(onset - pad, generator.model.fraction(shift['start'])), min(generator.model.fraction(pickup['duration']), generator.model.fraction(shift['end']))
if low < high: if low < high:
offset = absolute - generator.model.fraction(pickup['duration']) offset = absolute - generator.model.fraction(pickup['duration'])
octave_shifts.append({**shift, 'id': f'pickup-{section.section_index}-{shift["id"]}', 'start': generator.model.rational(offset + low), 'end': generator.model.rational(offset + high)}) octave_shifts.append({**shift, 'id': f'pickup-{section.section_index}-{shift["id"]}', 'start': generator.model.rational(offset + low), 'end': generator.model.rational(offset + high)})
tuplets.extend(generator.score.reidentify(identity_segment, staff.get('tuplets', [])))
output.extend(segment) output.extend(segment)
absolute += segment_length absolute += segment_length
tuplets = generator.arrange.pickup.retain_links(output, tuplets)
result = generator.score.make_arranged_document(document, staff, staff_id='staff-arranged-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts) result = generator.score.make_arranged_document(document, staff, staff_id='staff-arranged-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts)
result['metadata'].setdefault('tempo', 124) result['metadata'].setdefault('tempo', 124)
if part_type != 'vocal': if part_type != 'vocal':
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
import copy
import fractions
import generator.accompaniment
import generator.model
import generator.score
def enters(section, source: list[dict]) -> bool:
return section is not None and section.section_index in (1, 2, 5) and section.source_measure_index == 1 and generator.score.leading_pickup_measure_index(source) == 0
def offset(destination: dict, pickup: dict) -> fractions.Fraction:
pad = generator.model.fraction(destination['duration']) - generator.model.fraction(pickup['duration'])
if pad < 0:
raise ValueError(f'measure {destination["number"]}: pickup is longer than the preceding arrangement measure')
return pad
def harmonies(destination: dict, pickup: dict) -> None:
pad = offset(destination, pickup)
if pickup['harmonies']:
first = pad + generator.model.fraction(pickup['harmonies'][0]['onset'])
destination['harmonies'] = [h for h in destination['harmonies'] if generator.model.fraction(h['onset']) < first]
destination['harmonies'].extend({**copy.deepcopy(h), 'onset': generator.model.rational(pad + generator.model.fraction(h['onset']))} for h in pickup['harmonies'])
def truncate(note: dict, end: fractions.Fraction) -> None:
duration = end - generator.model.fraction(note['onset'])
if generator.model.fraction(note['duration']) <= duration:
return
note.update(duration=generator.model.rational(duration), written=generator.model.written_for(duration))
for field in ('measure_rest', 'beams'):
note.pop(field, None)
for pitch in note['pitches']:
if generator.model.fraction(pitch['duration']) > duration:
pitch.update(duration=note['duration'], written=copy.deepcopy(note['written']))
pitch.pop('tie_to', None)
def splice(destination: dict, pickup: dict) -> fractions.Fraction:
"""Keep outgoing music until the pickup's first sounding event, then hand over."""
pad = offset(destination, pickup)
sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(pickup) if n['pitches']]
if not sounding:
return generator.model.fraction(destination['duration'])
start = pad + min(sounding)
voices = {v['id']: {'id': v['id'], 'notes': []} for v in pickup['voices']}
primary = pickup['voices'][0]['id']
for voice in destination['voices']:
identifier = primary if len(destination['voices']) == 1 and voice['id'] not in voices else voice['id']
notes = voices.setdefault(identifier, {'id': identifier, 'notes': []})['notes']
for note in voice['notes']:
if generator.model.fraction(note['onset']) < start:
truncate(note, start)
note['voice'] = identifier
notes.append(note)
for voice in pickup['voices']:
notes = voices[voice['id']]['notes']
for note in voice['notes']:
onset = pad + generator.model.fraction(note['onset'])
end = onset + generator.model.fraction(note['duration'])
if onset < start:
if end <= start:
continue
note.update(duration=generator.model.rational(end - start), written=generator.model.written_for(end - start))
note.pop('measure_rest', None)
onset = start
note['onset'] = generator.model.rational(onset)
notes.append(note)
generator.accompaniment.normalize_beams(notes)
destination['voices'] = [voice for voice in voices.values() if voice['notes']]
state = {k: copy.deepcopy(pickup[k]) for k in ('key', 'clef', 'meter', 'transpose', 'staff_details')}
for change in pickup.get('attributes', []):
if pad + generator.model.fraction(change['onset']) <= start:
state.update({k: copy.deepcopy(v) for k, v in change.items() if k != 'onset'})
destination['attributes'] = [a for a in destination.get('attributes', []) if generator.model.fraction(a['onset']) < start]
previous = dict(destination)
for change in destination['attributes']:
previous.update(change)
state = {k: v for k, v in state.items() if previous.get(k) != v}
if start and state:
destination['attributes'].append({'onset': generator.model.rational(start), **state})
elif not start:
destination.update(state)
destination['key_signature'] = generator.model.key_label(destination['key'])
destination['time_signature'] = generator.model.meter_label(destination['meter'])
destination['attributes'].extend({**copy.deepcopy(a), 'onset': generator.model.rational(pad + generator.model.fraction(a['onset']))} for a in pickup.get('attributes', []) if pad + generator.model.fraction(a['onset']) > start)
return start
def retain_links(measures: list[dict], tuplets: list[dict]) -> list[dict]:
notes = [n for m in measures for n in generator.score.measure_notes(m)]
tones = {p['id']: p for n in notes for p in n['pitches']}
for pitch in tones.values():
for field, reverse in (('tie_to', 'tie_from'), ('tie_from', 'tie_to')):
if field in pitch and tones.get(pitch[field], {}).get(reverse) != pitch['id']:
pitch.pop(field)
members = {}
for note in notes:
for identifier in note.get('tuplet_ids', []):
members.setdefault(identifier, []).append(note['id'])
result = []
for span in tuplets:
if events := members.get(span['id']):
result.append({**span, 'start': events[0], 'end': events[-1], 'continued_start': span.get('continued_start', False) or span['start'] != events[0], 'continued_end': span.get('continued_end', False) or span['end'] != events[-1]})
return result
+5 -2
View File
@@ -14,6 +14,8 @@ class MeasureOrigin:
onset: fractions.Fraction onset: fractions.Fraction
role: str role: str
measure: dict measure: dict
start: fractions.Fraction = fractions.Fraction()
end: fractions.Fraction | None = None
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class VoicingLink: class VoicingLink:
@@ -33,9 +35,10 @@ def link_voicings(source: list[dict], origins: list[MeasureOrigin]) -> list[Voic
for origin in origins: for origin in origins:
for voice in origin.measure['voices']: for voice in origin.measure['voices']:
for note in voice['notes']: for note in voice['notes']:
if '_harmony' not in note: local = generator.model.fraction(note['onset'])
if '_harmony' not in note or local < origin.start or (origin.end is not None and local >= origin.end):
continue continue
onset = origin.onset + generator.model.fraction(note['onset']) onset = origin.onset + local
span = bisect.bisect_right(boundaries, onset) span = bisect.bisect_right(boundaries, onset)
if origin.role == 'melody' and note.get('_harmonize'): if origin.role == 'melody' and note.get('_harmonize'):
melody.setdefault(span, []).append((onset, note['_harmony'], note)) melody.setdefault(span, []).append((onset, note['_harmony'], note))
-19
View File
@@ -101,22 +101,3 @@ def barline(style: str, x: float, top: float, source: str) -> list[generator.eng
return [generator.engraving.types.line('barline', references, ((x, top - 4 if style == 'tick' else top * 0.75), (x, top + 5 if style == 'tick' else top * 0.25)))] return [generator.engraving.types.line('barline', references, ((x, top - 4 if style == 'tick' else top * 0.75), (x, top + 5 if style == 'tick' else top * 0.25)))]
weights = {'regular': [1.4], 'heavy': [4], 'light-light': [1.4, 1.4], 'light-heavy': [1.4, 4], 'heavy-light': [4, 1.4], 'heavy-heavy': [4, 4]}[style] weights = {'regular': [1.4], 'heavy': [4], 'light-light': [1.4, 1.4], 'light-heavy': [1.4, 4], 'heavy-light': [4, 1.4], 'heavy-heavy': [4, 4]}[style]
return [generator.engraving.types.line('barline', references, ((x - (len(weights) - 1 - i) * 5, top), (x - (len(weights) - 1 - i) * 5, 0)), width) for i, width in enumerate(weights)] return [generator.engraving.types.line('barline', references, ((x - (len(weights) - 1 - i) * 5, top), (x - (len(weights) - 1 - i) * 5, 0)), width) for i, width in enumerate(weights)]
def tempo(value: dict, fonts: generator.engraving.fonts.Fonts, x: float, y: float, space: float, source: str) -> list[generator.engraving.types.Primitive]:
written = value['written']
references = (source,)
glyph = fonts.glyph(generator.engraving.notation.head_glyph(written, None), x, y, space * 0.7, 'tempo', references)
items = [glyph]
stem_x = glyph.box.right - 0.5
if written['type'] not in ('whole', 'breve', 'long', 'maxima'):
items.append(generator.engraving.types.line('tempo', references, ((stem_x, y), (stem_x, y - 23)), 1))
if generator.engraving.notation.flags(written):
name = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + 'Up'
items.append(fonts.glyph(name, stem_x, y - 23, space * 0.7, 'tempo', references))
cursor = max(p.box.right for p in items) + 3
for _ in range(written.get('dots', 0)):
dot = fonts.glyph('augmentationDot', cursor, y - 3, space * 0.7, 'tempo', references)
items.append(dot)
cursor = dot.box.right + 3
items.append(fonts.text(' = ' + str(generator.model.fraction(value['bpm'])), cursor, y + 3, 16, 'tempo', references))
return items
+7 -3
View File
@@ -23,16 +23,23 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
index = generator.engraving.notation.ScoreIndex(document) index = generator.engraving.notation.ScoreIndex(document)
fragments, primitives = {}, {} fragments, primitives = {}, {}
previous_end = fractions.Fraction() previous_end = fractions.Fraction()
scale = layout.pages[0].systems[0].scale
for page in layout.pages: for page in layout.pages:
if len(page.systems) != 2 and (page is not layout.pages[-1] or len(page.systems) != 1): if len(page.systems) != 2 and (page is not layout.pages[-1] or len(page.systems) != 1):
raise generator.engraving.types.EngravingError('pages must contain two music systems, except an odd final system') raise generator.engraving.types.EngravingError('pages must contain two music systems, except an odd final system')
if len(page.systems) == 2 and page.systems[0].box.bottom + layout.style.system_gap > page.systems[1].box.top + 0.01: if len(page.systems) == 2 and page.systems[0].box.bottom + layout.style.system_gap > page.systems[1].box.top + 0.01:
raise generator.engraving.types.EngravingError('music systems overlap their page rows') raise generator.engraving.types.EngravingError('music systems overlap their page rows')
audit_primitives(page.primitives, page.width, page.height, fonts) audit_primitives(page.primitives, page.width, page.height, fonts)
header = generator.engraving.types.bounds([p for p in page.primitives if p.role in ('title', 'subtitle')])
footer = generator.engraving.types.bounds([p for p in page.primitives if p.role == 'page-number'])
if page.systems[0].box.top < header.bottom + layout.style.page_clearance - 0.01 or page.systems[-1].box.bottom > footer.top - layout.style.page_clearance + 0.01:
raise generator.engraving.types.EngravingError('music overlaps the page header or footer')
for item in page.primitives: for item in page.primitives:
for source in item.sources: for source in item.sources:
primitives.setdefault(source, []).append(item) primitives.setdefault(source, []).append(item)
for system in page.systems: for system in page.systems:
if system.scale != scale:
raise generator.engraving.types.EngravingError('notation size must remain consistent throughout a part')
for role in ('staff', 'tab-staff'): for role in ('staff', 'tab-staff'):
lines = [p for p in system.primitives if p.role == role] lines = [p for p in system.primitives if p.role == role]
if lines: if lines:
@@ -123,9 +130,6 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
label = generator.harmony.symbol(value) label = generator.harmony.symbol(value)
if not any(p.role == 'harmony' and p.value == label for p in primitives.get(value['id'], [])): if not any(p.role == 'harmony' and p.value == label for p in primitives.get(value['id'], [])):
raise generator.engraving.types.EngravingError(f'{value["id"]}: harmony was omitted') raise generator.engraving.types.EngravingError(f'{value["id"]}: harmony was omitted')
for i, _ in enumerate(measure.get('tempos', [])):
if not any(p.role == 'tempo' for p in primitives.get(measure['id'] + f':tempo:{i}', [])):
raise generator.engraving.types.EngravingError(f'{measure["id"]}: tempo was omitted')
for span in index.staff.get('tuplets', []): for span in index.staff.get('tuplets', []):
if span.get('show_number', 'actual') != 'none' or span.get('bracket', 'auto') != 'no': if span.get('show_number', 'actual') != 'none' or span.get('bracket', 'auto') != 'no':
if not any(p.role.startswith('tuplet-') for p in primitives.get(span['id'], [])): if not any(p.role.startswith('tuplet-') for p in primitives.get(span['id'], [])):
+65 -38
View File
@@ -54,9 +54,6 @@ def column(index: generator.engraving.notation.ScoreIndex, time: fractions.Fract
for owner, _, item in index.harmonies_by_onset.get(time, []): for owner, _, item in index.harmonies_by_onset.get(time, []):
if owner is measure: if owner is measure:
items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 18 if index.staff.get('tablature') else 21, 'harmony', (item['id'],))) items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 18 if index.staff.get('tablature') else 21, 'harmony', (item['id'],)))
for owner, i, value in index.tempos_by_onset.get(time, []):
if owner is measure:
items.extend(generator.engraving.attributes.tempo(value, fonts, 0, 0, style.space, measure['id'] + f':tempo:{i}'))
if time == measure_start: if time == measure_start:
items.append(fonts.text(str(measure['number']), 0, -style.space * (index.state(time)['staff_details']['lines'] + 0.5), 12, 'measure-number', (measure['id'],))) items.append(fonts.text(str(measure['number']), 0, -style.space * (index.state(time)['staff_details']['lines'] + 0.5), 12, 'measure-number', (measure['id'],)))
if label := measure.get('section_label'): if label := measure.get('section_label'):
@@ -159,54 +156,84 @@ def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions
items = generator.engraving.tablature.staff(index, items + tab_items, start, end, content_left, width - 1, fonts, style) items = generator.engraving.tablature.staff(index, items + tab_items, start, end, content_left, width - 1, fonts, style)
return generator.engraving.types.System(start, end, tuple(items), tuple(anchors), tuple(fragments), generator.engraving.types.bounds(items)) return generator.engraving.types.System(start, end, tuple(items), tuple(anchors), tuple(fragments), generator.engraving.types.bounds(items))
def fit_page_systems(systems, index, row_height, fonts, style): def fit_part_systems(systems, index, available_height, fonts, style):
scale = min(1, row_height / max(s.box.bottom - s.box.top for s in systems))
def fitting_scale(items):
scale = 1
for first in range(0, len(items), 2):
pair = items[first:first + 2]
height = sum(s.box.bottom - s.box.top for s in pair)
scale = min(scale, (available_height - style.system_gap * (len(pair) - 1)) / height)
return scale
scale = fitting_scale(systems)
columns = collections.OrderedDict() columns = collections.OrderedDict()
# Re-space columns at the final physical width; scale glyphs uniformly, not horizontally. # Fit the entire part together; only spacing, never page-specific glyph sizes, varies.
for _ in range(8): for _ in range(8):
width = (style.width - 2 * style.margin_x - 2) / scale width = (style.width - 2 * style.margin_x - 2) / scale
fitted = [make_system(index, s.start, s.end, fonts, style, justify=s.end < index.end, columns=columns, staff_width=width) for s in systems] fitted = [make_system(index, s.start, s.end, fonts, style, justify=s.end < index.end, columns=columns, staff_width=width) for s in systems]
height = max(s.box.bottom - s.box.top for s in fitted) next_scale = fitting_scale(fitted)
if height * scale <= row_height + 0.001: if scale <= next_scale + 0.000001:
return [s.scaled(scale) for s in fitted] return [s.scaled(scale) for s in fitted]
scale = row_height / height scale = next_scale
raise generator.engraving.types.EngravingError(f'at musical offset {systems[0].start}: page spacing did not converge') raise generator.engraving.types.EngravingError('part spacing did not converge')
def part_header(document, fonts, style):
size = 16
title = document['metadata']['title']
item = fonts.text(title, style.margin_x, style.margin_y + size, size, 'title', ())
while item.box.right > style.width - style.margin_x and size > 12:
size -= 1
item = fonts.text(title, style.margin_x, style.margin_y + size, size, 'title', ())
if item.box.right > style.width - style.margin_x:
raise generator.engraving.types.EngravingError('score title cannot fit the page without clipping')
result = [item]
subtitle = ' - '.join(str(value) for value in (document['metadata'].get('composer'), document['staves'][0].get('name')) if value)
if not subtitle:
return result
credit = fonts.text(subtitle, 0, style.margin_y + size, 12, 'subtitle', ())
width = credit.box.right - credit.box.left
if item.box.right + 20 + width <= style.width - style.margin_x:
result.append(credit.moved(style.width - style.margin_x - credit.box.right, 0))
return result
line = ''
baseline = item.box.bottom + 15
for word in subtitle.split():
text = ' '.join(filter(None, (line, word)))
candidate = fonts.text(text, style.margin_x, baseline, 12, 'subtitle', ())
if candidate.box.right > style.width - style.margin_x and line:
result.append(fonts.text(line, style.margin_x, baseline, 12, 'subtitle', ()))
baseline = result[-1].box.bottom + 15
text = word
candidate = fonts.text(text, style.margin_x, baseline, 12, 'subtitle', ())
if candidate.box.right > style.width - style.margin_x:
raise generator.engraving.types.EngravingError('score credits cannot fit the page without clipping')
line = text
result.append(fonts.text(line, style.margin_x, baseline, 12, 'subtitle', ()))
return result
def paginate(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, *, index=None) -> tuple[generator.engraving.types.Page, ...]: def paginate(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, *, index=None) -> tuple[generator.engraving.types.Page, ...]:
pages = [] pages = []
index = index or generator.engraving.notation.ScoreIndex(document) index = index or generator.engraving.notation.ScoreIndex(document)
guitar = document['staves'][0].get('tablature', False) header = part_header(document, fonts, style)
header_gap = 14 if guitar else 24 top = generator.engraving.types.bounds(header).bottom + style.page_clearance
footers = [fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True) for number in range(1, (len(systems) + 1) // 2 + 1)]
def page_header(number): bottom = min(p.box.top for p in footers) - style.page_clearance
title = document['metadata']['title'] if bottom - top <= style.system_gap:
size = (20 if number == 1 else 16) if guitar else (26 if number == 1 else 17) raise generator.engraving.types.EngravingError('page header leaves no room for two music systems')
item = fonts.text(title, style.margin_x, style.margin_y + size, size, 'title', ()) fitted = fit_part_systems(systems, index, bottom - top, fonts, style)
while item.box.right > style.width - style.margin_x and size > 12:
size -= 1
item = fonts.text(title, style.margin_x, style.margin_y + size, size, 'title', ())
if item.box.right > style.width - style.margin_x:
raise generator.engraving.types.EngravingError('score title cannot fit the page without clipping')
result = [item]
if number == 1:
subtitle = ' - '.join(str(value) for value in (document['metadata'].get('composer'), document['staves'][0].get('name')) if value)
if subtitle:
result.append(fonts.text(subtitle, style.margin_x, item.box.bottom + (15 if guitar else 22), 12 if guitar else 16, 'subtitle', ()))
return result
for first in range(0, len(systems), 2): for first in range(0, len(systems), 2):
number = len(pages) + 1 number = len(pages) + 1
header = page_header(number) pair = fitted[first:first + 2]
top = generator.engraving.types.bounds(header).bottom + header_gap slack = max(0, bottom - top - sum(s.box.bottom - s.box.top for s in pair) - style.system_gap * (len(pair) - 1))
row_height = (style.height - style.margin_y - 24 - top - style.system_gap) / 2 spacing = slack / 3 if len(pair) == 2 else 0
if row_height <= 0: cursor = top + spacing
raise generator.engraving.types.EngravingError('page header leaves no room for two music systems')
pair = fit_page_systems(systems[first:first + 2], index, row_height, fonts, style)
placed = [] placed = []
for row, system in enumerate(pair): for system in pair:
dx, dy = style.margin_x + 1, top + row * (row_height + style.system_gap) - system.box.top dx, dy = style.margin_x + 1, cursor - system.box.top
placed.append(dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy))) placed.append(dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy)))
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True) cursor = placed[-1].box.bottom + style.system_gap + spacing
footer = footers[first // 2]
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer]))) pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
return tuple(pages) return tuple(pages)
+1 -3
View File
@@ -29,7 +29,6 @@ class ScoreIndex:
self.events_by_onset = {} self.events_by_onset = {}
self.attributes_by_onset = {} self.attributes_by_onset = {}
self.harmonies_by_onset = {} self.harmonies_by_onset = {}
self.tempos_by_onset = {}
self.by_id = {} self.by_id = {}
self.tones = {} self.tones = {}
self.boundaries = {fractions.Fraction()} self.boundaries = {fractions.Fraction()}
@@ -51,8 +50,7 @@ class ScoreIndex:
self.columns.add(onset) self.columns.add(onset)
for tone in note['pitches']: for tone in note['pitches']:
self.tones[tone['id']] = (event, tone) self.tones[tone['id']] = (event, tone)
for field in ('attributes', 'harmonies', 'tempos'): for field, bucket in (('attributes', self.attributes_by_onset), ('harmonies', self.harmonies_by_onset)):
bucket = {'attributes': self.attributes_by_onset, 'harmonies': self.harmonies_by_onset, 'tempos': self.tempos_by_onset}[field]
for index, item in enumerate(measure.get(field, [])): for index, item in enumerate(measure.get(field, [])):
onset = time + generator.model.fraction(item['onset']) onset = time + generator.model.fraction(item['onset'])
self.columns.add(onset) self.columns.add(onset)
+2 -2
View File
@@ -203,11 +203,11 @@ def draw_spans(index: generator.engraving.notation.ScoreIndex, anchors: list[gen
return items + result return items + result
def place_text_lanes(items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]: def place_text_lanes(items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'tempo')] music = [p for p in items if p.role not in ('harmony', 'lyric', 'section')]
box = generator.engraving.types.bounds(music) box = generator.engraving.types.bounds(music)
result = list(music) result = list(music)
top = box.top - 12 top = box.top - 12
for role in ('harmony', 'tempo', 'section'): for role in ('harmony', 'section'):
lane = [p for p in items if p.role == role] lane = [p for p in items if p.role == role]
if lane: if lane:
extent = generator.engraving.types.bounds(lane) extent = generator.engraving.types.bounds(lane)
+1
View File
@@ -14,6 +14,7 @@ class Style:
space: float = 12 space: float = 12
margin_x: float = 28 margin_x: float = 28
margin_y: float = 18 margin_y: float = 18
page_clearance: float = 14
supersampling: int = 4 supersampling: int = 4
column_gap: float = 10 column_gap: float = 10
system_gap: float = 26 system_gap: float = 26
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 52 KiB

+118
View File
@@ -0,0 +1,118 @@
import copy
import fractions
import pathlib
import unittest
import generator.arrange.form
import generator.arrange.generate
import generator.arrange.pickup
import generator.model
import generator.musicxml
import generator.parts
import generator.playback
import generator.score
import tests.rhythm_fixtures
import tests.test_musicxml
import tests.test_parts
def source(pickup, body=None, *, implicit=True, divisions=1):
body = body or tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=4 * divisions)
xml = tests.test_musicxml.xml_score(pickup, implicit=implicit, divisions=divisions)
return tests.test_musicxml.parse(xml.replace('</measure></part>', '</measure><measure number="2">' + body + '</measure></part>'))
class ArrangementPickupTests(unittest.TestCase):
def test_catalog_body_lengths_and_part_timelines(self):
for path in sorted(pathlib.Path('data/songs').glob('*.xml')):
document = generator.musicxml.load_musicxml(path)
before = copy.deepcopy(document)
prepared = generator.parts.prepare_document(document)
plans = generator.arrange.generate.plan_arrangement(prepared, tests.rhythm_fixtures.library())
timelines = []
for part in generator.parts.PART_TYPES:
with self.subTest(song=path.stem, part=part):
result = generator.arrange.generate._arrange_document(prepared, part, plans)
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 176)
self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 704)
timelines.append([m['duration'] for m in measures])
if path.stem == 'autumn-leaves' and part in ('vocal', 'electric'):
for boundary in ([8, 136] if part == 'vocal' else [40]):
notes = [n for n in generator.score.measure_notes(measures[boundary - 1]) if generator.model.fraction(n['onset']) >= 1 and n['pitches']]
self.assertEqual([generator.model.fraction(n['onset']) for n in notes], [1, 2, 3])
self.assertEqual([n['pitches'][-1]['letter'] for n in notes], ['d', 'e', 'f'])
self.assertTrue(all(n['pitches'][-1]['octave'] == (5 if part == 'electric' else 4) for n in notes))
self.assertFalse(measures[boundary - 1]['attributes'])
self.assertTrue(all(timeline == timelines[0] for timeline in timelines))
self.assertEqual(document, before)
def test_unmarked_leading_rests_are_not_pickups(self):
opening = '<note><rest/><duration>1</duration><type>quarter</type></note>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')
document = source(opening, implicit=False)
result = generator.arrange.generate.arrange_document(document, 'vocal', rhythms=tests.rhythm_fixtures.library())
self.assertEqual(len(result['staves'][0]['measures']), 14)
self.assertEqual(generator.score.body_start_measure_index(document['staves'][0]['measures']), 0)
def test_short_pickup_ties_and_octave_spans_survive_each_melody_entry(self):
document = source(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), tests.test_musicxml.xml_note(extra='<tie type="stop"/>'))
document['staves'][0]['octave_shifts'] = [{'id': 'pickup-shift', 'start': generator.model.rational(0), 'end': generator.model.rational(1), 'octaves': -1}]
for part, boundaries in (('vocal', (1, 5)), ('electric', (2,))):
with self.subTest(part=part):
result = generator.arrange.generate.arrange_document(document, part, rhythms=tests.rhythm_fixtures.library())
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 7)
for boundary in boundaries:
pickup = generator.score.measure_notes(measures[boundary - 1])[-1]
downbeat = generator.score.measure_notes(measures[boundary])[0]
self.assertEqual(generator.model.fraction(pickup['onset']), 3)
self.assertEqual(pickup['pitches'][0]['tie_to'], downbeat['pitches'][0]['id'])
self.assertEqual(downbeat['pitches'][0]['tie_from'], pickup['pitches'][0]['id'])
self.assertTrue(any(generator.model.fraction(s['start']) == boundary * 4 - 1 and generator.model.fraction(s['end']) == boundary * 4 for s in result['staves'][0]['octave_shifts']))
self.assertTrue(generator.playback.compile_timeline(result)['events'])
def test_pickup_truncates_outgoing_music_without_discarding_earlier_notes(self):
opening = '<note><rest/><duration>1</duration><type>quarter</type></note>' + tests.test_musicxml.xml_note('D', 3, 'half', extra='<dot/><lyric><text>doo</text></lyric>')
document = source(opening)
pickup, destination = copy.deepcopy(document['staves'][0]['measures'])
before = copy.deepcopy(destination['voices'][0]['notes'][0])
start = generator.arrange.pickup.splice(destination, pickup)
self.assertEqual(start, 1)
notes = generator.score.measure_notes(destination)
self.assertEqual(len(notes), 2)
self.assertEqual(notes[0]['id'], before['id'])
self.assertEqual(notes[0]['pitches'][0]['id'], before['pitches'][0]['id'])
self.assertEqual(generator.model.fraction(notes[0]['duration']), 1)
self.assertEqual(notes[0]['written']['type'], 'quarter')
self.assertEqual(notes[1]['lyrics'][0]['syllables'][0]['text'], 'doo')
self.assertEqual([generator.model.fraction(n['onset']) for n in notes], [0, 1])
document['staves'][0]['measures'] = [destination]
generator.model.validate_document(document)
def test_pickup_harmonies_are_planned_for_every_part_at_the_same_offset(self):
document = source(tests.test_parts.xml_harmony('F') + tests.test_musicxml.xml_note('F', 1, 'quarter'))
prepared = generator.parts.prepare_document(document)
plans = generator.arrange.generate.plan_arrangement(prepared, tests.rhythm_fixtures.library())
for part in generator.parts.PART_TYPES:
result = generator.arrange.generate._arrange_document(prepared, part, plans)
generator.model.validate_document(result)
for index in (0, 1, 4):
chords = result['staves'][0]['measures'][index]['harmonies']
self.assertEqual([(generator.model.fraction(h['onset']), h['root']['letter']) for h in chords], [(0, 'c'), (3, 'f')])
self.assertIn(fractions.Fraction(3), [event.onset for event in plans[0].acoustic_upper[0].events])
def test_pickup_tuplets_keep_unique_identities_across_iterations(self):
notes = ''
for index, pitch in enumerate(('C', 'D', 'E')):
tuplet = '<notations><tuplet type="start"/></notations>' if index == 0 else '<notations><tuplet type="stop"/></notations>' if index == 2 else ''
notes += tests.test_musicxml.xml_note(pitch, 1, 'eighth', extra='<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes><normal-type>eighth</normal-type></time-modification>' + tuplet)
document = source(notes, divisions=3)
for part in ('vocal', 'electric'):
result = generator.arrange.generate.arrange_document(document, part, rhythms=tests.rhythm_fixtures.library())
generator.model.validate_document(result)
spans = result['staves'][0]['tuplets']
self.assertEqual(len(spans), 2 if part == 'vocal' else 1)
self.assertEqual(len({s['id'] for s in spans}), len(spans))
for span in spans:
members = [n for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m) if span['id'] in n.get('tuplet_ids', [])]
self.assertEqual(len(members), 3)
self.assertEqual([generator.model.fraction(n['onset']) for n in members], [3, fractions.Fraction(10, 3), fractions.Fraction(11, 3)])
def test_overlong_pickup_is_rejected_with_measure_context(self):
document = source(tests.test_musicxml.xml_note(duration=4), '<attributes><time><beats>3</beats><beat-type>4</beat-type></time></attributes>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>'))
with self.assertRaisesRegex(ValueError, 'measure 2: pickup is longer'):
generator.arrange.generate.arrange_document(document, 'vocal', rhythms=tests.rhythm_fixtures.library())
+12
View File
@@ -33,6 +33,18 @@ def shape(event):
return tuple(sorted((p['tablature']['string'], p['tablature']['fret']) for p in event['pitches'])) return tuple(sorted((p['tablature']['string'], p['tablature']['fret']) for p in event['pitches']))
class ElectricVoicingTests(unittest.TestCase): class ElectricVoicingTests(unittest.TestCase):
def test_mixed_pickup_bar_links_each_role_to_its_original_passage(self):
chord = harmony('C7')
pickup = note('pickup', 3, chord, (2, 3, 1, 3), melody=True)
outgoing = note('outgoing', 0, chord, (5, 3, 5, 3))
target = note('target', 0, chord, (10, 9, 11, 8))
mixed = measure([outgoing, pickup])
source = [measure([], [chord]), measure([], [harmony('G7')]), measure([], [chord])]
origins = [generator.electric.MeasureOrigin(fractions.Fraction(8), 'accompaniment', mixed, end=fractions.Fraction(3)), generator.electric.MeasureOrigin(fractions.Fraction(-3), 'melody', mixed, start=fractions.Fraction(3)), generator.electric.MeasureOrigin(fractions.Fraction(0), 'accompaniment', measure([target]))]
links = generator.electric.link_voicings(source, origins)
self.assertEqual(len(links), 1)
self.assertIs(links[0].target, target)
self.assertEqual(links[0].references, (pickup,))
def test_corresponding_melody_shapes_change_within_one_harmony(self): def test_corresponding_melody_shapes_change_within_one_harmony(self):
chord = harmony('C7') chord = harmony('C7')
first = note('melody-1', 1, chord, (2, 3, 1, 3), melody=True, duration=2) first = note('melody-1', 1, chord, (2, 3, 1, 3), melody=True, duration=2)
+29
View File
@@ -49,6 +49,7 @@ class EngravingTests(unittest.TestCase):
def assert_paired_pages(self, layout): def assert_paired_pages(self, layout):
self.assertTrue(all(len(page.systems) == 2 for page in layout.pages[:-1])) self.assertTrue(all(len(page.systems) == 2 for page in layout.pages[:-1]))
self.assertIn(len(layout.pages[-1].systems), (1, 2)) self.assertIn(len(layout.pages[-1].systems), (1, 2))
self.assertEqual(len({system.scale for page in layout.pages for system in page.systems}), 1)
for page in layout.pages: for page in layout.pages:
self.assertEqual(len({system.scale for system in page.systems}), 1) self.assertEqual(len({system.scale for system in page.systems}), 1)
for system in page.systems: for system in page.systems:
@@ -59,6 +60,34 @@ class EngravingTests(unittest.TestCase):
self.assertAlmostEqual(max(x for p in lines for x, _ in p.points), page.width - layout.style.margin_x - 1) self.assertAlmostEqual(max(x for p in lines for x, _ in p.points), page.width - layout.style.margin_x - 1)
for first, second in zip(page.systems, page.systems[1:]): for first, second in zip(page.systems, page.systems[1:]):
self.assertGreaterEqual(second.box.top - first.box.bottom, layout.style.system_gap - 0.001) self.assertGreaterEqual(second.box.top - first.box.bottom, layout.style.system_gap - 0.001)
def test_titles_sections_and_tempos_do_not_change_size_between_pages(self):
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * 40))
source['metadata']['composer'] = 'Composer'
for index in (0, 9, 18, 27):
measure = source['staves'][0]['measures'][index]
measure['section_label'] = f'Section {index}'
measure['tempos'] = [{'onset': generator.model.rational(fractions.Fraction(1, 2)), 'written': {'type': 'quarter', 'dots': 0}, 'bpm': generator.model.rational(124)}]
layout = self.layout(source)
self.assertGreater(len(layout.pages), 2)
self.assert_paired_pages(layout)
self.assertEqual(layout.pages[0].systems[0].scale, 1)
self.assertEqual(self.primitives(layout, 'tempo'), [])
self.assertEqual(len(self.primitives(layout, 'section')), 4)
self.assertEqual(len(self.primitives(layout, 'subtitle')), len(layout.pages))
without_tempos = copy.deepcopy(source)
for measure in without_tempos['staves'][0]['measures']:
measure.pop('tempos', None)
self.assertEqual(layout, self.layout(without_tempos))
self.assertEqual(generator.playback.compile_timeline(source), generator.playback.compile_timeline(without_tempos))
def test_long_credits_wrap_without_overflow_or_page_specific_sizing(self):
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * 32))
source['metadata']['composer'] = 'A moderately long composer credit ' * 6
layout = self.layout(source)
self.assert_paired_pages(layout)
header = [p for p in layout.pages[0].primitives if p.role in ('title', 'subtitle')]
self.assertGreater(len(header), 2)
for page in layout.pages:
self.assertEqual(header, [p for p in page.primitives if p.role in ('title', 'subtitle')])
def test_guitar_pages_always_pair_systems_including_tall_passages(self): def test_guitar_pages_always_pair_systems_including_tall_passages(self):
content = tests.test_musicxml.xml_note('E', octave=5) content = tests.test_musicxml.xml_note('E', octave=5)
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 32)) source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 32))