+6
-4
@@ -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. |
|
||||
| Small notes | Grace notes with zero metric duration, cue notes, and measured single/paired tremolos. |
|
||||
| Staff state | G/F/C, percussion, and no-clef staves; octave clefs and octave-shift spans; major/minor/modal keys or custom seven-letter alterations; timed key, clef, and meter changes. Authored TAB and technical fingering instructions are rejected; TAB is generated from pitched guitar parts. |
|
||||
| Meter | Simple, compound, additive, mixed-denominator, and unmetered timelines. A partial measure must be marked `implicit="yes"`; an initial implicit measure is the arrangement pickup. 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. |
|
||||
| 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. |
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
`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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
+1070
-1190
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@
|
||||
</score-part>
|
||||
</part-list>
|
||||
<part id="P1">
|
||||
<measure number="1">
|
||||
<measure number="1" implicit="yes">
|
||||
<attributes>
|
||||
<divisions>4</divisions>
|
||||
<key>
|
||||
|
||||
@@ -7,6 +7,7 @@ import pathlib
|
||||
import generator.accompaniment
|
||||
import generator.acoustic
|
||||
import generator.arrange.form
|
||||
import generator.arrange.pickup
|
||||
import generator.electric
|
||||
import generator.guitar
|
||||
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 {}
|
||||
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)
|
||||
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']:
|
||||
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
|
||||
|
||||
def plan_arrangement(document: dict, rhythms: generator.rhythm.Library) -> dict[int, generator.accompaniment.Plan]:
|
||||
source = generator.score.source_staff(document)['measures']
|
||||
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':
|
||||
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)
|
||||
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))
|
||||
@@ -56,8 +61,8 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
|
||||
for m in source:
|
||||
source_starts.append(source_time)
|
||||
source_time += generator.model.fraction(m['duration'])
|
||||
for section in sections:
|
||||
section_measures = section_source(source, section.section_index == FINAL_SECTION_INDEX)
|
||||
for index, section in enumerate(sections):
|
||||
section_measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None)
|
||||
previous = generator.accompaniment.carried_chords(section_measures)
|
||||
segment = []
|
||||
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)
|
||||
if part_type == 'electric' and section.role in ('melody', 'accompaniment'):
|
||||
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())
|
||||
if section.role in ('vocal', 'melody'):
|
||||
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']))
|
||||
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)})
|
||||
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 = generator.parts.build_measure(source[0], source[0]['id'], 'vocal', part_type, None)
|
||||
pickup = 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]
|
||||
pad = generator.model.fraction(destination['duration']) - generator.model.fraction(pickup['duration'])
|
||||
if pad < 0:
|
||||
raise ValueError('pickup is longer than the preceding arrangement measure')
|
||||
voices = generator.score.copy_source_voices(pickup, strip_lyrics=False)
|
||||
for voice in voices:
|
||||
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)})
|
||||
pad = generator.arrange.pickup.offset(destination, pickup)
|
||||
onset = generator.arrange.pickup.splice(destination, pickup)
|
||||
if part_type == 'electric':
|
||||
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]
|
||||
voicing_origins.append(generator.electric.MeasureOrigin(-pad, 'melody', destination, onset))
|
||||
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:
|
||||
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)})
|
||||
tuplets.extend(generator.score.reidentify(identity_segment, staff.get('tuplets', [])))
|
||||
output.extend(segment)
|
||||
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['metadata'].setdefault('tempo', 124)
|
||||
if part_type != 'vocal':
|
||||
|
||||
@@ -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
|
||||
@@ -14,6 +14,8 @@ class MeasureOrigin:
|
||||
onset: fractions.Fraction
|
||||
role: str
|
||||
measure: dict
|
||||
start: fractions.Fraction = fractions.Fraction()
|
||||
end: fractions.Fraction | None = None
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class VoicingLink:
|
||||
@@ -33,9 +35,10 @@ def link_voicings(source: list[dict], origins: list[MeasureOrigin]) -> list[Voic
|
||||
for origin in origins:
|
||||
for voice in origin.measure['voices']:
|
||||
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
|
||||
onset = origin.onset + generator.model.fraction(note['onset'])
|
||||
onset = origin.onset + local
|
||||
span = bisect.bisect_right(boundaries, onset)
|
||||
if origin.role == 'melody' and note.get('_harmonize'):
|
||||
melody.setdefault(span, []).append((onset, note['_harmony'], note))
|
||||
|
||||
@@ -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)))]
|
||||
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)]
|
||||
|
||||
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
|
||||
|
||||
@@ -23,16 +23,23 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
|
||||
index = generator.engraving.notation.ScoreIndex(document)
|
||||
fragments, primitives = {}, {}
|
||||
previous_end = fractions.Fraction()
|
||||
scale = layout.pages[0].systems[0].scale
|
||||
for page in layout.pages:
|
||||
if 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')
|
||||
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')
|
||||
audit_primitives(page.primitives, page.width, page.height, fonts)
|
||||
header = generator.engraving.types.bounds([p for p in page.primitives if p.role in ('title', 'subtitle')])
|
||||
footer = generator.engraving.types.bounds([p for p in page.primitives if p.role == 'page-number'])
|
||||
if page.systems[0].box.top < header.bottom + layout.style.page_clearance - 0.01 or page.systems[-1].box.bottom > footer.top - layout.style.page_clearance + 0.01:
|
||||
raise generator.engraving.types.EngravingError('music overlaps the page header or footer')
|
||||
for item in page.primitives:
|
||||
for source in item.sources:
|
||||
primitives.setdefault(source, []).append(item)
|
||||
for system in page.systems:
|
||||
if system.scale != scale:
|
||||
raise generator.engraving.types.EngravingError('notation size must remain consistent throughout a part')
|
||||
for role in ('staff', 'tab-staff'):
|
||||
lines = [p for p in system.primitives if p.role == role]
|
||||
if lines:
|
||||
@@ -123,9 +130,6 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
|
||||
label = generator.harmony.symbol(value)
|
||||
if not any(p.role == 'harmony' and p.value == label for p in primitives.get(value['id'], [])):
|
||||
raise generator.engraving.types.EngravingError(f'{value["id"]}: harmony was omitted')
|
||||
for 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', []):
|
||||
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'], [])):
|
||||
|
||||
@@ -54,9 +54,6 @@ def column(index: generator.engraving.notation.ScoreIndex, time: fractions.Fract
|
||||
for owner, _, item in index.harmonies_by_onset.get(time, []):
|
||||
if owner is measure:
|
||||
items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 18 if index.staff.get('tablature') else 21, 'harmony', (item['id'],)))
|
||||
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:
|
||||
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'):
|
||||
@@ -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)
|
||||
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):
|
||||
scale = min(1, row_height / max(s.box.bottom - s.box.top for s in systems))
|
||||
def fit_part_systems(systems, index, available_height, fonts, style):
|
||||
|
||||
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()
|
||||
# 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):
|
||||
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]
|
||||
height = max(s.box.bottom - s.box.top for s in fitted)
|
||||
if height * scale <= row_height + 0.001:
|
||||
next_scale = fitting_scale(fitted)
|
||||
if scale <= next_scale + 0.000001:
|
||||
return [s.scaled(scale) for s in fitted]
|
||||
scale = row_height / height
|
||||
raise generator.engraving.types.EngravingError(f'at musical offset {systems[0].start}: page spacing did not converge')
|
||||
scale = next_scale
|
||||
raise generator.engraving.types.EngravingError('part spacing did not converge')
|
||||
|
||||
def part_header(document, fonts, style):
|
||||
size = 16
|
||||
title = document['metadata']['title']
|
||||
item = fonts.text(title, style.margin_x, style.margin_y + size, size, 'title', ())
|
||||
while item.box.right > style.width - style.margin_x and size > 12:
|
||||
size -= 1
|
||||
item = fonts.text(title, style.margin_x, style.margin_y + size, size, 'title', ())
|
||||
if item.box.right > style.width - style.margin_x:
|
||||
raise generator.engraving.types.EngravingError('score title cannot fit the page without clipping')
|
||||
result = [item]
|
||||
subtitle = ' - '.join(str(value) for value in (document['metadata'].get('composer'), document['staves'][0].get('name')) if value)
|
||||
if 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, ...]:
|
||||
pages = []
|
||||
index = index or generator.engraving.notation.ScoreIndex(document)
|
||||
guitar = document['staves'][0].get('tablature', False)
|
||||
header_gap = 14 if guitar else 24
|
||||
|
||||
def page_header(number):
|
||||
title = document['metadata']['title']
|
||||
size = (20 if number == 1 else 16) if guitar else (26 if number == 1 else 17)
|
||||
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]
|
||||
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
|
||||
|
||||
header = part_header(document, fonts, style)
|
||||
top = generator.engraving.types.bounds(header).bottom + style.page_clearance
|
||||
footers = [fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True) for number in range(1, (len(systems) + 1) // 2 + 1)]
|
||||
bottom = min(p.box.top for p in footers) - style.page_clearance
|
||||
if bottom - top <= style.system_gap:
|
||||
raise generator.engraving.types.EngravingError('page header leaves no room for two music systems')
|
||||
fitted = fit_part_systems(systems, index, bottom - top, fonts, style)
|
||||
for first in range(0, len(systems), 2):
|
||||
number = len(pages) + 1
|
||||
header = page_header(number)
|
||||
top = generator.engraving.types.bounds(header).bottom + header_gap
|
||||
row_height = (style.height - style.margin_y - 24 - top - style.system_gap) / 2
|
||||
if row_height <= 0:
|
||||
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)
|
||||
pair = fitted[first:first + 2]
|
||||
slack = max(0, bottom - top - sum(s.box.bottom - s.box.top for s in pair) - style.system_gap * (len(pair) - 1))
|
||||
spacing = slack / 3 if len(pair) == 2 else 0
|
||||
cursor = top + spacing
|
||||
placed = []
|
||||
for row, system in enumerate(pair):
|
||||
dx, dy = style.margin_x + 1, top + row * (row_height + style.system_gap) - system.box.top
|
||||
for system in pair:
|
||||
dx, dy = style.margin_x + 1, cursor - system.box.top
|
||||
placed.append(dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy)))
|
||||
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])))
|
||||
return tuple(pages)
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ class ScoreIndex:
|
||||
self.events_by_onset = {}
|
||||
self.attributes_by_onset = {}
|
||||
self.harmonies_by_onset = {}
|
||||
self.tempos_by_onset = {}
|
||||
self.by_id = {}
|
||||
self.tones = {}
|
||||
self.boundaries = {fractions.Fraction()}
|
||||
@@ -51,8 +50,7 @@ class ScoreIndex:
|
||||
self.columns.add(onset)
|
||||
for tone in note['pitches']:
|
||||
self.tones[tone['id']] = (event, tone)
|
||||
for field in ('attributes', 'harmonies', 'tempos'):
|
||||
bucket = {'attributes': self.attributes_by_onset, 'harmonies': self.harmonies_by_onset, 'tempos': self.tempos_by_onset}[field]
|
||||
for field, bucket in (('attributes', self.attributes_by_onset), ('harmonies', self.harmonies_by_onset)):
|
||||
for index, item in enumerate(measure.get(field, [])):
|
||||
onset = time + generator.model.fraction(item['onset'])
|
||||
self.columns.add(onset)
|
||||
|
||||
@@ -203,11 +203,11 @@ def draw_spans(index: generator.engraving.notation.ScoreIndex, anchors: list[gen
|
||||
return items + result
|
||||
|
||||
def place_text_lanes(items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
|
||||
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'tempo')]
|
||||
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section')]
|
||||
box = generator.engraving.types.bounds(music)
|
||||
result = list(music)
|
||||
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]
|
||||
if lane:
|
||||
extent = generator.engraving.types.bounds(lane)
|
||||
|
||||
@@ -14,6 +14,7 @@ class Style:
|
||||
space: float = 12
|
||||
margin_x: float = 28
|
||||
margin_y: float = 18
|
||||
page_clearance: float = 14
|
||||
supersampling: int = 4
|
||||
column_gap: float = 10
|
||||
system_gap: float = 26
|
||||
|
||||
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 52 KiB |
@@ -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())
|
||||
@@ -33,6 +33,18 @@ def shape(event):
|
||||
return tuple(sorted((p['tablature']['string'], p['tablature']['fret']) for p in event['pitches']))
|
||||
|
||||
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):
|
||||
chord = harmony('C7')
|
||||
first = note('melody-1', 1, chord, (2, 3, 1, 3), melody=True, duration=2)
|
||||
|
||||
@@ -49,6 +49,7 @@ class EngravingTests(unittest.TestCase):
|
||||
def assert_paired_pages(self, layout):
|
||||
self.assertTrue(all(len(page.systems) == 2 for page in layout.pages[:-1]))
|
||||
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:
|
||||
self.assertEqual(len({system.scale for system in page.systems}), 1)
|
||||
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)
|
||||
for first, second in zip(page.systems, page.systems[1:]):
|
||||
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):
|
||||
content = tests.test_musicxml.xml_note('E', octave=5)
|
||||
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 32))
|
||||
|
||||
Reference in New Issue
Block a user