Comping
This commit is contained in:
@@ -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
@@ -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 "
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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']]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user