+1
-1
@@ -68,7 +68,7 @@ The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model and
|
||||
|
||||
`generator.rhythm.load_library()` parses the library once per asset generation. Immutable snippets retain exact event onsets, durations, written values, rests, beams, and continuations. Their placeholder pitches and tempo do not transpose chords or select playback BPM. A snippet must contain one or two complete sequential bars in one supported meter and voice, without grace notes, tuplets, time modifications, tremolo, or meter changes.
|
||||
|
||||
`generator.accompaniment` selects a two-bar motif, either a complete two-bar snippet or a pair of one-bar snippets, and realizes a four-bar `A, B, A, B'` cycle. The fourth bar varies one beat when a suitable candidate exists. The cycle repeats through the section, restarting at section or meter boundaries; a final partial cycle uses only the required bars. The immutable plan has separate `acoustic_bass`, `acoustic_upper`, and `electric` lanes. Density targets are one bass attack per two quarter-note beats, one upper-chord attack per beat, and one electric attack per two beats. These are scoring targets, not per-measure limits: electric aims for about two attacks in 4/4, with metrical scaling and phrase variation. Harmonic coverage and pulse support take priority for acoustic, while electric favors responses around melody and acoustic attacks. Silent electric bars are allowed. Simultaneous bass and upper attacks are allowed, as are collisions with the melody. Source identity breaks ties deterministically; selection does not enumerate every pair of one-bar snippets or introduce randomness. Long values compete normally, without a series preference or duration quota.
|
||||
`generator.accompaniment` selects a two-bar motif, either a complete two-bar snippet or a pair of one-bar snippets, and realizes a four-bar `A, B, A, B'` cycle. The fourth bar varies one beat when a suitable candidate exists. The cycle repeats through the section, restarting at section or meter boundaries; a final partial cycle uses only the required bars. The immutable plan has separate `acoustic_bass`, `acoustic_upper`, and `electric` lanes. Density targets are one bass attack per two quarter-note beats, three upper-chord attacks per four beats, and one electric attack per two beats. These are scoring targets, not per-measure limits: a full harmonically active 4/4 bar targets two bass, three upper-chord, and two electric attacks, scaled proportionally for other meters and shorter active harmonic durations. Harmonic coverage remains more important than acoustic density. Both parts consider melody attacks and sustained notes; acoustic bass favors a steady pulse, while upper chords and electric favor gaps between attacks. Electric also considers acoustic attacks, so its timing can change when the acoustic plan changes. Silent electric bars are allowed. Simultaneous bass and upper attacks are allowed, as are collisions with the melody. Source identity breaks ties deterministically; selection does not enumerate every pair of one-bar snippets or introduce randomness. Long values compete normally, without a series preference or duration quota.
|
||||
|
||||
Pickups use the corresponding tail of a full-meter rhythm bar. Harmonic state carries across bars until changed or canceled by N.C.; a snippet attack sounds only when a chord is active. A held generated accompaniment chord stops before a different harmony or N.C., leaving silence until the next selected attack. Electric notes have no fixed duration cap: half, dotted-half, and whole notes sustain until their planned end or a musical boundary. Snippet tie continuations remain silent. Shared duration spelling shortens a clipped binary span requiring ties to one conventional note followed by rests; nonbinary boundaries retain exact rational timing. Melody attacks influence selection but do not directly delete acoustic attacks. Missing rhythm material, unsupported accompaniment meters, and mid-measure meter changes produce errors. The broader import and engraving model still accepts the meters described below.
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import functools
|
||||
import generator.model
|
||||
import generator.rhythm
|
||||
|
||||
ATTACKS_PER_BEAT = {'bass': fractions.Fraction(1, 2), 'upper': fractions.Fraction(3, 4), 'electric': fractions.Fraction(1, 2)}
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Context:
|
||||
measure: dict
|
||||
@@ -75,7 +77,7 @@ def measure_rank(bar, offset, intervals, melody_attacks, melody_spans, companion
|
||||
missing_bars = int(bool(intervals) and not spans and role != 'electric')
|
||||
uncovered = sum(not any(start < b and end > a for start, end in spans) for a, b in intervals)
|
||||
duration = sum((b - a for a, b in intervals), fractions.Fraction())
|
||||
density = abs(len(attacks) - duration / {'electric': 2, 'bass': 2, 'upper': 1}[role])
|
||||
density = abs(len(attacks) - duration * ATTACKS_PER_BEAT[role])
|
||||
collisions = sum((t in melody_attacks) + (t in companions) for t in attacks)
|
||||
occupied = sum(any(a <= t < b for a, b in melody_spans) for t in attacks)
|
||||
pulse = -sum(2 if t == 0 else 1 if t.denominator == 1 else 0 for t in attacks)
|
||||
|
||||
@@ -210,6 +210,45 @@ class AccompanimentPlanningTests(unittest.TestCase):
|
||||
for bar in plan.electric[:3]:
|
||||
self.assertIn(len(bar.attacks()), {beats // 2, (beats + 1) // 2})
|
||||
self.assertLessEqual(len(plan.electric[3].attacks()), (beats + 1) // 2 + 1)
|
||||
def test_upper_density_targets_three_attacks_per_four_quarter_beats(self):
|
||||
library = tests.rhythm_fixtures.library()
|
||||
for beats, counts in ((2, {1, 2}), (3, {2}), (4, {3}), (5, {4})):
|
||||
with self.subTest(beats=beats):
|
||||
plan = generator.accompaniment.plan_section(melody_measures(8, beats=beats, dense=True), library)
|
||||
for bar in plan.acoustic_upper[:3]:
|
||||
self.assertIn(len(bar.attacks()), counts)
|
||||
for bar in plan.acoustic_bass[:3]:
|
||||
self.assertIn(len(bar.attacks()), {beats // 2, (beats + 1) // 2})
|
||||
self.assertEqual(plan.acoustic_upper[:4], plan.acoustic_upper[4:])
|
||||
def test_upper_density_scales_with_pickup_and_active_harmony_duration(self):
|
||||
for beats in (1, 2, 3):
|
||||
for pickup in (False, True):
|
||||
with self.subTest(beats=beats, pickup=pickup):
|
||||
length = beats if pickup else 4
|
||||
content = tests.test_parts.xml_harmony(offset=0 if pickup else 4 - beats) + tests.test_musicxml.xml_note(duration=1, kind='quarter') * length
|
||||
measure = tests.test_musicxml.parse(xml_bars([content], implicit=pickup))['staves'][0]['measures'][0]
|
||||
item = generator.accompaniment.context(measure, None)
|
||||
bar = quarter_bar(4, {0, 1, 2, 3})
|
||||
self.assertEqual(item.offset, 4 - beats if pickup else 0)
|
||||
self.assertEqual(len(generator.accompaniment.sounding_attacks(bar, item)), beats)
|
||||
self.assertEqual(generator.accompaniment.rank(bar, [(item, ())], 'upper')[2], fractions.Fraction(beats, 4))
|
||||
def test_harmonic_coverage_outweighs_upper_density_target(self):
|
||||
content = ''.join(tests.test_parts.xml_harmony(root, offset=i) for i, root in enumerate(('C', 'F', 'G', 'C'))) + tests.test_musicxml.xml_note()
|
||||
measure = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures'][0]
|
||||
item = generator.accompaniment.context(measure, None)
|
||||
sparse, covered = quarter_bar(4, {0, 1, 2}), quarter_bar(4, {0, 1, 2, 3})
|
||||
sparse_rank = generator.accompaniment.rank(sparse, [(item, ())], 'upper')
|
||||
covered_rank = generator.accompaniment.rank(covered, [(item, ())], 'upper')
|
||||
self.assertEqual((sparse_rank[2], covered_rank[2]), (0, 1))
|
||||
self.assertLess(covered_rank, sparse_rank)
|
||||
library = generator.rhythm.Library(tuple(generator.rhythm.Snippet(name, '4/4', (bar,)) for name, bar in (('sparse', sparse), ('covered', covered))))
|
||||
plan = generator.accompaniment.plan_section([measure] * 3, library)
|
||||
self.assertEqual(plan.acoustic_upper, (covered,) * 3)
|
||||
def test_upper_density_is_not_an_attack_cap(self):
|
||||
bar = quarter_bar(4, {0, 1, 2, 3})
|
||||
library = generator.rhythm.Library((generator.rhythm.Snippet('dense', '4/4', (bar,)),))
|
||||
plan = generator.accompaniment.plan_section(melody_measures(4, dense=True), library)
|
||||
self.assertEqual(plan.acoustic_upper, (bar,) * 4)
|
||||
def test_section_repeats_four_bar_motif_with_at_most_one_beat_variation(self):
|
||||
for beats in (2, 3, 4, 5):
|
||||
measures = melody_measures(12, beats=beats, dense=True)
|
||||
@@ -227,17 +266,33 @@ class AccompanimentPlanningTests(unittest.TestCase):
|
||||
reordered = generator.rhythm.Library(tuple(reversed([s for group in library.snippets.values() for s in group])))
|
||||
self.assertEqual(generator.accompaniment.plan_section(measures, reordered), plan)
|
||||
self.assertEqual(measures, before)
|
||||
def test_acoustic_and_electric_coordinate_when_space_is_available(self):
|
||||
def test_acoustic_and_electric_coordinate_when_target_density_has_gaps(self):
|
||||
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note()
|
||||
measures = tests.test_musicxml.parse(xml_bars([content] * 4))['staves'][0]['measures']
|
||||
plan = generator.accompaniment.plan_section(measures, tests.rhythm_fixtures.library())
|
||||
for index in range(3):
|
||||
acoustic, electric = set(plan.acoustic_upper[index].attacks()), set(plan.electric[index].attacks())
|
||||
self.assertTrue(acoustic)
|
||||
self.assertTrue(electric)
|
||||
self.assertFalse(acoustic & electric)
|
||||
self.assertNotIn(0, acoustic | electric)
|
||||
self.assertGreater(len(acoustic), len(electric))
|
||||
bass = quarter_bar(4, {0, 2})
|
||||
electric = quarter_bar(4, {1, 3})
|
||||
upper = generator.rhythm.Bar(tuple(generator.rhythm.Event(fractions.Fraction(i, 2), fractions.Fraction(1, 2), i in (1, 3, 5), 'eighth') for i in range(8)), fractions.Fraction(4))
|
||||
library = generator.rhythm.Library(tuple(generator.rhythm.Snippet(name, '4/4', (bar,)) for name, bar in (('bass', bass), ('electric', electric), ('upper', upper))))
|
||||
plan = generator.accompaniment.plan_section(measures, library)
|
||||
self.assertEqual(plan.acoustic_bass, (bass,) * 4)
|
||||
self.assertEqual(plan.acoustic_upper, (upper,) * 4)
|
||||
self.assertEqual(plan.electric, (electric,) * 4)
|
||||
def test_equal_density_candidates_favor_gaps_around_melody_and_companions(self):
|
||||
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note()
|
||||
measure = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures'][0]
|
||||
item = generator.accompaniment.context(measure, None)
|
||||
for role, companions, collision, gap in (('upper', (), {0, 1, 2}, {1, 2, 3}), ('electric', (2,), {0, 2}, {1, 3})):
|
||||
with self.subTest(role=role):
|
||||
ranks = [generator.accompaniment.rank(quarter_bar(4, attacks), [(item, companions)], role) for attacks in (gap, collision)]
|
||||
self.assertLess(ranks[0], ranks[1])
|
||||
def test_equal_density_candidates_consider_sustained_melody(self):
|
||||
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=2, kind='half') + '<note><rest/><duration>2</duration><type>half</type></note>'
|
||||
measure = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures'][0]
|
||||
item = generator.accompaniment.context(measure, None)
|
||||
occupied, gap = quarter_bar(4, {1, 3}), quarter_bar(4, {2, 3})
|
||||
for role in ('upper', 'electric'):
|
||||
with self.subTest(role=role):
|
||||
self.assertLess(generator.accompaniment.rank(gap, [(item, ())], role), generator.accompaniment.rank(occupied, [(item, ())], role))
|
||||
def test_dense_melody_allows_collisions_and_keeps_accompaniment_audible(self):
|
||||
measures = melody_measures(8, dense=True)
|
||||
plan = generator.accompaniment.plan_section(measures, tests.rhythm_fixtures.library())
|
||||
|
||||
Reference in New Issue
Block a user