Construction

This commit is contained in:
khodowany
2026-10-07 23:45:17 -05:00
parent 3a01b4b6bd
commit 841a3f974e
8 changed files with 236 additions and 80 deletions
+24 -7
View File
@@ -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;