Sustained Rhythm
ci / checks (push) Successful in 47m43s

This commit is contained in:
khodowany
2026-09-30 02:18:31 -05:00
parent 8a23fa0fc2
commit 923b7f14e2
826 changed files with 57641 additions and 35 deletions
+62 -5
View File
@@ -89,6 +89,28 @@ class RhythmLibraryTests(unittest.TestCase):
self.assertEqual([bar.attacks() for bar in snippet.bars], [(0,), ()])
self.assertEqual([bar.events[0].continuation for bar in snippet.bars], [False, True])
self.assertEqual([bar.length for bar in snippet.bars], [4, 4])
def test_third_series_supports_long_notes_and_rests_in_every_meter(self):
for beats, duration, kind, dots in ((2, 2, 'half', 0), (3, 3, 'half', 1), (4, 4, 'whole', 0), (5, 4, 'whole', 0)):
for sounding in (False, True):
with self.subTest(beats=beats, sounding=sounding):
content = tests.test_musicxml.xml_note(duration=duration, kind=kind, extra='<dot/>' if dots else '') if sounding else f'<note><rest/><duration>{duration}</duration><type>{kind}</type>{"<dot/>" if dots else ""}</note>'
if beats == 5:
content += tests.test_musicxml.xml_note(duration=1, kind='quarter')
snippet = self.load({f'{beats}-4/3/0001.xml': xml_bars([content], beats=beats)}).for_meter(f'{beats}/4')[0]
self.assertEqual(snippet.bars[0].events[0], generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(duration), sounding, kind, dots))
self.assertEqual(snippet.bars[0].length, beats)
def test_all_three_series_participate_in_deterministic_long_note_selection(self):
silence = '<note><rest/><duration>4</duration><type>whole</type></note>'
halves = tests.test_musicxml.xml_note(duration=2, kind='half') * 2
library = self.load({'4-4/1/0001.xml': xml_bars([silence] * 2), '4-4/2/0001.xml': xml_bars([silence]), '4-4/3/0001.xml': xml_bars([halves])})
self.assertEqual(len(library.for_meter('4/4')), 3)
measures = melody_measures(8)
plan = generator.accompaniment.plan_section(measures, library)
expected = library.for_meter('4/4')[-1].bars[0]
for lane in (plan.acoustic_bass, plan.acoustic_upper, plan.electric):
self.assertEqual(lane, (expected,) * 8)
reordered = generator.rhythm.Library(tuple(reversed(library.for_meter('4/4'))))
self.assertEqual(generator.accompaniment.plan_section(measures, reordered), plan)
def test_beat_comparison_counts_held_half_notes_and_quarter_reattacks(self):
half = held_half_bar()
silent_half = held_half_bar(False)
@@ -123,7 +145,7 @@ class RhythmLibraryTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'empty'):
self.load({})
source = xml_bars([tests.test_musicxml.xml_note()])
for path in ('snippet.xml', '4-4/snippet.xml', '4-4/3/snippet.xml', '4-4/1/nested/snippet.xml', '6-8/1/snippet.xml'):
for path in ('snippet.xml', '4-4/snippet.xml', '4-4/4/snippet.xml', '4-4/1/nested/snippet.xml', '6-8/1/snippet.xml'):
with self.subTest(path=path), self.assertRaisesRegex(ValueError, 'meter/series'):
self.load({path: source})
with self.assertRaisesRegex(ValueError, 'meter disagrees'):
@@ -264,6 +286,40 @@ class AccompanimentPlanningTests(unittest.TestCase):
self.assertEqual(generator.accompaniment.sounding_attacks(plan.acoustic_upper[2], contexts[2]), ())
class AccompanimentRealizationTests(unittest.TestCase):
def test_electric_preserves_half_dotted_half_and_whole_durations(self):
for beats, duration, kind, dots in ((2, 2, 'half', 0), (3, 3, 'half', 1), (4, 4, 'whole', 0), (5, 4, 'whole', 0)):
with self.subTest(beats=beats):
measure = melody_measures(1, beats=beats)[0]
events = [generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(duration), True, kind, dots)]
if beats == 5:
events.append(generator.rhythm.Event(fractions.Fraction(4), fractions.Fraction(1), False, 'quarter'))
notes = generator.accompaniment.realize(measure, generator.rhythm.Bar(tuple(events), fractions.Fraction(beats)), None, lane='electric')
self.assertEqual(notes[0]['written'], {'type': kind, 'dots': dots})
self.assertEqual(generator.model.fraction(notes[0]['duration']), duration)
self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes), beats)
self.assertEqual([generator.model.fraction(n['onset']) for n in notes if n['kind'] == 'note'], [0])
def test_clipped_long_notes_use_single_note_spelling_then_rests(self):
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole'),), fractions.Fraction(4))
for kind in ('major', 'none'):
for offset, divisions, expected in ((5, 2, fractions.Fraction(2)), (7, 2, fractions.Fraction(7, 2)), (4, 3, fractions.Fraction(4, 3))):
with self.subTest(kind=kind, offset=offset, divisions=divisions):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', kind, offset=offset) + tests.test_musicxml.xml_note(duration=4 * divisions)
measure = tests.test_musicxml.parse(xml_bars([content], divisions=divisions))['staves'][0]['measures'][0]
notes = generator.accompaniment.realize(measure, bar, None, lane='electric')
self.assertEqual(generator.model.fraction(notes[0]['duration']), expected)
self.assertEqual([n['kind'] for n in notes], ['note'] + ['rest'] * (len(notes) - 1))
self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes), 4)
if divisions == 2:
self.assertTrue(all('ratio' not in n['written'] for n in notes))
for left, right in zip(notes, notes[1:]):
self.assertEqual(generator.model.fraction(left['onset']) + generator.model.fraction(left['duration']), generator.model.fraction(right['onset']))
def test_long_note_pickup_is_clipped_without_extending_the_measure(self):
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole'),), fractions.Fraction(4))
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')
measure = tests.test_musicxml.parse(xml_bars([content], implicit=True))['staves'][0]['measures'][0]
notes = generator.accompaniment.realize(measure, bar, None, lane='electric')
self.assertEqual(timing(notes), [(0, 3, 'note')])
self.assertEqual(notes[0]['written'], {'type': 'half', 'dots': 1})
def test_harmonic_gating_removes_dangling_beams(self):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony(kind='none', offset=1) + tests.test_musicxml.xml_note(duration=8)
measure = tests.test_musicxml.parse(xml_bars([content], divisions=2))['staves'][0]['measures'][0]
@@ -278,7 +334,7 @@ class AccompanimentRealizationTests(unittest.TestCase):
measure = tests.test_musicxml.parse(xml_bars([content], divisions=2))['staves'][0]['measures'][0]
before = copy.deepcopy(measure)
events = generator.accompaniment.realize(measure, bar, None)
self.assertEqual(timing(events), [(0, fractions.Fraction(3, 2), 'note'), (fractions.Fraction(3, 2), fractions.Fraction(5, 2), 'rest')])
self.assertEqual(timing(events), [(0, fractions.Fraction(3, 2), 'note'), (fractions.Fraction(3, 2), 2, 'rest'), (fractions.Fraction(7, 2), fractions.Fraction(1, 2), 'rest')])
self.assertEqual(events[0]['_harmony']['root']['letter'], 'c')
self.assertEqual(measure, before)
def test_chord_changes_preserve_authored_rhythm_rests_until_next_attack(self):
@@ -293,9 +349,10 @@ class AccompanimentRealizationTests(unittest.TestCase):
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole'),), fractions.Fraction(4))
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F') + tests.test_parts.xml_harmony('F', offset=2) + tests.test_musicxml.xml_note()
measure = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures'][0]
events = generator.accompaniment.realize(measure, bar, None)
self.assertEqual(timing(events), [(0, 4, 'note')])
self.assertEqual(events[0]['_harmony']['root']['letter'], 'f')
for lane in ('bass', 'upper', 'electric'):
events = generator.accompaniment.realize(measure, bar, None, lane=lane)
self.assertEqual(timing(events), [(0, 4, 'note')])
self.assertEqual(events[0]['_harmony']['root']['letter'], 'f')
def test_pickup_uses_right_edge_of_nominal_bar_with_exact_clipping(self):
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(3), False, 'half', 1), generator.rhythm.Event(fractions.Fraction(3), fractions.Fraction(1), True, 'quarter')), fractions.Fraction(4))
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=3, kind='quarter', extra='<dot/>')
+13
View File
@@ -4,6 +4,7 @@ import pathlib
import unittest
import unittest.mock
import generator.accompaniment
import generator.acoustic
import generator.arrange.generate
import generator.engraving.fonts
@@ -11,6 +12,7 @@ import generator.engraving.layout
import generator.engraving.types
import generator.model
import generator.playback
import generator.rhythm
import tests.arrangement_fixtures
import tests.rhythm_fixtures
import tests.test_accompaniment
@@ -108,6 +110,17 @@ class AcousticTests(unittest.TestCase):
notes = self.combine(acoustic_document([([(0, fractions.Fraction(5, 2))], [])]))[0]
self.assertEqual([n['written'] for n in notes], [{'type': 'half', 'dots': 0}, {'type': 'half', 'dots': 0}])
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 2, 'note'), (2, 2, 'rest')])
def test_harmony_clipping_waits_for_combined_attacks_before_shortening(self):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=13) + tests.test_musicxml.xml_note(duration=16)
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content], divisions=4))
bass = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole'),), fractions.Fraction(4))
upper = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(3), False, 'half', 1), generator.rhythm.Event(fractions.Fraction(3), fractions.Fraction(1), True, 'quarter')), fractions.Fraction(4))
plan = generator.accompaniment.Plan((bass,), (upper,), ())
result = tests.arrangement_fixtures.section_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library(), plan=plan)
generator.model.validate_document(result)
notes = tests.test_musicxml.notes(result)
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 3, 'note'), (3, fractions.Fraction(1, 4), 'note'), (fractions.Fraction(13, 4), fractions.Fraction(3, 4), 'rest')])
self.assertEqual(notes[0]['written'], {'type': 'half', 'dots': 1})
def test_unrelated_voices_and_solo_slashes_are_unchanged(self):
document = acoustic_document([([(0, 4)], [(0, 4)])])
for part in ('electric', 'vocal', None):
+24 -2
View File
@@ -170,9 +170,31 @@ class GuitarPartTests(unittest.TestCase):
expected = 76 if part == 'electric' else 64
self.assertEqual({generator.model.pitch_number(n['pitches'][0]) for n in notes}, {expected})
self.assertEqual(source, before)
def test_electric_stabs_are_capped_and_do_not_continue(self):
def test_electric_whole_notes_are_uncapped_but_do_not_continue(self):
source = tests.test_accompaniment.melody_measures(1)[0]
for continuation in (False, True):
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole', continuation=continuation),), fractions.Fraction(4))
events = generator.accompaniment.realize(source, bar, None, lane='electric')
self.assertEqual(tests.test_accompaniment.timing(events), [(0, 4, 'rest')] if continuation else [(0, 1, 'note'), (1, 3, 'rest')])
self.assertEqual(tests.test_accompaniment.timing(events), [(0, 4, 'rest')] if continuation else [(0, 4, 'note')])
def test_long_electric_accompaniment_preserves_playback_durations_without_ties(self):
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', octave=4)
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 8))
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole'),), fractions.Fraction(4))
library = generator.rhythm.Library((generator.rhythm.Snippet('4-4/3/whole', '4/4', (bar,)),))
result = generator.arrange.generate.arrange_document(source, 'electric', rhythms=library)
generator.model.validate_document(result)
durations = {}
section = ''
for measure in result['staves'][0]['measures']:
section = measure.get('section_label') or section
if section not in ('Vocal Melody', 'Ending'):
continue
notes = measure['voices'][0]['notes']
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 4, 'note')])
for pitch in notes[0]['pitches']:
self.assertNotIn('tie_from', pitch)
self.assertNotIn('tie_to', pitch)
durations[pitch['id']] = generator.model.fraction(pitch['duration'])
self.assertTrue(durations)
timeline = generator.playback.compile_timeline(result)['events']
self.assertEqual({e['id']: generator.model.fraction(e['duration']) for e in timeline if e['id'] in durations}, durations)