This commit is contained in:
khodowany
2026-10-06 21:28:42 -05:00
parent e15dc31568
commit c7f6a30a13
30 changed files with 2957 additions and 490 deletions
+4
View File
@@ -12,3 +12,7 @@ def section_document(document, part_type):
with unittest.mock.patch('generator.arrange.form.full_arrangement_sections', return_value=[section]), generator.vocabulary.using(generator.vocabulary.load(), part_type + '_accompaniment'):
backing = generator.comping.Canonical(prepared, part_type, 'fixture') if part_type == 'acoustic' else None
return generator.arrange.generate._arrange_document(prepared, part_type, accompaniment=backing)
def melody_performance(document, part_type):
"""Isolate unchanged source melody from accompaniment/ending construction."""
return generator.arrange.generate._arrange_document(generator.parts.prepare_document(document), part_type, owned={'melody', 'vocal'})
+204 -6
View File
@@ -4,7 +4,7 @@
"composer": "",
"key_signature": "G",
"part_type": "acoustic",
"measure_count": 168,
"measure_count": 171,
"section_labels": [
{
"measure": 1,
@@ -29,6 +29,10 @@
{
"measure": 137,
"label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
}
],
"measures": [
@@ -1831,6 +1835,32 @@
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "GM6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
}
]
},
@@ -1839,7 +1869,7 @@
"composer": "",
"key_signature": "G",
"part_type": "electric",
"measure_count": 168,
"measure_count": 171,
"section_labels": [
{
"measure": 1,
@@ -1864,6 +1894,10 @@
{
"measure": 137,
"label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
}
],
"measures": [
@@ -3666,6 +3700,32 @@
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "GM6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
}
]
},
@@ -3674,7 +3734,7 @@
"composer": "",
"key_signature": "G",
"part_type": "vocal",
"measure_count": 168,
"measure_count": 171,
"section_labels": [
{
"measure": 1,
@@ -3699,6 +3759,10 @@
{
"measure": 137,
"label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
}
],
"measures": [
@@ -6113,6 +6177,41 @@
"0+2 half note G4 ",
"2+2 half note G4 "
]
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "GM6"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
}
]
},
@@ -6121,7 +6220,7 @@
"composer": "",
"key_signature": "D minor",
"part_type": "acoustic",
"measure_count": 168,
"measure_count": 171,
"section_labels": [
{
"measure": 1,
@@ -6146,6 +6245,10 @@
{
"measure": 137,
"label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
}
],
"measures": [
@@ -8082,6 +8185,32 @@
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "Dm"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
}
]
},
@@ -8090,7 +8219,7 @@
"composer": "",
"key_signature": "D minor",
"part_type": "electric",
"measure_count": 168,
"measure_count": 171,
"section_labels": [
{
"measure": 1,
@@ -8115,6 +8244,10 @@
{
"measure": 137,
"label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
}
],
"measures": [
@@ -10051,6 +10184,32 @@
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "Dm"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
}
]
},
@@ -10059,7 +10218,7 @@
"composer": "",
"key_signature": "D minor",
"part_type": "vocal",
"measure_count": 168,
"measure_count": 171,
"section_labels": [
{
"measure": 1,
@@ -10084,6 +10243,10 @@
{
"measure": 137,
"label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
}
],
"measures": [
@@ -12628,6 +12791,41 @@
"vocal_skeleton": [
"0+4 whole rest "
]
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "Dm"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
}
]
}
+93
View File
@@ -0,0 +1,93 @@
"""The held recording tail has exact score time and no vocal attacks."""
import copy
import fractions
import pathlib
import unittest
import generator.acoustic
import generator.arrange.ending
import generator.arrange.generate
import generator.fretboard
import generator.harmony
import generator.model
import generator.musicxml
import generator.parts
import generator.performance
import generator.production
import generator.score
import tests.solo_fixtures
class ArrangementEnding(unittest.TestCase):
def test_current_sources_end_on_effective_harmony_with_one_attack(self):
for name, expected in (('all-of-me', 'GM6'), ('autumn_leaves', 'Dm')):
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs') / (name + '.xml'))
before = copy.deepcopy(source)
for part in ('acoustic', 'electric', 'vocal'):
result = generator.arrange.generate.arrange_document(source, part)
measures = result['staves'][0]['measures']
tail = measures[-3:]
self.assertEqual(len(measures), 171)
self.assertEqual([m['number'] for m in tail], ['169', '170', '171'])
self.assertEqual(generator.harmony.symbol(tail[0]['harmonies'][0]), expected)
self.assertEqual([m['section_label'] for m in tail], ['Ending', '', ''])
self.assertTrue(all(m['ending_sustain'] for m in tail))
notes = [n for m in tail for n in generator.score.measure_notes(m)]
attacks = [n for n in notes if n['pitches'] and not any(p.get('tie_from') for p in n['pitches'])]
self.assertEqual(len(attacks), 0 if part == 'vocal' else 1)
if part == 'vocal':
self.assertTrue(all(n['kind'] == 'rest' and not n.get('lyrics') for n in notes))
else:
self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes if n['pitches']), 12)
self.assertEqual([p['tablature'] for p in notes[0]['pitches']], [p['tablature'] for p in notes[-1]['pitches']])
if part == 'acoustic':
self.assertIn(tuple(p['tablature']['string'] for p in attacks[0]['pitches']), ((5, 4, 3, 2), (6, 4, 3, 2)))
timing = generator.performance.timing(result)
self.assertEqual(generator.model.fraction(timing['sustain_start']), 672)
self.assertEqual(generator.model.fraction(timing['duration']), 684)
self.assertEqual(len(timing['clicks']), 672)
generator.model.validate_document(result)
generator.fretboard.validate_document(result)
self.assertEqual(source, before)
def test_ending_uses_final_meter_and_does_not_extend_solo_windows(self):
for meter in (3, 5):
source = tests.solo_fixtures.song(count=2, meter=meter, chords=('C7', 'FM7'))
prepared = generator.parts.prepare_document(source)
for part in ('acoustic', 'electric'):
role = part + '_accompaniment'
result = generator.production.generate_role(generator.production.SourceAnalysis.read(prepared), role, pathlib.Path('data'))
tail = result['document']['staves'][0]['measures'][-3:]
self.assertEqual([generator.model.fraction(m['duration']) for m in tail], [meter] * 3)
windows = generator.production.windows(prepared, part)
self.assertEqual(windows[-1][2], role)
self.assertEqual(windows[-1][1] - windows[-1][0], 3 * meter)
self.assertEqual(sum(b - a for a, b, r in windows if r == part + '_solo'), 2 * meter)
def test_missing_or_explicitly_absent_final_chord_is_not_invented(self):
for no_chord in (False, True):
source = tests.solo_fixtures.song(count=1, chords=('C7',))
last = source['staves'][0]['measures'][-1]
if no_chord:
last['harmonies'][0]['kind'] = 'none'
else:
last['harmonies'] = []
with self.assertRaisesRegex(ValueError, 'final performed harmony'):
generator.arrange.generate.arrange_document(source, 'acoustic')
def test_partial_combination_and_short_overlap_fail_after_tie_merging(self):
source = tests.solo_fixtures.song(count=1, chords=('C7',))
doc = generator.arrange.generate.arrange_document(source, 'acoustic')
tail = copy.deepcopy(doc)
tail['staves'][0]['measures'] = tail['staves'][0]['measures'][-3:]
generator.acoustic.validate_comping(tail)
first = generator.score.measure_notes(tail['staves'][0]['measures'][0])[0]
bass = first['pitches'][0]
bass.pop('tie_to')
bass['duration'] = generator.model.rational(1)
with self.assertRaises(ValueError):
generator.acoustic.validate_comping(tail)
def test_ending_markers_cannot_interrupt_the_main_score(self):
source = tests.solo_fixtures.song(count=3, chords=('C7',))
source['staves'][0]['measures'][0]['ending_sustain'] = True
with self.assertRaisesRegex(ValueError, 'contiguous'):
generator.performance.timing(source)
if __name__ == '__main__':
unittest.main()
+7 -7
View File
@@ -31,10 +31,10 @@ class ArrangementPickupTests(unittest.TestCase):
result = generator.arrange.generate.arrange_document(document, part)
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 168)
self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 169)])
self.assertEqual([m['number'] for m in measures if m.get('section_label')], ['1', '9', '41', '73', '105', '137'])
self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 672)
self.assertEqual(len(measures), 171)
self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 172)])
self.assertEqual([m['number'] for m in measures if m.get('section_label')], ['1', '9', '41', '73', '105', '137', '169'])
self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 684)
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]):
@@ -49,17 +49,17 @@ class ArrangementPickupTests(unittest.TestCase):
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')
self.assertEqual(len(result['staves'][0]['measures']), 12)
self.assertEqual(len(result['staves'][0]['measures']), 15)
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)
result = generator.arrange.generate._arrange_document(generator.parts.prepare_document(document), part, owned={'melody', 'vocal'})
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 6)
self.assertEqual(len(measures), 9)
for boundary in boundaries:
pickup = generator.score.measure_notes(measures[boundary - 1])[-1]
downbeat = generator.score.measure_notes(measures[boundary])[0]
+1 -1
View File
@@ -15,7 +15,7 @@ class CatalogTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as temporary:
source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output'
source.mkdir()
(source / 'song.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
(source / 'song.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>E</root-step><root-alter>-1</root-alter></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output), 3)
self.assertEqual({p.name for p in output.iterdir()}, {'song_acoustic.json', 'song_electric.json', 'song_vocal.json'})
for path in output.iterdir():
+5 -6
View File
@@ -87,7 +87,7 @@ class ChordFamilies(unittest.TestCase):
def test_acoustic_policy_change_does_not_reseed_electric_roles(self):
resources = generator.vocabulary.load()
changed = copy.deepcopy(resources)
changed['accompaniment']['instruments']['acoustic']['families']['quarters'] = ['1/2']
changed['accompaniment']['instruments']['acoustic']['patterns'][0]['events'][0]['duration'] = '1/2'
for role in ('electric_accompaniment', 'electric_melody', 'electric_solo', 'vocal_melody'):
self.assertEqual(generator.production.dependencies(role, resources), generator.production.dependencies(role, changed))
self.assertNotEqual(generator.production.dependencies('acoustic_accompaniment', resources), generator.production.dependencies('acoustic_accompaniment', changed))
@@ -142,16 +142,15 @@ class CombinedAcoustic(unittest.TestCase):
m = source['staves'][0]['measures'][0]
choices = generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'quarters-eighths')
self.assertTrue(all(e.end - e.start in (fractions.Fraction(1, 2), 1) for c in choices for e in c.events))
policy = generator.vocabulary.accompaniment('acoustic')
upper = generator.comping_rhythm.lane_candidates(m, None, 'upper', (), policy, 'pairs')
self.assertTrue(any(b.start - a.start == fractions.Fraction(1, 2) for c in upper for a, b in zip(c.events, c.events[1:])))
canonical = generator.comping.Canonical(source, 'acoustic', 'short')
self.assertFalse(any(l['tied_continuations'] for row in canonical.rhythm_report for l in row['layers'].values()))
for row in canonical.rhythm_report:
for event in row['events']:
self.assertGreater(fractions.Fraction(event['duration']), 0)
def test_unknown_harmony_subdivision_is_not_silently_quantized(self):
source = tests.solo_fixtures.song(count=1, chords=('C7',))
m = source['staves'][0]['measures'][0]
m['harmonies'].append({**generator.harmony.parse_symbol('FM7'), 'id': 'change', 'onset': generator.model.rational(fractions.Fraction(1, 3))})
with self.assertRaisesRegex(ValueError, 'no bass rhythm'):
with self.assertRaisesRegex(ValueError, 'no acoustic pattern'):
generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'exact')
if __name__ == '__main__':
+97 -75
View File
@@ -6,6 +6,7 @@ import pathlib
import unittest
import generator.accompaniment
import generator.acoustic
import generator.arrange.accompaniment
import generator.comping
import generator.comping_rhythm
@@ -29,56 +30,64 @@ class LayeredComping(unittest.TestCase):
def setUp(self):
self.source = tests.solo_fixtures.song(count=6, chords=('C7', 'C7', 'FM7', 'FM7', 'Dm7', 'G7'))
self.resources = generator.vocabulary.load()
def test_candidates_preserve_all_counts_and_articulations(self):
m = self.source['staves'][0]['measures'][0]
for lane, expected in (('electric', {0, 1, 2}), ('bass', {1, 2}), ('upper', {1, 2, 3})):
policy = generator.vocabulary.accompaniment('electric' if lane == 'electric' else 'acoustic', self.resources)
candidates = generator.comping_rhythm.lane_candidates(m, m['harmonies'][0], lane, (), policy, 'test')
self.assertEqual({len(c.events) for c in candidates}, expected)
self.assertTrue(any(e.end - e.start <= fractions.Fraction(1, 2) for c in candidates for e in c.events) if lane != 'bass' else True)
if lane == 'electric':
self.assertTrue(any(e.end - e.start >= 2 for c in candidates for e in c.events))
else:
self.assertEqual({e.end - e.start for c in candidates for e in c.events}, {fractions.Fraction(1, 2), fractions.Fraction(1)})
if lane == 'bass':
self.assertTrue(all(c.events[0].start == 0 for c in candidates))
def test_ranges_downbeats_and_overlap(self):
canonical = generator.comping.Canonical(self.source, 'acoustic', 'light')
carried = generator.accompaniment.carried_chords(self.source['staves'][0]['measures'])
for i, (m, prior) in enumerate(zip(self.source['staves'][0]['measures'], carried)):
result = canonical(m, prior, i)
voices = {v['id']: v['notes'] for v in result['voices']}
self.assertEqual(set(voices), {'bass', 'upper'})
self.assertTrue(voices['bass'][0]['pitches'])
self.assertEqual(generator.model.fraction(voices['bass'][0]['onset']), 0)
for lane, high in (('bass', 2), ('upper', 3)):
attacks = [n for n in voices[lane] if n['pitches'] and not any(p.get('tie_from') for p in n['pitches'])]
self.assertTrue(1 <= len(attacks) <= high)
self.assertTrue(any(fractions.Fraction(r['overlap_beats']) > 0 for r in canonical.rhythm_report))
self.assertTrue(all(abs(sum(r['contributions'].values()) - r['selection_cost']) < 1e-10 for r in canonical.rhythm_report))
def test_combined_crowding_favors_shared_attacks(self):
m = self.source['staves'][0]['measures'][0]
candidates = generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'joint')
self.assertTrue(any(dict(c.contributions)['combined_crowding'] == 0 for c in candidates))
self.assertTrue(any(len({e.start for e in c.events}) < len(c.events) for c in candidates))
shared = (generator.comping_rhythm.Attack('bass', fractions.Fraction(), fractions.Fraction(2)), generator.comping_rhythm.Attack('upper', fractions.Fraction(), fractions.Fraction(1)))
separated = (shared[0], dataclasses.replace(shared[1], start=fractions.Fraction(1, 2)))
self.assertLess(generator.comping_rhythm.crowding(shared), generator.comping_rhythm.crowding(separated))
def test_harmonic_changes_share_one_measure_budget(self):
m = copy.deepcopy(self.source['staves'][0]['measures'][0])
m['harmonies'] = [{**generator.harmony.parse_symbol(s), 'id': str(i), 'onset': generator.model.rational(t)} for i, (s, t) in enumerate((('C7', 0), ('FM7', 1), ('G7', 2)))]
def block(self, part, source=None, melody=()):
source = source or self.source
measures = source['staves'][0]['measures'][:2]
inputs = tuple((m, prior, i) for i, (m, prior) in enumerate(zip(measures, generator.accompaniment.carried_chords(measures))))
flat = generator.comping_rhythm.flatten(inputs)
lengths = tuple(generator.model.fraction(m['duration']) for m in measures)
return generator.comping_rhythm.candidates(flat, None, part, melody, 'test', lengths=lengths)
def test_all_ten_families_are_reachable_and_roles_have_distinct_onsets(self):
expected = {'charleston', 'shifted_charleston', 'forward_motion', 'anticipated_heavy_beats', 'syncopated_riff', 'quarters', 'dotted_spacing', 'shifting_motif', 'call_response', 'anticipated_four'}
choices = {part: self.block(part) for part in ('acoustic', 'electric')}
for part, candidates in choices.items():
self.assertTrue(expected <= {dict(c.families)['pattern'] for c in candidates})
for c in candidates:
generator.comping_rhythm.validate_events(c.events, part)
for start in (0, 4):
self.assertLessEqual(sum(start <= e.start < start + 4 for e in c.events), 3 if part == 'acoustic' else 2)
self.assertAlmostEqual(c.cost, sum(dict(c.contributions).values()))
a = {tuple(e.start for e in c.events) for c in choices['acoustic']}
e = {tuple(e.start for e in c.events) for c in choices['electric']}
self.assertFalse(a & e)
self.assertTrue(any(c.events[0].start > 0 for c in choices['acoustic']))
self.assertTrue(any(c.events[0].lane == 'upper' for c in choices['acoustic']))
self.assertTrue(any(e.end - e.start > 1 for c in choices['electric'] for e in c.events))
def test_separate_short_attacks_and_only_synchronized_long_combinations(self):
F = fractions.Fraction
attack = generator.comping_rhythm.Attack
generator.comping_rhythm.validate_events((attack('bass', F(0), F(1)), attack('upper', F(1), F(3, 2))), 'acoustic')
generator.comping_rhythm.validate_events((attack('combined', F(0), F(3, 2)),), 'acoustic')
for events in ((attack('combined', F(0), F(1)),), (attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(2)),)):
with self.assertRaises(ValueError):
generator.comping_rhythm.validate_events(events, 'acoustic')
def test_harmonic_changes_clip_direct_holds_without_extra_attacks(self):
source = copy.deepcopy(self.source)
for m in source['staves'][0]['measures'][:2]:
m['harmonies'] = [{**generator.harmony.parse_symbol(s), 'id': str(i), 'onset': generator.model.rational(t)} for i, (s, t) in enumerate((('C7', 0), ('FM7', 1), ('G7', 2)))]
for part in ('acoustic', 'electric'):
choices = generator.comping_rhythm.candidates(m, None, part, (), 'changes')
for c in choices:
for lane, high in (('bass', 2), ('upper', 3), ('electric', 2)):
count = sum(e.lane == lane for e in c.events)
self.assertLessEqual(count, high + 1)
if count > high:
self.assertIn((lane, 'extra attack for harmonic changes'), c.exceptions)
for c in self.block(part, source):
self.assertLessEqual(len(c.events), 6 if part == 'acoustic' else 4)
for e in c.events:
self.assertLessEqual(e.end, 1 if e.start < 1 else 2 if e.start < 2 else 4)
if part == 'electric':
self.assertTrue(all(c.exceptions for c in choices))
if e.harmonic_time is None:
self.assertFalse(any(e.start < boundary < e.end for boundary in (1, 2, 4, 5, 6)))
def test_anticipation_binds_exact_upcoming_chord_and_keeps_direct_alternative(self):
source = tests.solo_fixtures.song(count=2, chords=('C7', 'FM7'))
for part in ('acoustic', 'electric'):
candidates = self.block(part, source)
anticipated = [c for c in candidates if any(e.harmonic_time is not None for e in c.events)]
self.assertTrue(anticipated)
for c in anticipated:
for e in c.events:
if e.harmonic_time is not None:
self.assertIn(e.lane, ('upper', 'electric'))
self.assertEqual(e.harmonic_time - e.start, fractions.Fraction(1, 2))
self.assertLess(e.harmonic_time, e.end)
self.assertTrue(any(all(e.harmonic_time is None for e in c.events) for c in candidates))
flat = generator.comping_rhythm.flatten(tuple((m, p, i) for i, (m, p) in enumerate(zip(source['staves'][0]['measures'], generator.accompaniment.carried_chords(source['staves'][0]['measures'])))))
pattern = next(p for p in self.resources['accompaniment']['instruments']['electric']['patterns'] if p['id'] == 'electric.forward_motion.statement')
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'electric', anticipate=True, limit=fractions.Fraction(4))
self.assertTrue(all(e.harmonic_time is None for e in events))
def test_no_chord_is_silent_and_partial_measure_is_bounded(self):
m = copy.deepcopy(self.source['staves'][0]['measures'][0])
m['harmonies'][0]['kind'] = 'none'
@@ -86,23 +95,21 @@ class LayeredComping(unittest.TestCase):
m['harmonies'][0] = {**generator.harmony.parse_symbol('C7'), 'id': 'chord', 'onset': generator.model.rational(0)}
m['duration'] = generator.model.rational(fractions.Fraction(1, 2))
self.assertTrue(all(0 <= e.start < e.end <= fractions.Fraction(1, 2) for c in generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'pickup') for e in c.events))
def test_two_other_meters_preserve_full_bar_bounds(self):
def test_two_other_meters_use_authored_patterns(self):
for meter in (3, 5):
source = tests.solo_fixtures.song(meter=meter, count=2, chords=('C7',))
for part in ('acoustic', 'electric'):
canonical = generator.comping.Canonical(source, part, 'meter')
self.assertTrue(all(str(meter) + '_beat' in r.pattern_id for r in canonical.block_rhythms.values()))
for rhythm in canonical.rhythms.values():
self.assertTrue(all(0 <= e.start < e.end <= meter for e in rhythm.events))
def test_melody_changes_the_objective_without_changing_seed(self):
e = generator.comping_rhythm.Attack('electric', fractions.Fraction(1), fractions.Fraction(3, 2))
m = self.source['staves'][0]['measures'][0]
harmony = generator.comping_rhythm.spans(m, None)
busy = tuple((fractions.Fraction(i), fractions.Fraction(i + 1)) for i in range(4))
rest = ((fractions.Fraction(), fractions.Fraction(1, 2)),)
first = dict(generator.comping_rhythm.lane_score((e,), 'electric', busy, harmony, fractions.Fraction(4)))
second = dict(generator.comping_rhythm.lane_score((e,), 'electric', rest, harmony, fractions.Fraction(4)))
self.assertGreater(first['melody_support'], second['melody_support'])
self.assertNotEqual(generator.comping_rhythm.candidates(m, None, 'electric', busy, 'same'), generator.comping_rhythm.candidates(m, None, 'electric', rest, 'same'))
busy = tuple((fractions.Fraction(i), fractions.Fraction(i + 1)) for i in range(8))
space = ((fractions.Fraction(), fractions.Fraction(1, 2)),)
first = {c.pattern_id: dict(c.contributions) for c in self.block('electric', melody=busy)}
second = {c.pattern_id: dict(c.contributions) for c in self.block('electric', melody=space)}
common = first.keys() & second.keys()
self.assertTrue(any(first[k]['melody_support'] != second[k]['melody_support'] for k in common))
def test_tempo_changes_metadata_but_not_accompaniment_music(self):
changed = copy.deepcopy(self.source)
changed['metadata']['tempo'] = 240
@@ -144,24 +151,39 @@ class LayeredComping(unittest.TestCase):
m['voices'] = [{'id': str(i), 'notes': [generator.model.make_event(str(i), fractions.Fraction(i), fractions.Fraction(2), 'note', [p])]} for i, p in enumerate(pitches)]
with self.assertRaisesRegex(ValueError, 'overlapping guitar voices'):
generator.fretboard.validate_document(doc)
def test_sustained_join_is_one_attack_and_keeps_position(self):
def test_explicit_cross_bar_hold_survives_projection_without_reattack(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',))
canonical = generator.comping.Canonical(source, 'electric', 'tie')
inputs = [(m, p, i) for i, (m, p) in enumerate(zip(source['staves'][0]['measures'], generator.accompaniment.carried_chords(source['staves'][0]['measures'])))]
chord = inputs[0][0]['harmonies'][0]
grip = generator.comping.voicings(generator.comping.semantic_key(chord), 'electric')[0]
for m, p, i in inputs:
note = generator.model.make_event('held' + str(i), fractions.Fraction(), fractions.Fraction(4), 'note', generator.fretboard.chord_pitches(chord, grip))
note['voice'] = 'electric'
canonical.cache[canonical.key(m, p, i)]['voices'] = [{'id': 'electric', 'notes': [note]}]
canonical._rhythm_reports[canonical.key(m, p, i)]['layers']['electric'].update(attacks=1, tied_continuations=0)
canonical.connect_holds(inputs)
a, b = [canonical.cache[canonical.key(*args)]['voices'][0]['notes'][0] for args in inputs]
for p, q in zip(a['pitches'], b['pitches']):
self.assertEqual(p['tie_to'], q['id'])
self.assertEqual(q['tie_from'], p['id'])
self.assertEqual(p['tablature'], q['tablature'])
self.assertEqual(canonical.rhythm_report[1]['layers']['electric']['attacks'], 0)
for part, pattern_id in (('electric', 'electric.forward_motion.statement'), ('acoustic', 'acoustic.call_response.held_answer')):
resources = copy.deepcopy(self.resources)
patterns = resources['accompaniment']['instruments'][part]['patterns']
resources['accompaniment']['instruments'][part]['patterns'] = [p for p in patterns if p['id'] == pattern_id]
with generator.vocabulary.using(resources, part + '_accompaniment'):
canonical = generator.comping.Canonical(source, part, 'held')
measures = source['staves'][0]['measures']
rendered = [canonical(m, p, i) for i, (m, p) in enumerate(zip(measures, generator.accompaniment.carried_chords(measures)))]
a = [p for n in generator.score.measure_notes(rendered[0]) for p in n['pitches'] if p.get('tie_to')]
b = {p['id']: p for n in generator.score.measure_notes(rendered[1]) for p in n['pitches']}
cross = [p for p in a if p['tie_to'] in b]
self.assertTrue(cross)
for p in cross:
self.assertEqual(p['tablature'], b[p['tie_to']]['tablature'])
self.assertEqual(p['id'], b[p['tie_to']]['tie_from'])
if part == 'acoustic':
document = copy.deepcopy(source)
document['staves'][0]['measures'] = rendered
generator.acoustic.validate_comping(document)
def test_separate_repeated_attacks_are_never_joined(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',))
resources = copy.deepcopy(self.resources)
resources['accompaniment']['instruments']['electric']['patterns'] = [{'id': 'test.repeated', 'family': 'quarters', 'lengths': ['4', '4'],
'events': [{'onset': str(t), 'duration': '4', 'layer': 'electric'} for t in (0, 4)]}]
with generator.vocabulary.using(resources, 'electric_accompaniment'):
canonical = generator.comping.Canonical(source, 'electric', 'repeat')
measures = source['staves'][0]['measures']
rendered = [canonical(m, p, i) for i, (m, p) in enumerate(zip(measures, generator.accompaniment.carried_chords(measures)))]
notes = [n for m in rendered for n in generator.score.measure_notes(m) if n['pitches']]
self.assertEqual(len(notes), 2)
self.assertTrue(all(not p.get('tie_from') and not p.get('tie_to') for n in notes for p in n['pitches']))
class SoloRhythmConditioning(unittest.TestCase):
def test_melody_rhythm_and_progression_change_learned_probabilities(self):
+2 -2
View File
@@ -353,7 +353,7 @@ class EngravingCacheTests(unittest.TestCase):
data, output = self.root / 'data', self.root / 'output'
(data / 'songs').mkdir(parents=True)
source = data / 'songs/fixture.xml'
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
source.write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note()))
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False, engraving_cache=self.cache, jobs=2)
generator.__main__.generate_assets(args)
original = self.files(output)
@@ -364,7 +364,7 @@ class EngravingCacheTests(unittest.TestCase):
self.assertEqual(original, self.files(output))
self.assertFalse((output / 'solo_xml').exists())
self.assertFalse(list(output.rglob('*.xml')))
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
source.write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'unavailableGlyph'):
generator.__main__.generate_assets(args)
self.assertEqual(original, self.files(output))
+2 -2
View File
@@ -255,7 +255,7 @@ class GuitarTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as temporary:
source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output'
source.mkdir()
(source / '0000.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
(source / '0000.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>E</root-step><root-alter>-1</root-alter></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output), 3)
first = {p.name: p.read_bytes() for p in output.rglob('*.json')}
generator.arrange.generate.generate_performance_scores(source, output)
@@ -264,7 +264,7 @@ class GuitarTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
(root / 'data/songs').mkdir(parents=True)
(root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=7, alter=1)))
(root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note(octave=7, alter=1)))
output = root / 'output'
output.mkdir()
(output / 'last-good').write_text('preserved')
+1 -1
View File
@@ -137,7 +137,7 @@ class GuitarPartTests(unittest.TestCase):
def test_song_melody_only_is_raised_an_octave(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4)))
before = copy.deepcopy(source)
for builder in (tests.arrangement_fixtures.section_document, generator.arrange.generate.arrange_document):
for builder in (tests.arrangement_fixtures.section_document, tests.arrangement_fixtures.melody_performance):
for part in ('electric', 'vocal'):
result = builder(source, part)
notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] if n['pitches']]
+3 -3
View File
@@ -84,7 +84,7 @@ class MusicModelTests(unittest.TestCase):
result = generator.arrange.generate.arrange_document(source, part)
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody'])
self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody', 'Ending'])
self.assertFalse(measures[-1]['harmonies'])
self.assertTrue(any(h for m in measures[:-1] for h in m['harmonies']))
self.assertFalse(any('chord_optional' in h for m in measures for h in m['harmonies']))
@@ -115,7 +115,7 @@ class MusicModelTests(unittest.TestCase):
with self.assertRaises(ValueError):
generator.__main__.generate_assets(args)
self.assertEqual((output / 'last-good').read_text(), 'preserved')
(data / 'songs' / 'bad.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8')
(data / 'songs' / 'bad.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note()), encoding='utf-8')
args.check = True
# --check uses the validation-only repository fixture. An alternate
# source tree fails its form contract before atomic publication.
@@ -134,7 +134,7 @@ class MusicModelTests(unittest.TestCase):
data, output = pathlib.Path(temp) / 'data', pathlib.Path(temp) / 'output'
(data / 'songs').mkdir(parents=True)
source = data / 'songs' / 'fixture.xml'
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8')
source.write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note()), encoding='utf-8')
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
generator.__main__.generate_assets(args)
first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
+3 -1
View File
@@ -40,6 +40,8 @@ class PartGenerationTests(unittest.TestCase):
generator.model.validate_document(result)
for measure in result['staves'][0]['measures']:
events = measure['voices'][0]['notes']
if measure.get('ending_sustain'):
continue
if measure.get('section_label') in ('Rhythm Lead-in', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo'):
continue
self.assert_complete_measure(measure)
@@ -91,7 +93,7 @@ class PartGenerationTests(unittest.TestCase):
source = tests.test_musicxml.parse(xml.etree.ElementTree.tostring(root, encoding='unicode'))
before = copy.deepcopy(source)
expected = [n for m in source['staves'][0]['measures'] for n in m['voices'][0]['notes']]
for builder in (tests.arrangement_fixtures.section_document, generator.arrange.generate.arrange_document):
for builder in (tests.arrangement_fixtures.section_document, tests.arrangement_fixtures.melody_performance):
for part in ('electric', 'vocal'):
with self.subTest(builder=builder.__module__, part=part):
result = builder(source, part)
+20 -1
View File
@@ -58,7 +58,7 @@ BOOST_AUTO_TEST_CASE(recordings_and_clicks_share_clock_and_tail_is_unclicked) {
BOOST_REQUIRE(engine);
auto session = engine->play_transport(std::move(*prepared));
BOOST_REQUIRE(session);
auto frames = backing ? 576000U : 288000U;
auto frames = 576000U;
auto buffer = std::array<float, 960>{};
for (unsigned cursor = 0; cursor < frames; cursor += 480) {
BOOST_REQUIRE(engine->render(buffer.data(), 480));
@@ -164,6 +164,25 @@ BOOST_AUTO_TEST_CASE(page_timing_and_recording_manifests_reject_corruption) {
BOOST_CHECK(!vamp_zero::prepare_performance(score(), app, VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F));
}
BOOST_AUTO_TEST_CASE(written_ending_boundary_and_clicks_are_validated) {
auto original = boost::json::parse(read("score.json"));
auto document = score();
BOOST_TEST(document.measure_count == 5U);
BOOST_TEST(document.duration.numerator == 20U);
BOOST_TEST(document.sustain_start.numerator == 8U);
BOOST_TEST(vamp_zero::performance_at(document, 287999, 120).phase == "Playing");
BOOST_TEST(vamp_zero::performance_at(document, 288000, 120).phase == "Sustain");
auto changed = original;
changed.at("performance").at("sustain_start").at("numerator") = 9;
BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(changed), "score.json"));
changed = original;
changed.at("music").at("staves").as_array()[0].at("measures").as_array()[2].as_object().erase("ending_sustain");
BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(changed), "score.json"));
changed = original;
changed.at("performance").at("clicks").as_array().push_back(boost::json::object{{"beat", boost::json::object{{"numerator", 8}, {"denominator", 1}}}, {"downbeat", true}});
BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(changed), "score.json"));
}
BOOST_AUTO_TEST_CASE(preparation_waits_for_pages_and_cancellation_discards_stale_work) {
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .no_device = true});
BOOST_REQUIRE(engine);
+5 -3
View File
@@ -17,9 +17,10 @@ def fixture(root):
music = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
first = music['staves'][0]['measures'][0]
music['staves'][0]['measures'].append(copy.deepcopy(first))
music['staves'][0]['measures'].extend({**copy.deepcopy(first), 'ending_sustain': True} for _ in range(3))
music['metadata']['song_id'] = 'fixture'
pages = [dict(path=f'{i + 1}.png', sha256='0' * 64, first_measure=i + 1, measure_count=1,
start_beat=generator.model.rational(i * 4), end_beat=generator.model.rational((i + 1) * 4)) for i in range(2)]
pages = [dict(path=f'{i + 1}.png', sha256='0' * 64, first_measure=i + 1, measure_count=1 if i == 0 else 4,
start_beat=generator.model.rational(i * 4), end_beat=generator.model.rational(4 if i == 0 else 20)) for i in range(2)]
bundle = dict(music=music, presentation=dict(width=976, height=484, pages=pages), performance=generator.performance.timing(music))
(root / 'score.json').write_text(json.dumps(bundle))
entries = []
@@ -73,7 +74,8 @@ class PerformanceTests(unittest.TestCase):
bundle = json.loads((root / 'score.json').read_text())
generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages'])
self.assertEqual([c['downbeat'] for c in bundle['performance']['clicks']], [True, False, False, False] * 2)
self.assertEqual(bundle['performance']['duration'], generator.model.rational(8))
self.assertEqual(bundle['performance']['duration'], generator.model.rational(20))
self.assertEqual(bundle['performance']['sustain_start'], generator.model.rational(8))
bundle['presentation']['pages'][1]['start_beat'] = generator.model.rational(3)
with self.assertRaisesRegex(ValueError, 'timing'):
generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages'])