Construction
This commit is contained in:
@@ -35,12 +35,13 @@ class AcousticPairs(unittest.TestCase):
|
||||
self.assertEqual(tuple(e.start for e in events), tuple(map(F, onsets)))
|
||||
def test_pairing_uses_attack_spacing_not_written_duration(self):
|
||||
events = self.assign(('0', '1/2'), ('1', '1'))
|
||||
self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1)), ('upper', F(3, 2))])
|
||||
# Bass keeps ringing, without another bass attack under the upper chord.
|
||||
self.assertGreater(events[0].end, events[1].start)
|
||||
def test_same_strings_release_but_different_strings_can_ring(self):
|
||||
self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1, 2)), ('upper', F(3, 2))])
|
||||
self.assertEqual(events[0].end, events[1].start)
|
||||
def test_each_attack_releases_previous_sound_in_both_directions(self):
|
||||
events = self.assign(('0', '1/2', '1'), ('2', '2', '1'))
|
||||
self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1)), ('upper', F(1)), ('combined', F(2))])
|
||||
self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1, 2)), ('upper', F(1)), ('combined', F(2))])
|
||||
events = self.assign(('0', '1/2', '1', '3/2'), ('2', '2', '2', '2'))
|
||||
self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1, 2)), ('upper', F(1)), ('bass', F(3, 2)), ('upper', F(7, 2))])
|
||||
events = self.assign(('0', '1/2'), ('1', '3'))
|
||||
self.assertEqual(events[-1].end, F(7, 2))
|
||||
def test_chord_change_breaks_pair_but_repeated_label_does_not(self):
|
||||
@@ -66,17 +67,17 @@ class AcousticPairs(unittest.TestCase):
|
||||
{'onset': str(t), 'duration': '1', 'layer': 'acoustic'} for t in (F(beats) - F(1, 2), F(beats))]}
|
||||
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
|
||||
self.assertEqual([e.lane for e in events], ['bass', 'upper'])
|
||||
self.assertEqual(events[0].end, F(beats) + F(1, 2))
|
||||
def test_anticipated_pair_plays_same_future_harmony(self):
|
||||
self.assertEqual(events[0].end, F(beats))
|
||||
def test_release_rebinds_expired_anticipation_to_direct_harmony(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7', 'FM7'))
|
||||
flat = generator.comping_rhythm.flatten(tuple((m, None, i) for i, m in enumerate(source['staves'][0]['measures'])))
|
||||
pattern = {'lengths': ['4', '4'], 'events': [
|
||||
{'onset': '7/2', 'duration': '1', 'layer': 'acoustic', 'anticipate': True},
|
||||
{'onset': '4', 'duration': '1', 'layer': 'acoustic'}]}
|
||||
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic', anticipate=True)
|
||||
self.assertEqual([e.lane for e in events], ['bass', 'upper'])
|
||||
self.assertEqual(events[0].harmonic_time, 4)
|
||||
self.assertGreater(events[0].end, 4)
|
||||
self.assertEqual([e.lane for e in events], ['combined', 'combined'])
|
||||
self.assertIsNone(events[0].harmonic_time)
|
||||
self.assertEqual(events[0].end, 4)
|
||||
# A participation break prevents pairing; an expired anticipation must
|
||||
# revert to the current harmony rather than keep an unresolved label.
|
||||
raw, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic', anticipate=True, assign=False)
|
||||
@@ -84,6 +85,11 @@ class AcousticPairs(unittest.TestCase):
|
||||
self.assertEqual([e.lane for e in broken], ['combined', 'combined'])
|
||||
self.assertIsNone(broken[0].harmonic_time)
|
||||
self.assertEqual(broken[0].end, 4)
|
||||
# With no intervening attack the bounded anticipated hold stays intact.
|
||||
pattern['events'].pop()
|
||||
held, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic', anticipate=True)
|
||||
self.assertEqual(held[0].harmonic_time, 4)
|
||||
self.assertEqual(held[0].end, F(9, 2))
|
||||
def resources(self):
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['acoustic']
|
||||
|
||||
@@ -65,6 +65,58 @@ class RecurringGrooves(unittest.TestCase):
|
||||
selected, _, report = generator.comping_rhythm.choose_cycles(groups(source), 'electric', generator.comping_rhythm.melody_contexts(source), 'coverage')
|
||||
self.assertTrue(all(p['id'] == 'electric.quarters.statement' for p, _, _ in selected))
|
||||
self.assertEqual(report[0]['cost'], 0)
|
||||
def test_acoustic_cycle_is_ranked_after_cross_block_assignment(self):
|
||||
source = tests.solo_fixtures.song(count=4, chords=('C7',))
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['acoustic']
|
||||
policy['groove']['cycle_patterns'] = 1
|
||||
policy['patterns'] = [{'id': name, 'family': 'quarters', 'lengths': ['4', '4'], 'events': [
|
||||
{'onset': t, 'duration': '1', 'layer': 'acoustic'} for t in times]}
|
||||
for name, times in (('crossing', ('0', '1/2', '15/2')), ('local', ('0', '2', '6')))]
|
||||
def evaluated(events, *args):
|
||||
# Isolated retrieval prefers 'local' (0) over 'crossing' (1).
|
||||
# The complete stream permits a prepared upper entry in block two.
|
||||
cost = -3 if events[0].lane == 'upper' else 1 if events[1].start == fractions.Fraction(1, 2) else 0
|
||||
return [('test_entry', cost)]
|
||||
with generator.vocabulary.using(resources, 'acoustic_accompaniment'), unittest.mock.patch('generator.comping_rhythm.evaluate', side_effect=evaluated):
|
||||
selected, _, reports = generator.comping_rhythm.choose_cycles(groups(source), 'acoustic', generator.comping_rhythm.melody_contexts(source), 'complete')
|
||||
self.assertEqual([p['id'] for p, _, _ in selected], ['crossing', 'crossing'])
|
||||
self.assertEqual([r.events[0].lane for _, _, r in selected], ['bass', 'upper'])
|
||||
self.assertEqual(reports[0]['retrieval_cost'], 4)
|
||||
self.assertEqual(reports[0]['cost'], -4)
|
||||
self.assertEqual(reports[0]['cost'], sum(r.cost * 2 for _, _, r in selected))
|
||||
self.assertEqual(reports[0]['cost'], reports[0]['final_cost'])
|
||||
for _, _, rhythm in selected:
|
||||
generator.comping_rhythm.validate_events(rhythm.events, 'acoustic')
|
||||
def test_acoustic_fill_cannot_reassign_neighboring_attacks(self):
|
||||
source = tests.solo_fixtures.song(count=8, chords=('C7',))
|
||||
source['staves'][0]['measures'][2]['section_label'] = 'Source boundary'
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['acoustic']
|
||||
policy['patterns'] = [{'id': name, 'family': 'quarters', 'lengths': ['4', '4'], 'events': [
|
||||
{'onset': t, 'duration': '1/2', 'layer': 'acoustic'} for t in ('0', '2', last)]}
|
||||
for name, last in (('base', '5'), ('unsafe', '15/2'), ('safe', '7'))]
|
||||
source_groups = groups(source)
|
||||
melody = generator.comping_rhythm.melody_contexts(source)
|
||||
context = generator.comping_rhythm.acoustic_context(source_groups, melody)
|
||||
base = policy['patterns'][0]
|
||||
selected = [(base, False, generator.comping_rhythm.realize(base, g, 'acoustic', melody, assign=False)) for g in source_groups]
|
||||
locations = [{'fill': None} for _ in selected]
|
||||
original = generator.comping_rhythm.realize_acoustic_form([r for _, _, r in selected], context)
|
||||
def evaluated(events, part, melody, *args):
|
||||
# Only the first block has this source entrance. The preferred local
|
||||
# fill pairs with the next block's first attack, so it is ineligible.
|
||||
eligible = melody[0][0] == 0
|
||||
cost = (-2 if events[-1].start == fractions.Fraction(15, 2) else -1 if events[-1].start == 7 else 0) if eligible else 0
|
||||
return [('test_fill', cost)]
|
||||
# Distinguish source contexts without relying on the pattern or part ID.
|
||||
contexts = tuple(m if i < 2 else tuple((a + fractions.Fraction(1, 4), b) for a, b in m) for i, m in enumerate(melody))
|
||||
context = generator.comping_rhythm.acoustic_context(source_groups, contexts)
|
||||
with generator.vocabulary.using(resources, 'acoustic_accompaniment'), unittest.mock.patch('generator.comping_rhythm.evaluate', side_effect=evaluated):
|
||||
generator.comping_rhythm.add_fills(source_groups, selected, locations, 'acoustic', contexts, 'fill', context=context)
|
||||
self.assertEqual(locations[0]['fill']['pattern'], 'base.fill.safe')
|
||||
final = generator.comping_rhythm.realize_acoustic_form([r for _, _, r in selected], context)
|
||||
self.assertEqual([r.events for r in original[1:]], [r.events for r in final[1:]])
|
||||
def test_melody_and_progression_influence_selection_with_fixed_seed(self):
|
||||
source = tests.solo_fixtures.song(count=16, chords=('C7', 'FM7'))
|
||||
sparse = copy.deepcopy(source)
|
||||
@@ -149,8 +201,9 @@ class RecurringGrooves(unittest.TestCase):
|
||||
after = canonical(changed[6], changed[5]['harmonies'][0], 6, sources=changed)
|
||||
self.assertEqual(signature(before), signature(after))
|
||||
self.assertEqual(canonical.rhythm_report[-1]['base_pattern'], canonical.locations[6]['base_pattern'])
|
||||
def test_overlap_survives_single_voice_merge_and_tablature(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7',))
|
||||
def test_bass_eighth_then_upper_quarter_survives_single_voice_merge(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('Dm7',))
|
||||
source['staves'][0]['measures'][0]['harmonies'].append({**generator.harmony.parse_symbol('G7'), 'id': 'middle', 'onset': generator.model.rational(2)})
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['acoustic']
|
||||
policy['patterns'] = [p for p in policy['patterns'] if p['id'] == 'acoustic.quarters.overlapping']
|
||||
@@ -165,9 +218,14 @@ class RecurringGrooves(unittest.TestCase):
|
||||
generator.acoustic.validate_comping(source)
|
||||
first = source['staves'][0]['measures'][0]
|
||||
self.assertEqual(len(first['voices']), 1)
|
||||
overlap = next(n for n in generator.score.measure_notes(first) if generator.model.fraction(n['onset']) == fractions.Fraction(1, 2))
|
||||
self.assertEqual(len(overlap['pitches']), 4)
|
||||
self.assertTrue(any(p.get('tie_from') and p['tablature']['string'] >= 5 for p in overlap['pitches']))
|
||||
notes = [n for n in generator.score.measure_notes(first) if n['pitches']]
|
||||
self.assertEqual([(generator.model.fraction(n['onset']), generator.model.fraction(n['duration'])) for n in notes],
|
||||
[(0, fractions.Fraction(1, 2)), (fractions.Fraction(1, 2), 1), (2, 1)])
|
||||
strings = [{p['tablature']['string'] for p in n['pitches']} for n in notes]
|
||||
self.assertIn(strings[0], ({5}, {6}))
|
||||
self.assertEqual(strings[1], {2, 3, 4})
|
||||
self.assertIn(strings[2], ({2, 3, 4, 5}, {2, 3, 4, 6}))
|
||||
self.assertFalse(any(p.get('tie_from') or p.get('tie_to') for n in notes for p in n['pitches']))
|
||||
def test_mid_measure_chord_change_bass_without_forced_barline_attack(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7',))
|
||||
m = source['staves'][0]['measures'][0]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Independent rhythm contracts, including real overlapping guitar voices."""
|
||||
"""Independent rhythm contracts and their complete sounding durations."""
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
@@ -53,14 +53,13 @@ class LayeredComping(unittest.TestCase):
|
||||
self.assertTrue(any(c.events[0].start > 0 for c in choices['acoustic']))
|
||||
self.assertTrue(all(c.events[0].lane in ('bass', 'combined') for c in choices['acoustic'] if c.events))
|
||||
self.assertTrue(any(e.end - e.start > 1 for c in choices['electric'] for e in c.events))
|
||||
def test_separate_lanes_can_overlap_and_short_combined_attacks_are_valid(self):
|
||||
def test_separate_attacks_cannot_overlap_and_combined_attacks_are_valid(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')
|
||||
generator.comping_rhythm.validate_events((attack('combined', F(0), F(1)),), 'acoustic')
|
||||
generator.comping_rhythm.validate_events((attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(3, 2))), 'acoustic')
|
||||
for events in ((attack('combined', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(1)), attack('bass', F(1, 2), F(1))), (attack('bass', F(0), F(1, 3)),)):
|
||||
for events in ((attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(3, 2))), (attack('combined', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(1)), attack('bass', F(1, 2), F(1))), (attack('bass', F(0), F(1, 3)),)):
|
||||
with self.assertRaises(ValueError):
|
||||
generator.comping_rhythm.validate_events(events, 'acoustic')
|
||||
def test_bass_first_keeps_pattern_timing_and_does_not_reset_at_barlines(self):
|
||||
@@ -72,7 +71,10 @@ class LayeredComping(unittest.TestCase):
|
||||
if pattern['lengths'] != ['4', '4']:
|
||||
continue
|
||||
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
|
||||
self.assertEqual([(e.start, e.end - e.start) for e in events], [(fractions.Fraction(r['onset']), fractions.Fraction(r['duration'])) for r in pattern['events']])
|
||||
self.assertEqual([e.start for e in events], [fractions.Fraction(r['onset']) for r in pattern['events']])
|
||||
for i, (e, r) in enumerate(zip(events, pattern['events'])):
|
||||
intended = e.start + fractions.Fraction(r['duration'])
|
||||
self.assertEqual(e.end, min(intended, events[i + 1].start) if i + 1 < len(events) else intended)
|
||||
if events:
|
||||
self.assertIn(events[0].lane, ('bass', 'combined'))
|
||||
pattern = {'lengths': ['4', '4'], 'events': [{'onset': '0', 'duration': '1', 'layer': 'bass'}, {'onset': '4', 'duration': '1', 'layer': 'upper'}]}
|
||||
@@ -104,7 +106,7 @@ class LayeredComping(unittest.TestCase):
|
||||
pattern = {'id': 'test.delayed', 'family': 'call_response', 'lengths': ['4', '4'], 'events': [
|
||||
{'onset': '1', 'duration': '1/2', 'layer': 'upper'}, {'onset': '2', 'duration': '1', 'layer': 'bass'},
|
||||
{'onset': '5', 'duration': '1', 'layer': 'combined'}]}
|
||||
# A long combined event remains available alongside short overlaps.
|
||||
# A long combined event remains available alongside short attacks.
|
||||
pattern['events'][-1]['duration'] = '2'
|
||||
resources = copy.deepcopy(self.resources)
|
||||
resources['accompaniment']['instruments']['acoustic']['patterns'] = [pattern]
|
||||
@@ -117,6 +119,21 @@ class LayeredComping(unittest.TestCase):
|
||||
first['voices'] = [v for v in first['voices'] if v['id'] != 'bass']
|
||||
with self.assertRaisesRegex(ValueError, 'first acoustic attack'):
|
||||
generator.acoustic.validate_comping(source)
|
||||
def test_completed_score_rejects_bass_under_upper_before_and_after_merge(self):
|
||||
source = tests.solo_fixtures.song(count=1, chords=('C7',))
|
||||
source['staves'][0]['arrangement_part_type'] = 'acoustic'
|
||||
source['staves'][0]['measures'][0]['voices'] = [
|
||||
{'id': 'bass', 'notes': [generator.model.make_event('bass', fractions.Fraction(), fractions.Fraction(1), 'note', [
|
||||
{'letter': 'c', 'alter': 0, 'octave': 3, 'tablature': {'string': 5, 'fret': 3}}])]},
|
||||
{'id': 'upper', 'notes': [generator.model.make_event('upper', fractions.Fraction(1, 2), fractions.Fraction(1), 'note', [
|
||||
{'letter': 'e', 'alter': 0, 'octave': 3, 'tablature': {'string': 4, 'fret': 2}}])]}]
|
||||
with self.assertRaisesRegex(ValueError, 'release before the next attack'):
|
||||
generator.acoustic.validate_comping(source)
|
||||
generator.acoustic.combine(source)
|
||||
# The generic merger preserves imported overlaps faithfully. Generated
|
||||
# accompaniment must fail even when the overlap is encoded using ties.
|
||||
with self.assertRaisesRegex(ValueError, 'release before the next attack'):
|
||||
generator.acoustic.validate_comping(source)
|
||||
def test_harmonic_changes_clip_direct_holds_without_extra_attacks(self):
|
||||
source = copy.deepcopy(self.source)
|
||||
for m in source['staves'][0]['measures'][:2]:
|
||||
@@ -178,7 +195,7 @@ class LayeredComping(unittest.TestCase):
|
||||
self.assertEqual(sounding(first['document']), sounding(second['document']))
|
||||
self.assertEqual(first['details'], second['details'])
|
||||
self.assertNotEqual(first['trace']['cache_key'], second['trace']['cache_key'])
|
||||
def test_assembly_keeps_overlaps_and_canonical_reuse(self):
|
||||
def test_assembly_keeps_releases_and_canonical_reuse(self):
|
||||
source = generator.production.SourceAnalysis.read(self.source)
|
||||
results = {role: generator.production.generate_role(source, role, pathlib.Path('data')) for role in generator.production.ROLES if not role.endswith('_solo')}
|
||||
# Silence in source-defined solo slots is sufficient to test union;
|
||||
|
||||
Reference in New Issue
Block a user