Generation
ci / checks (push) Successful in 1h11m24s

This commit is contained in:
khodowany
2026-09-26 15:51:20 -05:00
parent 1d5f3a9372
commit 11222a9f8c
11 changed files with 272 additions and 37 deletions
+2 -2
View File
@@ -105,11 +105,11 @@ Identical notation bodies share layout and body rasterization even when titles o
The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model and rhythm plans -> part generation -> JSON, engraved PNG pages, generated-tone audio, Pico title artwork, and a read-only catalog database. Sources in `data/songs/` retain the seven-section performance form. Four-digit numbered sources in `data/practice/` (currently `0001.xml` through `0366.xml`; `0000.xml` is also valid) produce one pass each of **acoustic accompaniment**, **electric melody**, and **vocal melody**. Both categories use the authored musical key, with no key variants or key-selection menu. Duplicate titles never merge exercises.
Accompaniment uses deterministic four-bar phrase cycles: a two-bar motif repeats with a one-beat variation in the fourth bar when a suitable candidate exists. Acoustic has independent bass and upper-chord rhythms, including simultaneous attacks, sharing a playable grip. Bass alternates root/fifth unless an explicit bass or playability requires otherwise. Electric accompaniment responds more sparsely with stabs lasting at most a quarter note. Rhythm snippets supply attacks and rests; structured harmony supplies chord tones. N.C. and absent harmony remain silent. Solo slashes and the seven-section form retain their roles. Unsupported accompaniment meters and mid-measure meter changes fail explicitly.
Accompaniment uses deterministic four-bar phrase cycles: a two-bar motif repeats with a one-beat variation in the fourth bar when a suitable candidate exists. Acoustic has independently planned bass and upper-chord rhythms, including simultaneous attacks, sharing a playable grip. They combine into one notated voice: sustaining notes use ties, simultaneous tones share a stem, and rests appear only when the entire part is silent. Bass alternates root/fifth unless an explicit bass or playability requires otherwise. Electric accompaniment responds more sparsely with stabs lasting at most a quarter note. Rhythm snippets supply attacks and rests; structured harmony supplies chord tones. N.C. and absent harmony remain silent. Solo slashes and the seven-section form retain their roles. Unsupported accompaniment meters and mid-measure meter changes fail explicitly.
There are no musical genre presets, genre defaults, or genre metadata. Obsolete `vamp:style` hints are rejected; visual styles, fonts, colors, and barline notation remain supported. Phrase cycles are written into the generated score and played once in reading order.
Guitar parts share a theory-based voicing and proximity search over standard tuning, frets 0-20, and a maximum four-fret span excluding open strings. Acoustic uses a bass on string 5 or 6 and two or three consecutive upper strings within 2-4, with zero or one unused string between them. Electric chords use strings 1-3 or 1-4 and may omit the root. Electric melody notes longer than a quarter note, including full tied runs, receive chords when playable; chord changes create fresh attacks even for short remaining fragments. Song electric melodies are raised one octave; practice and vocal registers stay unchanged. All parts still sound one octave below their generated notation, and explicit MusicXML transposition is honored once. Unplayable melody harmonizations remain single notes with a diagnostic; unplayable accompaniment fails generation.
Guitar parts share a theory-based voicing and proximity search over standard tuning, frets 0-20, and a maximum four-fret span including open strings. Open strings can accompany frets 1-4, but not distant positions such as 11 or 12. Acoustic uses a bass on string 5 or 6 and two or three consecutive upper strings within 2-4, with zero or one unused string between them. Electric chords use strings 1-3 or 1-4 and may omit the root. Electric melody notes longer than a quarter note, including full tied runs, receive chords when playable; chord changes create fresh attacks even for short remaining fragments. Song electric melodies are raised one octave; practice and vocal registers stay unchanged. All parts still sound one octave below their generated notation, and explicit MusicXML transposition is honored once. Unplayable melody harmonizations remain single notes with a diagnostic; unplayable accompaniment fails generation.
All parts use two music systems per page, with a single system only when the score ends on an odd system. Tall notation is scaled uniformly to fit the two rows; guitar notation and TAB stay together and aligned. TAB displays frets and ties without duplicate rests; standard notation retains the complete rhythm. The generated `.pages/*.png` files are the same page images displayed by the product.
+5 -3
View File
@@ -122,7 +122,9 @@ Measure durations, event onsets/durations, harmony offsets, span times, and pitc
### Shared Part Generation
`parts.py` owns shared source validation, melody-copying, measure construction, rests, and solo slashes. `rhythm.py` loads snippets; `accompaniment.py` plans phrase cycles and realizes independent bass/upper voices or electric stabs. `melody.py` identifies long tied melody runs and splits them at harmony changes before guitar placement. `arrange/generate.py` supplies the existing seven-section song form; `practice/generate.py` supplies a single source pass. `pitch.py` resolves source instrument transposition and the shared written-octave convention. There is no generic twelve-key transposer or separate practice fretboard solver.
`parts.py` owns shared source validation, melody-copying, measure construction, rests, and solo slashes. `rhythm.py` loads snippets; `accompaniment.py` plans phrase cycles and realizes independent bass/upper voices or electric stabs. After guitar placement and continuation linking, `acoustic.py` combines generated bass/upper lanes into one notated voice. `melody.py` identifies long tied melody runs and splits them at harmony changes before guitar placement. `arrange/generate.py` supplies the existing seven-section song form; `practice/generate.py` supplies a single source pass. `pitch.py` resolves source instrument transposition and the shared written-octave convention. There is no generic twelve-key transposer or separate practice fretboard solver.
Acoustic consolidation preserves the exact sounding-note timeline and TAB assignments. It combines simultaneous tones, splits sustains at intervening attacks/releases, and ties only fragments of the same sounding note or existing tie chain. Repeated pitches remain fresh attacks. Rests represent complete silence, with one measure rest for a silent full bar. Independent stem directions and old beam groups are removed; standard single-voice engraving supplies shared stems and automatic beams. Binary durations that require multiple written values become ordinary tied notes rather than artificial tuplets. Exact nonbinary timing retains the model's rational time modifications. This pass leaves authored voices and solo slashes unchanged and runs entirely at build time.
Practice acoustic accompaniment is derived from structured harmony roots, kinds, degrees, inversions, and slash basses using the same generation rules as songs. A missing harmony or N.C. produces rests, not guessed chords. Electric retains the melody's register and spelling while adding playable chords to long notes. Vocal retains authored notes and receives scat lyrics. Song vocal lyrics are unchanged. Practice optional-turnaround harmonies are played once, without creating extra sections.
@@ -134,13 +136,13 @@ All three parts, for both songs and practice, use octave-transposing notation: w
`pitch.py` resolves explicit MusicXML instrument transposition once before part generation. Explicit zero transposition is also authoritative. Octave-clef exports already encode sounding pitch in their note values, so they do not receive another implicit octave subtraction. Generated parts carry an explicit `transpose.octave_change: -1`; guitar placement and audio read that shared state. Internal concert-pitch normalization and written-pitch conversion cancel for ordinary source notation except for the intentional song electric melody octave increase. There is no automatic octave fitting, vocal-specific range shift, or key transposition.
`guitar.py` searches standard tuning E2 A2 D3 G3 B3 E4, frets 0-20 inclusive, with at most four frets between fretted positions; open strings do not count toward that span. Exact melody tones are never dropped or replaced. Simultaneous tones occupy distinct strings, and sustained/tied tones retain their assignments. Acoustic grips use one bass on string 5 or 6 and two or three consecutive strings within 2-4, with zero or one unused string between bass and upper voices. Both rhythmic voices must fit one complete grip, even when only one attacks. Bass alternates root and chord fifth, restarting at harmony/section changes; explicit basses/inversions remain fixed. A fifth-bass choice falls back to the root if it cannot preserve chord identity and the held strings.
`guitar.py` searches standard tuning E2 A2 D3 G3 B3 E4, frets 0-20 inclusive, with at most four frets between the lowest and highest positions, including fret 0. Open strings participate in span validation and proximity scoring: 0/2/3 and 0/4 are valid, while 0/5 and 0/11/12 are not. Exact melody tones are never dropped or replaced. Simultaneous tones occupy distinct strings, and sustained/tied tones retain their assignments. Acoustic grips use one bass on string 5 or 6 and two or three consecutive strings within 2-4, with zero or one unused string between bass and upper voices. Both rhythmic voices must fit one complete grip, even when only one attacks. Bass alternates root and chord fifth, restarting at harmony/section changes; explicit basses/inversions remain fixed. A fifth-bass choice falls back to the root if it cannot preserve chord identity and the held strings.
Electric chord blocks use strings 1-3 or 1-4, including string 1. Rootless voicings and octave doublings are allowed; the third or suspension, sixth/seventh, alterations, and named extensions remain required. An unaltered fifth in extended chords and unaltered intermediate ninth/eleventh extensions may be omitted. Melody notes can lie below added chord tones, including non-chord melody pitches that must remain unchanged.
`melody.py` treats the full tied duration as one note when testing whether it exceeds one quarter-note beat. Qualifying notes receive a chord at their initial attack and at each subsequent changed harmony, even if a resulting fragment is less than one beat. Changed harmonies break ties and reattack the melody; repeated identical harmonies do not. N.C. releases the added voices while preserving the melody's sustain. Added tones through unchanged harmony retain their own ties. The quarter-note cap on electric stabs does not apply to these melody chords. If no valid chord fits, the exact melody remains with a diagnostic; it is not octave-wrapped or omitted.
Placement minimizes total movement of fret/string position centers, then fretted span and omitted optional degrees, with deterministic ties. Independent attacks use an exact small-grid distance transform; when notes sustain across attacks the search retains actual occupied strings. There is no finger assignment, barre logic, or predefined chord-shape dictionary. These are proximity and fretboard-feasibility constraints, not a guarantee of comfortable fingering. Nonstandard tuning, capos, microtonal frets, and unplayable passages fail with source/part/event context; there is no slash fallback for a failed chord. Existing assets remain untouched after failure.
Placement minimizes total movement of fret/string position centers, then fret span and omitted optional degrees, with deterministic ties. All three calculations include open strings. Independent attacks use an exact small-grid distance transform; when notes sustain across attacks the search retains actual occupied strings. There is no finger assignment, barre logic, or predefined chord-shape dictionary. These are proximity and fretboard-feasibility constraints, not a guarantee of comfortable fingering. Nonstandard tuning, capos, microtonal frets, and unplayable passages fail with source/part/event context; there is no slash fallback for a failed chord. Existing assets remain untouched after failure.
### Practice Scat
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
import dataclasses
import fractions
import generator.model
@dataclasses.dataclass
class SoundingTone:
pitch: dict
start: fractions.Fraction
end: fractions.Fraction
def written_durations(length: fractions.Fraction):
while length:
written = generator.model.written_for(length)
duration = length
# Binary spans such as five eighths need ties, not an invented tuplet.
if 'ratio' in written and length.denominator & (length.denominator - 1) == 0:
duration = next((size for size in generator.model.TYPE_LENGTHS.values() if size < length), length)
written = generator.model.written_for(duration)
yield duration, written
length -= duration
def combine(document: dict) -> None:
"""Consolidate generated acoustic lanes without changing their sounding notes."""
staff = document['staves'][0]
if staff.get('arrangement_part_type') != 'acoustic':
return
measures, sounds, roots = [], {}, {}
absolute = fractions.Fraction()
for measure in staff['measures']:
length = generator.model.fraction(measure['duration'])
if {voice['id'] for voice in measure['voices']} == {'bass', 'upper'}:
members = set()
for voice in measure['voices']:
for note in voice['notes']:
start = absolute + generator.model.fraction(note['onset'])
for pitch in note['pitches']:
end = start + generator.model.fraction(pitch['duration'])
if source := pitch.get('tie_from'):
root = roots[source]
sounds[root].end = end
else:
root = pitch['id']
sounds[root] = SoundingTone(pitch, start, end)
roots[pitch['id']] = root
members.add(root)
measures.append((measure, absolute, members))
absolute += length
previous = {}
for measure, absolute, members in measures:
length = generator.model.fraction(measure['duration'])
spans = [(root, max(fractions.Fraction(), sounds[root].start - absolute), min(length, sounds[root].end - absolute)) for root in sorted(members)]
boundaries = sorted({fractions.Fraction(), length} | {time for _, start, end in spans for time in (start, end)})
notes = []
for start, end in zip(boundaries, boundaries[1:]):
active = sorted((root for root, low, high in spans if low <= start and end <= high), key=lambda root: (generator.model.pitch_number(sounds[root].pitch), sounds[root].pitch['tablature']['string'], root))
for duration, written in written_durations(end - start):
pitches = [{key: value for key, value in sounds[root].pitch.items() if key not in ('tie_from', 'tie_to', 'stem')} for root in active]
note = generator.model.make_event(f'{measure["id"]}-acoustic-{len(notes) + 1}', start, duration, 'note' if active else 'rest', pitches, written)
note['voice'] = 'voice-arranged'
for root, pitch in zip(active, note['pitches']):
if root in previous:
previous[root]['tie_to'], pitch['tie_from'] = pitch['id'], previous[root]['id']
previous[root] = pitch
notes.append(note)
start += duration
if not members and length == generator.model.meter_length(measure['meter']):
notes = [generator.model.make_event(f'{measure["id"]}-acoustic-1', fractions.Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'voice': 'voice-arranged', 'measure_rest': True}]
measure['voices'] = [{'id': 'voice-arranged', 'notes': notes}]
+2
View File
@@ -5,6 +5,7 @@ import fractions
import pathlib
import generator.accompaniment
import generator.acoustic
import generator.arrange.form
import generator.guitar
import generator.melody
@@ -118,6 +119,7 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
generator.guitar.place_guitar(result)
if generator.accompaniment.connect_continuations(result):
generator.guitar.place_guitar(result)
generator.acoustic.combine(result)
return result
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library) -> int:
+4 -4
View File
@@ -25,8 +25,8 @@ class TabPosition(typing.NamedTuple):
midi: int
def within_fret_span(positions) -> bool:
fretted = [position.fret for position in positions if position.fret > 0]
return not fretted or max(fretted) - min(fretted) <= MAX_FRET_SPAN
frets = [position.fret for position in positions]
return not frets or max(frets) - min(frets) <= MAX_FRET_SPAN
def positions_for_pitch(midi: fractions.Fraction) -> tuple[TabPosition, ...]:
if midi.denominator != 1:
@@ -131,12 +131,12 @@ def movement(previous: tuple[TabPosition, ...], current: tuple[TabPosition, ...]
@functools.lru_cache(maxsize=4096)
def position_center(positions: tuple[TabPosition, ...]) -> tuple[int, int]:
frets = [p.fret for p in positions if p.fret] or [0]
frets = [p.fret for p in positions] or [0]
strings = [p.string_number for p in positions]
return min(frets) + max(frets), min(strings) + max(strings) - 2
def compactness(positions) -> int:
frets = [p.fret for p in positions if p.fret]
frets = [p.fret for p in positions]
return max(frets) - min(frets) if frets else 0
def extend_positions(held, pending):
+2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import pathlib
import generator.accompaniment
import generator.acoustic
import generator.guitar
import generator.melody
import generator.musicxml
@@ -38,6 +39,7 @@ def _practice_document(document: dict, part_type: str, plan: generator.accompani
generator.guitar.place_guitar(result)
if generator.accompaniment.connect_continuations(result):
generator.guitar.place_guitar(result)
generator.acoustic.combine(result)
return result
def generate_practice_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library) -> int:
+3 -3
View File
@@ -344,7 +344,7 @@ class TiedAccompanimentTests(unittest.TestCase):
self.assert_tied_chord(first[0], second[0])
first_tones = [p for v in result['staves'][0]['measures'][0]['voices'] for n in v['notes'] for p in n['pitches']]
self.assertEqual({generator.model.pitch_number(p) % 12 for p in first_tones}, {0, 4, 7})
self.assert_tied_chord(result['staves'][0]['measures'][0]['voices'][1]['notes'][0], result['staves'][0]['measures'][1]['voices'][1]['notes'][0])
self.assertEqual(len(result['staves'][0]['measures'][0]['voices']), 1)
generator.model.validate_document(result)
timeline = generator.playback.compile_timeline(result)
self.assertEqual(generator.model.fraction(timeline['duration']), 8)
@@ -360,7 +360,7 @@ class TiedAccompanimentTests(unittest.TestCase):
result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=tied_library(2))
first, second = tests.test_musicxml.notes(result), tests.test_musicxml.notes(result, 1)
self.assertEqual(timing(first), [(0, 4, 'note')])
self.assertEqual(timing(second), [(0, 2, 'rest'), (2, 2, 'rest')])
self.assertEqual(timing(second), [(0, 4, 'rest')])
self.assertEqual({generator.model.pitch_number(p) % 12 for v in result['staves'][0]['measures'][0]['voices'] for n in v['notes'] for p in n['pitches']}, {0, 4, 7})
self.assertEqual(second[0]['pitches'], [])
for note in first + second:
@@ -377,7 +377,7 @@ class TiedAccompanimentTests(unittest.TestCase):
result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=tied_library(2))
first, second = tests.test_musicxml.notes(result), tests.test_musicxml.notes(result, 1)
self.assertEqual(timing(first), [(0, 4, 'note')])
self.assertEqual(timing(second), [(0, 1, 'note'), (1, 1, 'rest'), (2, 2, 'rest')])
self.assertEqual(timing(second), [(0, 1, 'note'), (1, 3, 'rest')])
self.assert_tied_chord(first[0], second[0])
generator.model.validate_document(result)
events = generator.playback.compile_timeline(result)['events']
+148
View File
@@ -0,0 +1,148 @@
import copy
import fractions
import pathlib
import unittest
import unittest.mock
import generator.acoustic
import generator.arrange.generate
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.types
import generator.model
import generator.playback
import generator.practice.generate
import tests.rhythm_fixtures
import tests.test_accompaniment
import tests.test_musicxml
import tests.test_parts
def sounding(document):
return sorted(tuple(generator.model.fraction(event[key]) for key in ('onset', 'duration', 'pitch')) for event in generator.playback.compile_timeline(document)['events'])
def acoustic_document(bars):
document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * len(bars)))
staff = document['staves'][0]
staff.update(arrangement_part_type='acoustic', tablature=True)
for measure, (bass, upper) in zip(staff['measures'], bars):
measure['transpose']['octave_change'] = -1
measure['voices'] = []
for lane, attacks, positions in (('bass', bass, (('c', 5, 3),)), ('upper', upper, (('e', 4, 2), ('g', 3, 0)))):
notes, cursor = [], fractions.Fraction()
for onset, duration in attacks:
onset, duration = fractions.Fraction(onset), fractions.Fraction(duration)
if onset > cursor:
notes.append(generator.model.make_event(f'{measure["id"]}-{lane}-{len(notes)}', cursor, onset - cursor, 'rest'))
pitches = [{'letter': letter, 'alter': generator.model.rational(0), 'octave': 4, 'tablature': {'string': string, 'fret': fret}} for letter, string, fret in positions]
notes.append(generator.model.make_event(f'{measure["id"]}-{lane}-{len(notes)}', onset, duration, 'note', pitches))
cursor = onset + duration
if cursor < 4:
notes.append(generator.model.make_event(f'{measure["id"]}-{lane}-{len(notes)}', cursor, 4 - cursor, 'rest'))
for note in notes:
note.update(voice=lane, stem='down' if lane == 'bass' else 'up')
measure['voices'].append({'id': lane, 'notes': notes})
generator.model.validate_document(document)
return document
class AcousticTests(unittest.TestCase):
def combine(self, document):
before = sounding(document)
generator.acoustic.combine(document)
generator.model.validate_document(document)
self.assertEqual(sounding(document), before)
self.assertTrue(all(len(m['voices']) == 1 for m in document['staves'][0]['measures']))
for measure in document['staves'][0]['measures']:
for note in measure['voices'][0]['notes']:
self.assertNotIn('stem', note)
self.assertNotIn('beams', note)
for pitch in note['pitches']:
self.assertNotIn('stem', pitch)
once = copy.deepcopy(document)
generator.acoustic.combine(document)
self.assertEqual(document, once)
return [m['voices'][0]['notes'] for m in document['staves'][0]['measures']]
def test_simultaneous_attacks_share_one_chord(self):
notes = self.combine(acoustic_document([([(0, 2), (3, 1)], [(0, 2), (3, 1)])]))[0]
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 2, 'note'), (2, 1, 'rest'), (3, 1, 'note')])
self.assertEqual([len(n['pitches']) for n in notes], [3, 0, 3])
def test_sustained_bass_ties_through_upper_attacks_and_releases(self):
notes = self.combine(acoustic_document([([(0, 4)], [(1, 1), (3, 1)])]))[0]
self.assertTrue(all(n['kind'] == 'note' for n in notes))
self.assertEqual([len(n['pitches']) for n in notes], [1, 3, 1, 3])
for first, second in zip(notes, notes[1:]):
self.assertEqual(first['pitches'][0]['tie_to'], second['pitches'][0]['id'])
self.assertEqual(first['pitches'][0]['tablature'], second['pitches'][0]['tablature'])
def test_repeated_pitch_is_an_attack_not_a_tie(self):
notes = self.combine(acoustic_document([([(0, 2), (2, 2)], [(0, 4)])]))[0]
self.assertEqual(len(notes), 2)
self.assertNotIn('tie_to', notes[0]['pitches'][0])
self.assertNotIn('tie_from', notes[1]['pitches'][0])
for first, second in zip(notes[0]['pitches'][1:], notes[1]['pitches'][1:]):
self.assertEqual(first['tie_to'], second['id'])
def test_existing_tie_boundary_does_not_add_a_redundant_event(self):
document = acoustic_document([([(0, 1), (1, 1)], [(2, 2)])])
first, second = document['staves'][0]['measures'][0]['voices'][0]['notes'][:2]
first['pitches'][0]['tie_to'] = second['pitches'][0]['id']
second['pitches'][0]['tie_from'] = first['pitches'][0]['id']
notes = self.combine(document)[0]
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 2, 'note'), (2, 2, 'note')])
def test_cross_measure_ties_preserve_both_rhythms(self):
document = acoustic_document([([(0, 4)], [(2, 2)]), ([(0, 2)], [(0, 1), (2, 1)])])
first, second = document['staves'][0]['measures']
for lane in range(2):
old = next(n for n in first['voices'][lane]['notes'] if n['pitches'])
new = second['voices'][lane]['notes'][0]
for a, b in zip(old['pitches'], new['pitches']):
a['tie_to'], b['tie_from'] = b['id'], a['id']
notes = self.combine(document)
self.assertTrue(all('tie_from' in p for p in notes[1][0]['pitches']))
self.assertEqual(notes[1][-1]['kind'], 'rest')
def test_silence_collapses_to_a_single_measure_rest(self):
notes = self.combine(acoustic_document([([], [])]))[0]
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 4, 'rest')])
self.assertTrue(notes[0]['measure_rest'])
def test_pickup_keeps_its_actual_duration(self):
document = acoustic_document([([(0, 1)], [(0, 1)])])
measure = document['staves'][0]['measures'][0]
measure.update(duration=generator.model.rational(1), implicit=True)
for voice in measure['voices']:
voice['notes'] = voice['notes'][:1]
notes = self.combine(document)[0]
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 1, 'note')])
def test_five_eighths_uses_conventional_tied_notes_not_a_tuplet(self):
notes = self.combine(acoustic_document([([(0, fractions.Fraction(5, 2))], [])]))[0]
self.assertEqual([n['written'] for n in notes], [{'type': 'half', 'dots': 0}, {'type': 'eighth', 'dots': 0}, {'type': 'quarter', 'dots': 1}])
self.assertEqual(notes[0]['pitches'][0]['tie_to'], notes[1]['pitches'][0]['id'])
def test_unrelated_voices_and_solo_slashes_are_unchanged(self):
document = acoustic_document([([(0, 4)], [(0, 4)])])
for part in ('electric', 'vocal', None):
document['staves'][0]['arrangement_part_type'] = part
before = copy.deepcopy(document)
generator.acoustic.combine(document)
self.assertEqual(document, before)
document['staves'][0]['arrangement_part_type'] = 'acoustic'
measure = document['staves'][0]['measures'][0]
measure['voices'] = [{'id': 'voice-arranged', 'notes': [generator.model.make_event('slash', fractions.Fraction(), fractions.Fraction(4), 'note') | {'voice': 'voice-arranged', 'notehead': 'slash'}]}]
before = copy.deepcopy(document)
generator.acoustic.combine(document)
self.assertEqual(document, before)
def test_song_and_practice_integration_preserve_playback(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note()))
rhythms = tests.rhythm_fixtures.library()
for builder in (generator.arrange.generate.arrange_document, generator.practice.generate.practice_document):
with unittest.mock.patch('generator.acoustic.combine'):
separate = builder(source, 'acoustic', rhythms=rhythms)
combined = builder(source, 'acoustic', rhythms=rhythms)
self.assertEqual(sounding(separate), sounding(combined))
generator.model.validate_document(combined)
self.assertTrue(all(len(m['voices']) == 1 for m in combined['staves'][0]['measures']))
def test_engraved_chord_has_one_stem_and_no_competing_rests(self):
document = acoustic_document([([(0, 4)], [(1, 1), (3, 1)])])
self.combine(document)
fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
layout = generator.engraving.layout.layout_score(document, generator.engraving.types.Style(), fonts)
primitives = [p for page in layout.pages for p in page.primitives]
self.assertFalse([p for p in primitives if p.role in ('rest', 'tab-rest')])
for note in document['staves'][0]['measures'][0]['voices'][0]['notes']:
self.assertEqual(len([p for p in primitives if p.role == 'stem' and note['id'] in p.sources]), 1)
self.assertTrue([p for p in primitives if p.role == 'tie'])
+1 -1
View File
@@ -60,7 +60,7 @@ class EngravingTests(unittest.TestCase):
self.assert_paired_pages(layout)
self.assertEqual(layout.style.space, 10)
self.assertEqual(layout.style.tab_font_size, 12)
wide = tests.test_musicxml.xml_note('E', octave=3) + tests.test_musicxml.xml_note('C', octave=7, prefix='<chord/>')
wide = tests.test_musicxml.xml_note('E', 2, 'half', octave=3) + tests.test_musicxml.xml_note('C', 2, 'half', octave=7)
document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([wide] * 32))
for measure in document['staves'][0]['measures']:
measure['transpose']['octave_change'] = -1
+15 -1
View File
@@ -27,8 +27,22 @@ class GuitarTests(unittest.TestCase):
self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(85)))
self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(129, 2)))
self.assertTrue(generator.guitar.within_fret_span((generator.guitar.TabPosition(4, 11, 61), generator.guitar.TabPosition(3, 12, 67), generator.guitar.TabPosition(2, 14, 73))))
self.assertTrue(generator.guitar.within_fret_span((generator.guitar.TabPosition(6, 0, 40), generator.guitar.TabPosition(1, 20, 84))))
self.assertFalse(generator.guitar.within_fret_span((generator.guitar.TabPosition(6, 0, 40), generator.guitar.TabPosition(1, 20, 84))))
self.assertFalse(generator.guitar.within_fret_span((generator.guitar.TabPosition(4, 10, 60), generator.guitar.TabPosition(1, 20, 84))))
def test_open_strings_count_in_span_and_proximity(self):
for frets, valid in (((0, 2, 3), True), ((0, 4), True), ((0, 5), False), ((12, 0, 11), False), ((0, 0), True)):
with self.subTest(frets=frets):
positions = tuple(generator.guitar.TabPosition(6 - i, fret, 0) for i, fret in enumerate(frets))
self.assertEqual(generator.guitar.within_fret_span(positions), valid)
self.assertEqual(generator.guitar.compactness(positions), max(frets) - min(frets))
self.assertEqual(generator.guitar.position_center(positions)[0], min(frets) + max(frets))
self.assertTrue(generator.guitar.within_fret_span(()))
def test_held_open_string_cannot_mix_with_high_frets(self):
held = tests.test_musicxml.xml_note('E', octave=2)
high = tests.test_musicxml.xml_note('C', 2, 'half', octave=6, extra='<voice>2</voice>')
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(held + '<backup><duration>4</duration></backup><forward><duration>2</duration></forward>' + high))
with self.assertRaisesRegex(ValueError, 'no guitar placement'):
generator.guitar.place_guitar(document)
def test_theory_controls_omissions_and_bass(self):
for symbol in ('CM', 'G7b9', 'FM7#11/A', 'CmM7', 'Cdim7', 'C13', 'Csus', 'Cmadd9'):
with self.subTest(symbol=symbol):
+19 -23
View File
@@ -39,25 +39,21 @@ class GuitarPartTests(unittest.TestCase):
self.assertNotIn(0, generator.guitar.chord_requirements(chord, 'electric')[0])
candidates = generator.guitar.chord_positions(*generator.guitar.chord_requirements(chord, 'electric'), profile='electric')
self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates))
def test_acoustic_has_independent_voices_and_alternating_bass(self):
def test_acoustic_combines_independent_rhythms_and_alternating_bass(self):
result = self.practice(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
measure = result['staves'][0]['measures'][0]
upper, bass = measure['voices']
self.assertEqual((upper['id'], bass['id']), ('upper', 'bass'))
self.assertNotEqual(tests.test_accompaniment.timing(upper['notes']), tests.test_accompaniment.timing(bass['notes']))
attacks = [n for n in bass['notes'] if n['pitches']]
self.assertEqual([generator.model.pitch_number(n['pitches'][0]) % 12 for n in attacks], [0, 7])
for voice in (upper, bass):
self.assertEqual(sum(generator.model.fraction(n['duration']) for n in voice['notes']), 4)
for note in voice['notes']:
self.assertEqual(note['stem'], 'up' if voice is upper else 'down')
strings = sorted(p['tablature']['string'] for p in note['pitches'])
if strings:
if voice is bass:
self.assertEqual(len(strings), 1)
self.assertIn(strings[0], (5, 6))
else:
self.assertIn(strings, ([2, 3], [3, 4], [2, 3, 4]))
self.assertEqual(len(measure['voices']), 1)
notes = measure['voices'][0]['notes']
bass = [(n['onset'], p) for n in notes for p in n['pitches'] if p['tablature']['string'] >= 5 and 'tie_from' not in p]
upper = {generator.model.fraction(n['onset']) for n in notes for p in n['pitches'] if p['tablature']['string'] <= 4 and 'tie_from' not in p}
self.assertNotEqual({generator.model.fraction(onset) for onset, _ in bass}, upper)
self.assertEqual([generator.model.pitch_number(p) % 12 for _, p in bass], [0, 7])
self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes), 4)
for note in notes:
self.assertNotIn('stem', note)
strings = sorted(p['tablature']['string'] for p in note['pitches'] if p['tablature']['string'] <= 4)
if strings:
self.assertIn(strings, ([2, 3], [3, 4], [2, 3, 4]))
timeline = generator.playback.compile_timeline(result)['events']
by_id = {p['id']: p for v in measure['voices'] for n in v['notes'] for p in n['pitches']}
for event in timeline:
@@ -72,8 +68,8 @@ class GuitarPartTests(unittest.TestCase):
chord = generator.harmony.parse_symbol(symbol)
source['staves'][0]['measures'][0]['harmonies'] = [{**chord, 'id': 'h1', 'onset': generator.model.rational(0)}]
result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library())
bass = result['staves'][0]['measures'][0]['voices'][1]
self.assertEqual({generator.model.pitch_number(p) % 12 for n in bass['notes'] for p in n['pitches']}, {expected})
notes = tests.test_musicxml.notes(result)
self.assertEqual({generator.model.pitch_number(p) % 12 for n in notes for p in n['pitches'] if p['tablature']['string'] >= 5}, {expected})
def test_acoustic_bass_and_upper_chord_can_attack_together(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note()))
prepared = generator.parts.prepare_document(source, practice=True)
@@ -81,11 +77,11 @@ class GuitarPartTests(unittest.TestCase):
plan = generator.accompaniment.Plan((bar,), (bar,), ())
result = generator.practice.generate._practice_document(prepared, 'acoustic', plan)
generator.model.validate_document(result)
upper, bass = result['staves'][0]['measures'][0]['voices']
self.assertEqual([generator.model.fraction(n['onset']) for n in upper['notes'] if n['pitches']], [0, 2])
self.assertEqual([generator.model.fraction(n['onset']) for n in bass['notes'] if n['pitches']], [0, 2])
self.assertEqual(len(result['staves'][0]['measures'][0]['voices']), 1)
notes = tests.test_musicxml.notes(result)
self.assertEqual([generator.model.fraction(n['onset']) for n in notes if n['pitches']], [0, 2])
for onset in (0, 2):
pitches = [p for voice in (upper, bass) for n in voice['notes'] if generator.model.fraction(n['onset']) == onset for p in n['pitches']]
pitches = [p for n in notes if generator.model.fraction(n['onset']) == onset for p in n['pitches']]
positions = [generator.guitar.TabPosition(p['tablature']['string'], p['tablature']['fret'], int(generator.model.pitch_number(p) - 12)) for p in pitches]
self.assertIn(tuple(sorted((p.string_number for p in positions), reverse=True)), generator.guitar.ACOUSTIC_STRINGS)
self.assertTrue(generator.guitar.within_fret_span(positions))