Generation
This commit is contained in:
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
@@ -27,7 +27,7 @@ def acoustic_document(bars):
|
||||
for measure, (bass, upper) in zip(staff['measures'], bars):
|
||||
measure['transpose']['octave_change'] = -1
|
||||
measure['voices'] = []
|
||||
for lane, attacks, positions in (('bass', bass, (('c', 5, 3),)), ('upper', upper, (('e', 4, 2), ('g', 3, 0)))):
|
||||
for lane, attacks, positions in (('bass', bass, (('d', 5, 5),)), ('upper', upper, (('f', 4, 3), ('a', 3, 2)))):
|
||||
notes, cursor = [], fractions.Fraction()
|
||||
for onset, duration in attacks:
|
||||
onset, duration = fractions.Fraction(onset), fractions.Fraction(duration)
|
||||
|
||||
@@ -52,18 +52,15 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
self.assertLessEqual(len(basses), 1)
|
||||
self.assertLessEqual(len(uppers), 1)
|
||||
grip = tuple(sorted({p for n in span.notes for p in shape(n)}, reverse=True))
|
||||
required, allowed, bass = generator.guitar.chord_requirements(span.harmony, 'acoustic')
|
||||
bass_classes = {bass}
|
||||
degrees = generator.harmony.degrees(span.harmony)
|
||||
if 'bass' not in span.harmony and 'inversion' not in span.harmony and 5 in degrees:
|
||||
bass_classes.add((bass + generator.harmony.degree_semitones(5, degrees[5])) % 12)
|
||||
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
|
||||
if basses and uppers:
|
||||
self.assertIn(tuple(p.string_number for p in grip), generator.guitar.ACOUSTIC_STRINGS)
|
||||
self.assertTrue(set(required) <= {p.midi % 12 for p in grip} <= set(allowed))
|
||||
self.assertTrue(requirements.covers({p.midi % 12 for p in grip}))
|
||||
self.assertTrue({p.midi % 12 for p in grip} <= set(requirements.allowed))
|
||||
if basses:
|
||||
self.assertIn(min(p.midi for p in grip) % 12, bass_classes)
|
||||
self.assertIn(min(p.midi for p in grip) % 12, requirements.basses)
|
||||
self.assertTrue(generator.guitar.within_fret_span(grip))
|
||||
self.assertTrue(all(0 <= p.fret <= 20 for p in grip))
|
||||
self.assertTrue(all(1 <= p.fret <= 20 for p in grip))
|
||||
for note in span.notes:
|
||||
self.assertFalse(any(k.startswith('_') for k in note))
|
||||
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in note['pitches']))
|
||||
@@ -78,16 +75,16 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
generator.acoustic.combine(document)
|
||||
self.assertEqual(tests.test_acoustic.sounding(document), before)
|
||||
generator.model.validate_document(document)
|
||||
def test_simpler_fifth_bass_can_win_without_alternating(self):
|
||||
def test_fifth_bass_can_win_without_alternating(self):
|
||||
document = planned_document([[(0, 'Csus')], []])
|
||||
spans = self.place(document)
|
||||
self.assertEqual(len(spans), 1)
|
||||
bass = [p for n in spans[0].notes for p in shape(n) if p.string_number >= 5]
|
||||
self.assertTrue(bass)
|
||||
self.assertEqual({p.midi % 12 for p in bass}, {7})
|
||||
root_shapes = generator.guitar.chord_positions(*generator.guitar.chord_requirements(spans[0].harmony, 'acoustic'), profile='acoustic')
|
||||
root_shapes = generator.guitar.chord_positions(generator.guitar.chord_requirements(generator.harmony.parse_symbol('Csus/C'), 'acoustic'), profile='acoustic')
|
||||
selected = tuple(sorted({p for n in spans[0].notes for p in shape(n)}, reverse=True))
|
||||
self.assertLess(generator.guitar.voicing_cost(selected, 'acoustic'), min(generator.guitar.voicing_cost(s, 'acoustic') for s in root_shapes))
|
||||
self.assertLessEqual(generator.guitar.voicing_cost(selected, 'acoustic'), min(generator.guitar.voicing_cost(s, 'acoustic') for s in root_shapes))
|
||||
def test_bass_options_follow_defined_fifth_and_explicit_basses(self):
|
||||
inversion = generator.harmony.parse_symbol('Cdim7') | {'inversion': 0}
|
||||
for chord, expected in (('C7', {0, 7}), ('Cdim7', {0, 6}), ('Caug', {0, 8}), ('Cno5', {0}), ('C7/E', {4}), (inversion, {0})):
|
||||
@@ -95,8 +92,8 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
span = generator.acoustic.harmony_spans(planned_document([[(0, chord)]]))[0]
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
self.assertEqual({min(p.midi for p in c.shape) % 12 for c in candidates}, expected)
|
||||
required, allowed, _ = generator.guitar.chord_requirements(span.harmony, 'acoustic')
|
||||
self.assertTrue(all(set(required) <= {p.midi % 12 for p in c.shape} <= set(allowed) for c in candidates))
|
||||
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
|
||||
self.assertTrue(all(requirements.covers({p.midi % 12 for p in c.shape}) for c in candidates))
|
||||
def test_same_grip_survives_repeated_symbols_rest_bars_and_sections(self):
|
||||
attacks = [([(0, 1), (2, 1)], [(0, 1), (3, 1)]), ([], []), ([(2, 1)], [(1, 1), (3, 1)])]
|
||||
document = planned_document([[(0, 'C7')], [], [(0, 'C7'), (2, 'C7')]], attacks)
|
||||
@@ -118,7 +115,9 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
document = planned_document([changes], [([(i, 1) for i in range(4)], [(i, 1) for i in range(4)])])
|
||||
spans = self.place(document)
|
||||
self.assertEqual(len(spans), 4)
|
||||
self.assertEqual([min(p.midi for n in span.notes for p in shape(n)) % 12 for span in spans], [0, 4, 7, 6])
|
||||
basses = [min(p.midi for n in span.notes for p in shape(n)) % 12 for span in spans]
|
||||
self.assertIn(basses[0], (0, 7))
|
||||
self.assertEqual(basses[1:], [4, 7, 6])
|
||||
def test_non_accompaniment_passage_ends_the_span(self):
|
||||
document = planned_document([[(0, 'C7')], [], []])
|
||||
measure = document['staves'][0]['measures'][1]
|
||||
@@ -159,14 +158,34 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
expected = {}
|
||||
for candidate in candidates:
|
||||
expected[candidate.active] = min((previous.cost.moved(generator.guitar.movement(tuple(p for _, p in previous.active), candidate.shape)).plus(candidate.cost), index) for index, previous in enumerate(states.values()))
|
||||
actual = generator.guitar.advance_attacks(candidates, states)
|
||||
expected[candidate.active] = min((previous.cost.moved(generator.guitar.acoustic_movement(tuple(p for _, p in previous.active), candidate.shape)).plus(candidate.cost), index) for index, previous in enumerate(states.values()))
|
||||
actual = generator.guitar.advance_acoustic(candidates, states)
|
||||
predecessors = {id(p): i for i, p in enumerate(states.values())}
|
||||
self.assertEqual({key: (p.cost, predecessors[id(p.previous)]) for key, p in actual.items()}, expected)
|
||||
states = actual
|
||||
def test_impossible_grip_fails_contextually_without_partial_materialization(self):
|
||||
document = planned_document([[(0, 'C7')], [(0, 'C7b9#11')]])
|
||||
before = copy.deepcopy(document)
|
||||
with self.assertRaisesRegex(ValueError, 'measure 2, event .*no acoustic grip.*0-20'):
|
||||
with self.assertRaisesRegex(ValueError, 'measure 2, event .*no acoustic grip.*1-20'):
|
||||
generator.guitar.place_guitar(document)
|
||||
self.assertEqual(document, before)
|
||||
def test_g_minor_to_c_seventh_preserves_positions_with_fifth_bass(self):
|
||||
tuning = dict(generator.guitar.STANDARD_TUNING)
|
||||
previous = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 10), (3, 10), (2, 11)))
|
||||
active = tuple((str(p.string_number), p) for p in previous)
|
||||
states = {active: generator.guitar.Placement(generator.guitar.VoicingCost(), active, None, ())}
|
||||
span = generator.acoustic.harmony_spans(planned_document([[(0, 'C7')]]))[0]
|
||||
candidates = list(generator.guitar.acoustic_candidates(span))
|
||||
result = generator.guitar.advance_acoustic(candidates, states)
|
||||
best = min(result.values(), key=lambda p: (p.cost, p.active))
|
||||
chosen = tuple(p for _, p in best.active)
|
||||
self.assertEqual([(p.string_number, p.fret) for p in chosen], [(5, 10), (3, 9), (2, 11)])
|
||||
self.assertEqual(generator.guitar.acoustic_movement(previous, chosen), 1)
|
||||
self.assertEqual(generator.guitar.acoustic_movement(chosen, previous), 1)
|
||||
self.assertEqual(generator.guitar.acoustic_movement(previous, previous), 0)
|
||||
self.assertEqual(generator.guitar.acoustic_movement((), chosen), 0)
|
||||
def test_acoustic_candidates_are_not_pruned_by_grip_center(self):
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7'), 'acoustic')
|
||||
candidates = generator.guitar.chord_positions(requirements, profile='acoustic')
|
||||
self.assertEqual(candidates, generator.guitar.chord_positions(requirements, profile='acoustic', prune=False))
|
||||
self.assertGreater(len(candidates), len({generator.guitar.position_center(p) for p in candidates}))
|
||||
|
||||
+93
-22
@@ -2,6 +2,7 @@ import collections
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
import math
|
||||
import pathlib
|
||||
import unittest
|
||||
import unittest.mock
|
||||
@@ -50,6 +51,12 @@ class EngravingTests(unittest.TestCase):
|
||||
self.assertIn(len(layout.pages[-1].systems), (1, 2))
|
||||
for page in layout.pages:
|
||||
self.assertEqual(len({system.scale for system in page.systems}), 1)
|
||||
for system in page.systems:
|
||||
for role in ('staff', 'tab-staff'):
|
||||
lines = [p for p in system.primitives if p.role == role]
|
||||
if lines:
|
||||
self.assertAlmostEqual(min(x for p in lines for x, _ in p.points), layout.style.margin_x + 1)
|
||||
self.assertAlmostEqual(max(x for p in lines for x, _ in p.points), page.width - layout.style.margin_x - 1)
|
||||
for first, second in zip(page.systems, page.systems[1:]):
|
||||
self.assertGreaterEqual(second.box.top - first.box.bottom, layout.style.system_gap - 0.001)
|
||||
def test_guitar_pages_always_pair_systems_including_tall_passages(self):
|
||||
@@ -60,7 +67,7 @@ class EngravingTests(unittest.TestCase):
|
||||
self.assert_paired_pages(layout)
|
||||
self.assertEqual(layout.style.space, 10)
|
||||
self.assertEqual(layout.style.tab_font_size, 12)
|
||||
wide = tests.test_musicxml.xml_note('E', 2, 'half', octave=3) + tests.test_musicxml.xml_note('C', 2, 'half', octave=7)
|
||||
wide = tests.test_musicxml.xml_note('F', 2, 'half', octave=3) + tests.test_musicxml.xml_note('C', 2, 'half', octave=7)
|
||||
document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([wide] * 32))
|
||||
for measure in document['staves'][0]['measures']:
|
||||
measure['transpose']['octave_change'] = -1
|
||||
@@ -104,8 +111,8 @@ class EngravingTests(unittest.TestCase):
|
||||
self.assertIsNone(PIL.ImageChops.difference(actual, expected.convert('RGB')).getbbox())
|
||||
def test_aligned_bands_match_full_pages_with_spans_tab_and_sampling_phases(self):
|
||||
tied = tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="stop"/>')
|
||||
dense = ''.join(tests.test_musicxml.xml_note('G', 1, '64th') for _ in range(64))
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(dense, divisions=16))
|
||||
content = ''.join(tests.test_musicxml.xml_note('G', 1, 'eighth') for _ in range(8))
|
||||
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 8, divisions=2))
|
||||
documents = [generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/notation.xml')),
|
||||
tests.test_musicxml.parse(tests.test_musicxml.xml_score(tied)),
|
||||
tests.arrangement_fixtures.section_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())]
|
||||
@@ -238,20 +245,85 @@ class EngravingTests(unittest.TestCase):
|
||||
self.assertEqual([p.value for p in self.primitives(self.layout(source), 'accidental')], ['accidentalSharp', 'accidentalNatural'])
|
||||
conflict = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter=1, extra='<voice>a</voice>') + '<backup><duration>4</duration></backup>' + tests.test_musicxml.xml_note(extra='<voice>b</voice>')))
|
||||
self.assertEqual(len(self.primitives(self.layout(conflict), 'accidental')), 2)
|
||||
def test_dense_measure_splits_exactly_without_modifying_music(self):
|
||||
def test_line_breaks_only_use_complete_measures_even_when_part_would_fit(self):
|
||||
content = ''.join(tests.test_musicxml.xml_note(duration=1, kind='quarter') for _ in range(4))
|
||||
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 3))
|
||||
index = generator.engraving.notation.ScoreIndex(source)
|
||||
style = generator.engraving.types.Style()
|
||||
trial = generator.engraving.layout.make_system(index, fractions.Fraction(), fractions.Fraction(5), self.fonts, style)
|
||||
style = dataclasses.replace(style, width=math.ceil(trial.box.right - trial.box.left + 2 * style.margin_x + 3))
|
||||
layout = self.layout(source, style)
|
||||
self.assertEqual(layout.pages[0].systems[0].end, 4)
|
||||
self.assertEqual(layout.pages[0].systems[1].start, 4)
|
||||
self.assertTrue(all(s.start in index.boundaries and s.end in index.boundaries for p in layout.pages for s in p.systems))
|
||||
self.assertFalse(self.primitives(layout, 'continuation'))
|
||||
self.assertEqual(len(self.primitives(layout, 'barline')), 3)
|
||||
self.assert_paired_pages(layout)
|
||||
def test_final_short_line_extends_staff_without_stretching_notes(self):
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') * 2))
|
||||
style = generator.engraving.types.Style()
|
||||
natural = generator.engraving.layout.layout_systems(source, style, self.fonts)[0]
|
||||
layout = self.layout(source, style)
|
||||
final = layout.pages[0].systems[0]
|
||||
for before, after in zip(natural.anchors, final.anchors):
|
||||
self.assertAlmostEqual(after.x, style.margin_x + 1 + before.x * final.scale)
|
||||
self.assert_paired_pages(layout)
|
||||
def test_beamed_tuplet_can_continue_across_an_ordinary_line_break(self):
|
||||
modification = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
|
||||
notes = [tests.test_musicxml.xml_note('G', 1, 'eighth', extra=modification + f'<beam number="1">{action}</beam>') for action in ('begin', *(['continue'] * 22), 'end')]
|
||||
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([''.join(notes[:12]), ''.join(notes[12:])], divisions=3))
|
||||
events = tests.test_musicxml.notes(source) + tests.test_musicxml.notes(source, 1)
|
||||
source['staves'][0]['tuplets'] = [{'id': 'across-line', 'voice': '1', 'start': events[0]['id'], 'end': events[-1]['id'], 'note_count': 3, 'space_count': 2, 'depth': 0, 'bracket': 'yes'}]
|
||||
for event in events:
|
||||
event['tuplet_ids'] = ['across-line']
|
||||
layout = self.layout(source, generator.engraving.types.Style(width=800))
|
||||
systems = [s for p in layout.pages for s in p.systems]
|
||||
self.assertGreater(len(systems), 1)
|
||||
for system in systems:
|
||||
self.assertTrue(any(p.role == 'beam' for p in system.primitives))
|
||||
self.assertTrue(any(p.role == 'tuplet-number' and 'across-line' in p.sources for p in system.primitives))
|
||||
self.assertEqual(len(self.primitives(layout, 'barline')), 2)
|
||||
def test_accidental_memory_and_grace_groups_follow_the_measure_not_the_line(self):
|
||||
grace = '<note><grace slash="yes"/><pitch><step>D</step><octave>4</octave></pitch><type>16th</type></note>'
|
||||
content = ''.join(grace + tests.test_musicxml.xml_note(duration=1, kind='quarter', alter=1) for _ in range(4))
|
||||
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 2))
|
||||
layout = self.layout(source, generator.engraving.types.Style(width=700))
|
||||
systems = [s for p in layout.pages for s in p.systems]
|
||||
self.assertGreater(len(systems), 1)
|
||||
self.assertEqual([p.value for p in self.primitives(layout, 'accidental')], ['accidentalSharp', 'accidentalSharp'])
|
||||
events = tests.test_musicxml.notes(source) + tests.test_musicxml.notes(source, 1)
|
||||
for grace, principal in zip(events[::2], events[1::2]):
|
||||
for system in systems:
|
||||
identifiers = {a.event for a in system.anchors}
|
||||
self.assertEqual(grace['id'] in identifiers, principal['id'] in identifiers)
|
||||
def test_tab_ties_enter_and_leave_whole_measure_systems(self):
|
||||
bars = []
|
||||
for ties in ('<tie type="start"/>', '<tie type="stop"/><tie type="start"/>', '<tie type="stop"/>'):
|
||||
content = tests.test_musicxml.xml_note('E', octave=5, extra='<voice>held</voice>' + ties) + '<backup><duration>4</duration></backup>'
|
||||
content += ''.join(tests.test_musicxml.xml_note('G', 1, 'quarter', octave=5, extra='<voice>moving</voice>') for _ in range(4))
|
||||
bars.append(content)
|
||||
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars(bars))
|
||||
for measure in source['staves'][0]['measures']:
|
||||
measure['transpose']['octave_change'] = -1
|
||||
generator.guitar.place_guitar(source)
|
||||
style = generator.engraving.types.score_style(source, generator.engraving.types.Style())
|
||||
index = generator.engraving.notation.ScoreIndex(source)
|
||||
for i, (start, end) in enumerate(((0, 4), (4, 8), (8, 12))):
|
||||
tone = source['staves'][0]['measures'][i]['voices'][0]['notes'][0]['pitches'][0]
|
||||
held = tone['id']
|
||||
system = generator.engraving.layout.make_system(index, fractions.Fraction(start), fractions.Fraction(end), self.fonts, style)
|
||||
number = next(p for p in system.primitives if p.role == 'tab' and held in p.sources)
|
||||
ties = [p for p in system.primitives if p.role == 'tab-tie' and {held, tone.get('tie_from')} & set(p.sources)]
|
||||
self.assertEqual(sum(p.points[-1][0] < number.box.left for p in ties), int(start > 0))
|
||||
self.assertEqual(sum(p.points[0][0] > number.box.right for p in ties), int(end < 12))
|
||||
def test_dense_measure_fails_contextually_without_modifying_music(self):
|
||||
content = ''.join(tests.test_musicxml.xml_note('G', 1, '64th', extra='<voice>fast</voice>') for _ in range(64))
|
||||
content += '<backup><duration>64</duration></backup>' + tests.test_musicxml.xml_note('C', 64, 'whole', extra='<voice>held</voice>')
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=16))
|
||||
layout = self.layout(source)
|
||||
systems = [s for p in layout.pages for s in p.systems]
|
||||
self.assertGreater(len(systems), 1)
|
||||
held = source['staves'][0]['measures'][0]['voices'][1]['notes'][0]['pitches'][0]['id']
|
||||
pieces = [f for s in systems for f in s.fragments if f.source == held]
|
||||
self.assertEqual(sum(f.length() for f in pieces), 4)
|
||||
self.assertGreater(len(pieces), 1)
|
||||
self.assertTrue(self.primitives(layout, 'tie'))
|
||||
for page in layout.pages:
|
||||
self.assertEqual(generator.engraving.raster.rasterize_page(page, self.fonts).size, (976, 484))
|
||||
before = copy.deepcopy(source)
|
||||
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'measure 1 .*complete measure exceeds.*readable spacing'):
|
||||
self.layout(source)
|
||||
self.assertEqual(source, before)
|
||||
def test_hidden_pitch_does_not_suppress_a_visible_accidental(self):
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half', alter=1) + tests.test_musicxml.xml_note(duration=2, kind='half', alter=1)))
|
||||
tests.test_musicxml.notes(source)[0]['hidden'] = True
|
||||
@@ -263,11 +335,10 @@ class EngravingTests(unittest.TestCase):
|
||||
index = generator.engraving.notation.ScoreIndex(source)
|
||||
self.assertEqual(index.pitch_position(tests.test_musicxml.notes(source)[0]['pitches'][0], fractions.Fraction()), original - 7)
|
||||
self.assertTrue(any(p.value == '8va' for p in self.primitives(self.layout(source), 'octave-shift')))
|
||||
def test_wide_pitch_range_can_break_inside_a_measure_vertically(self):
|
||||
def test_excessive_pitch_range_fails_instead_of_splitting_the_measure(self):
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half', octave=10) + tests.test_musicxml.xml_note(duration=2, kind='half', octave=-1)))
|
||||
layout = self.layout(source)
|
||||
self.assertEqual([len(page.systems) for page in layout.pages], [2])
|
||||
self.assertEqual(len(self.primitives(layout, 'head')), 2)
|
||||
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'measure 1 .*complete measure exceeds.*height'):
|
||||
self.layout(source)
|
||||
def test_cue_grace_and_tremolo_are_drawn(self):
|
||||
grace = '<note><grace slash="yes"/><pitch><step>D</step><octave>4</octave></pitch><type>16th</type></note>'
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(grace + tests.test_musicxml.xml_note(duration=4, kind='whole', prefix='<cue/>')))
|
||||
@@ -283,8 +354,8 @@ class EngravingTests(unittest.TestCase):
|
||||
tab = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('C', octave=6))), 'electric', rhythms=tests.rhythm_fixtures.library())
|
||||
self.assertEqual(self.primitives(self.layout(tab), 'tab')[0].value, '20')
|
||||
def test_generated_tab_shares_columns_and_systems_without_duplicating_music(self):
|
||||
content = ''.join(tests.test_musicxml.xml_note('G', 1, '64th') for _ in range(64))
|
||||
source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=16)), 'electric', rhythms=tests.rhythm_fixtures.library())
|
||||
content = ''.join(tests.test_musicxml.xml_note('G', 1, 'eighth') for _ in range(8))
|
||||
source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([content] * 8, divisions=2)), 'electric', rhythms=tests.rhythm_fixtures.library())
|
||||
layout = self.layout(source)
|
||||
systems = [s for page in layout.pages for s in page.systems]
|
||||
self.assertGreater(len(systems), 1)
|
||||
@@ -364,8 +435,8 @@ class EngravingTests(unittest.TestCase):
|
||||
self.assertEqual([p.value for p in self.primitives(layout, 'clef')], ['gClef', 'cClef'])
|
||||
def test_lyric_extender_continues_on_systems_without_syllables(self):
|
||||
lyrics = '<lyric number="1"><text>Ah</text><extend type="start"/></lyric>'
|
||||
content = ''.join(tests.test_musicxml.xml_note('G', 1, '64th', extra=lyrics if i == 0 else '') for i in range(64))
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=16))
|
||||
bars = [''.join(tests.test_musicxml.xml_note('G', 1, 'quarter', extra=lyrics if bar == i == 0 else '') for i in range(4)) for bar in range(8)]
|
||||
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars(bars))
|
||||
layout = self.layout(source)
|
||||
systems = [s for p in layout.pages for s in p.systems]
|
||||
self.assertGreater(len(systems), 1)
|
||||
|
||||
+76
-22
@@ -21,46 +21,100 @@ import tests.test_musicxml
|
||||
|
||||
class GuitarTests(unittest.TestCase):
|
||||
def test_fret_boundaries_and_span(self):
|
||||
self.assertIn(generator.guitar.TabPosition(6, 0, 40), generator.guitar.positions_for_pitch(fractions.Fraction(40)))
|
||||
self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(40)))
|
||||
self.assertIn(generator.guitar.TabPosition(6, 1, 41), generator.guitar.positions_for_pitch(fractions.Fraction(41)))
|
||||
self.assertIn(generator.guitar.TabPosition(1, 20, 84), generator.guitar.positions_for_pitch(fractions.Fraction(84)))
|
||||
self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(85)))
|
||||
self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(129, 2)))
|
||||
self.assertTrue(generator.guitar.within_fret_span((generator.guitar.TabPosition(4, 11, 61), generator.guitar.TabPosition(3, 12, 67), generator.guitar.TabPosition(2, 14, 73))))
|
||||
self.assertFalse(generator.guitar.within_fret_span((generator.guitar.TabPosition(6, 0, 40), generator.guitar.TabPosition(1, 20, 84))))
|
||||
self.assertFalse(generator.guitar.within_fret_span((generator.guitar.TabPosition(4, 10, 60), generator.guitar.TabPosition(1, 20, 84))))
|
||||
def test_open_strings_count_in_span_and_proximity(self):
|
||||
for frets, valid in (((0, 2, 3), True), ((0, 4), True), ((0, 5), False), ((12, 0, 11), False), ((0, 0), True)):
|
||||
def test_fretted_positions_keep_span_and_proximity_scoring(self):
|
||||
for frets, valid in (((1, 2, 3), True), ((1, 5), True), ((1, 6), False), ((12, 1, 11), False), ((3, 3), True)):
|
||||
with self.subTest(frets=frets):
|
||||
positions = tuple(generator.guitar.TabPosition(6 - i, fret, 0) for i, fret in enumerate(frets))
|
||||
self.assertEqual(generator.guitar.within_fret_span(positions), valid)
|
||||
self.assertEqual(generator.guitar.compactness(positions), max(frets) - min(frets))
|
||||
self.assertEqual(generator.guitar.position_center(positions)[0], min(frets) + max(frets))
|
||||
self.assertTrue(generator.guitar.within_fret_span(()))
|
||||
def test_held_open_string_cannot_mix_with_high_frets(self):
|
||||
held = tests.test_musicxml.xml_note('E', octave=2)
|
||||
high = tests.test_musicxml.xml_note('C', 2, 'half', octave=6, extra='<voice>2</voice>')
|
||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(held + '<backup><duration>4</duration></backup><forward><duration>2</duration></forward>' + high))
|
||||
with self.assertRaisesRegex(ValueError, 'no guitar placement'):
|
||||
def test_low_open_e_fails_without_octave_fitting_or_mutation(self):
|
||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=2)))
|
||||
before = copy.deepcopy(document)
|
||||
with self.assertRaisesRegex(ValueError, 'measure-1, event .*pitch 40.*1-20'):
|
||||
generator.guitar.place_guitar(document)
|
||||
self.assertEqual(document, before)
|
||||
def test_open_string_pitch_uses_a_fretted_alternative(self):
|
||||
for _, midi in generator.guitar.STANDARD_TUNING[1:]:
|
||||
positions = generator.guitar.positions_for_pitch(fractions.Fraction(midi))
|
||||
self.assertTrue(positions)
|
||||
self.assertTrue(all(p.fret >= 1 and p.midi == midi for p in positions))
|
||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4)))
|
||||
generator.guitar.place_guitar(document)
|
||||
tone = tests.test_musicxml.notes(document)[0]['pitches'][0]
|
||||
self.assertGreater(tone['tablature']['fret'], 0)
|
||||
self.assertEqual(generator.model.pitch_number(tone), 64)
|
||||
def test_explicit_open_tab_is_rejected_without_substitution(self):
|
||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4)))
|
||||
tests.test_musicxml.notes(document)[0]['pitches'][0]['tablature'] = {'string': 1, 'fret': 0}
|
||||
before = copy.deepcopy(document)
|
||||
with self.assertRaisesRegex(ValueError, 'event .*1-20'):
|
||||
generator.guitar.place_guitar(document)
|
||||
self.assertEqual(document, before)
|
||||
document['staves'][0]['tablature'] = True
|
||||
with self.assertRaisesRegex(ValueError, 'invalid generated tablature'):
|
||||
generator.model.validate_document(document)
|
||||
def test_theory_controls_omissions_and_bass(self):
|
||||
for symbol in ('CM', 'G7b9', 'FM7#11/A', 'CmM7', 'Cdim7', 'C13', 'Csus', 'Cmadd9'):
|
||||
with self.subTest(symbol=symbol):
|
||||
required, allowed, bass = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol))
|
||||
candidates = generator.guitar.chord_positions(required, allowed, bass)
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol))
|
||||
candidates = generator.guitar.chord_positions(requirements)
|
||||
self.assertTrue(candidates)
|
||||
for candidate in candidates:
|
||||
covered = {p.midi % 12 for p in candidate}
|
||||
self.assertTrue(set(required) <= covered <= set(allowed))
|
||||
self.assertTrue(requirements.covers(covered))
|
||||
self.assertTrue(covered <= set(requirements.allowed))
|
||||
self.assertEqual(len(candidate), len({p.string_number for p in candidate}))
|
||||
self.assertTrue(generator.guitar.within_fret_span(candidate))
|
||||
if bass is not None:
|
||||
self.assertEqual(min(p.midi for p in candidate) % 12, bass)
|
||||
self.assertIn(2, generator.guitar.chord_requirements(generator.harmony.parse_symbol('Cmadd9'))[0])
|
||||
self.assertNotIn(7, generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7'))[0])
|
||||
self.assertIn(7, generator.guitar.chord_requirements(generator.harmony.parse_symbol('CM'))[0])
|
||||
self.assertEqual(generator.guitar.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 0})[2], 0)
|
||||
self.assertEqual(generator.guitar.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 1})[2], 4)
|
||||
self.assertTrue(any(p.fret > 12 for c in generator.guitar.chord_positions(*generator.guitar.chord_requirements(generator.harmony.parse_symbol('CM'))) for p in c))
|
||||
self.assertTrue(all(1 <= p.fret <= 20 for p in candidate))
|
||||
if requirements.basses:
|
||||
self.assertIn(min(p.midi for p in candidate) % 12, requirements.basses)
|
||||
self.assertIn(2, generator.guitar.chord_requirements(generator.harmony.parse_symbol('Cmadd9')).required)
|
||||
self.assertNotIn(7, generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7')).required)
|
||||
self.assertEqual(generator.guitar.chord_requirements(generator.harmony.parse_symbol('CM')).foundation, (0, 7))
|
||||
self.assertEqual(generator.guitar.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 0}).basses, (0,))
|
||||
self.assertEqual(generator.guitar.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 1}).basses, (4,))
|
||||
self.assertTrue(any(p.fret > 12 for c in generator.guitar.chord_positions(generator.guitar.chord_requirements(generator.harmony.parse_symbol('CM'))) for p in c))
|
||||
def test_root_or_fifth_is_required_for_every_generated_profile(self):
|
||||
for profile in ('', 'acoustic', 'electric'):
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7'), profile)
|
||||
for classes in ({0, 4, 10}, {4, 7, 10}, {0, 4, 7, 10}):
|
||||
self.assertTrue(requirements.covers(classes))
|
||||
self.assertFalse(requirements.covers({4, 10}))
|
||||
candidates = generator.guitar.chord_positions(requirements, prune=False, profile=profile)
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all({p.midi % 12 for p in shape} & {0, 7} for shape in candidates))
|
||||
self.assertEqual({frozenset({p.midi % 12 for p in shape} & {0, 7}) for shape in candidates}, {frozenset({0}), frozenset({7}), frozenset({0, 7})})
|
||||
def test_altered_and_removed_fifths_keep_their_semantics(self):
|
||||
for symbol, fifth in (('Cdim7', 6), ('Caug7', 8)):
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol), 'electric')
|
||||
self.assertEqual(requirements.foundation, (0, fifth))
|
||||
self.assertIn(fifth, requirements.required)
|
||||
self.assertNotIn(7, requirements.allowed)
|
||||
for omitted, remaining in ((1, 7), (5, 0)):
|
||||
chord = generator.harmony.parse_symbol('C7')
|
||||
chord['degrees'].append({'value': omitted, 'alter': 0, 'type': 'subtract'})
|
||||
for profile in ('acoustic', 'electric'):
|
||||
requirements = generator.guitar.chord_requirements(chord, profile)
|
||||
self.assertEqual(requirements.foundation, (remaining,))
|
||||
self.assertTrue(all(remaining in {p.midi % 12 for p in c} for c in generator.guitar.chord_positions(requirements, profile=profile)))
|
||||
chord['degrees'].append({'value': 1, 'alter': 0, 'type': 'subtract'})
|
||||
with self.assertRaisesRegex(ValueError, 'root or fifth'):
|
||||
generator.guitar.chord_requirements(chord)
|
||||
def test_explicit_degree_chord_uses_the_same_root_or_fifth_rule(self):
|
||||
standard = generator.harmony.parse_symbol('C7')
|
||||
explicit = {**standard, 'kind': 'other', 'degrees': [{'value': d, 'alter': a, 'type': 'add'} for d, a in generator.harmony.degrees(standard).items()]}
|
||||
for profile in ('', 'acoustic', 'electric'):
|
||||
self.assertEqual(generator.guitar.chord_requirements(standard, profile), generator.guitar.chord_requirements(explicit, profile))
|
||||
def test_melody_placement_preserves_notes_timing_and_sound(self):
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', 2, 'half', octave=4) + tests.test_musicxml.xml_note('F', 2, 'half', octave=4)))
|
||||
before = copy.deepcopy(source)
|
||||
@@ -161,7 +215,7 @@ class GuitarTests(unittest.TestCase):
|
||||
generator.guitar.place_guitar(document)
|
||||
self.assertEqual(before, document)
|
||||
def test_source_contract_and_unplayable_music_fail(self):
|
||||
cases = [(tests.test_musicxml.xml_note(octave=7, alter=1), '', '0-20'), (tests.test_musicxml.xml_note(alter='0.5'), '', '0-20'),
|
||||
cases = [(tests.test_musicxml.xml_note(octave=7, alter=1), '', '1-20'), (tests.test_musicxml.xml_note(alter='0.5'), '', '1-20'),
|
||||
(tests.test_musicxml.xml_note(), '<staff-details><capo>1</capo></staff-details>', 'capo')]
|
||||
for content, attributes, error in cases:
|
||||
with self.subTest(error=error), self.assertRaisesRegex(ValueError, error):
|
||||
@@ -187,13 +241,13 @@ class GuitarTests(unittest.TestCase):
|
||||
output.mkdir()
|
||||
(output / 'last-good').write_text('preserved')
|
||||
args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
|
||||
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml, part electric:.*0-20'):
|
||||
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml, part electric:.*1-20'):
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
|
||||
self.assertEqual((output / 'last-good').read_text(), 'preserved')
|
||||
harmony = '<harmony><root><root-step>C</root-step></root><kind>dominant</kind><degree><degree-value>9</degree-value><degree-alter>-1</degree-alter><degree-type>add</degree-type></degree><degree><degree-value>11</degree-value><degree-alter>1</degree-alter><degree-type>add</degree-type></degree></harmony>'
|
||||
(root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score(harmony + tests.test_musicxml.xml_note()))
|
||||
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml, part acoustic:.*measure 1.*no acoustic grip.*0-20'):
|
||||
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0001.xml, part acoustic:.*measure 1.*no acoustic grip.*1-20'):
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
|
||||
self.assertEqual((output / 'last-good').read_text(), 'preserved')
|
||||
|
||||
@@ -27,17 +27,19 @@ class GuitarPartTests(unittest.TestCase):
|
||||
for profile in ('acoustic', 'electric'):
|
||||
with self.subTest(symbol=symbol, profile=profile):
|
||||
definition = generator.harmony.parse_symbol(symbol)
|
||||
required, allowed, bass = generator.guitar.chord_requirements(definition, profile)
|
||||
candidates = generator.guitar.chord_positions(required, allowed, bass, profile=profile)
|
||||
requirements = generator.guitar.chord_requirements(definition, profile)
|
||||
candidates = generator.guitar.chord_positions(requirements, profile=profile)
|
||||
self.assertTrue(candidates)
|
||||
for candidate in candidates:
|
||||
strings = tuple(p.string_number for p in candidate)
|
||||
self.assertIn(strings, generator.guitar.ACOUSTIC_STRINGS if profile == 'acoustic' else generator.guitar.ELECTRIC_STRINGS)
|
||||
self.assertTrue(set(required) <= {p.midi % 12 for p in candidate} <= set(allowed))
|
||||
self.assertTrue(requirements.covers({p.midi % 12 for p in candidate}))
|
||||
self.assertTrue({p.midi % 12 for p in candidate} <= set(requirements.allowed))
|
||||
self.assertTrue(generator.guitar.within_fret_span(candidate))
|
||||
self.assertTrue(all(1 <= p.fret <= 20 for p in candidate))
|
||||
chord = generator.harmony.parse_symbol('C7')
|
||||
self.assertNotIn(0, generator.guitar.chord_requirements(chord, 'electric')[0])
|
||||
candidates = generator.guitar.chord_positions(*generator.guitar.chord_requirements(chord, 'electric'), profile='electric')
|
||||
self.assertNotIn(0, generator.guitar.chord_requirements(chord, 'electric').required)
|
||||
candidates = generator.guitar.chord_positions(generator.guitar.chord_requirements(chord, 'electric'), profile='electric')
|
||||
self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates))
|
||||
def test_acoustic_combines_independent_rhythms_with_a_fixed_grip(self):
|
||||
result = self.section(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
|
||||
@@ -47,7 +49,8 @@ class GuitarPartTests(unittest.TestCase):
|
||||
bass = [(n['onset'], p) for n in notes for p in n['pitches'] if p['tablature']['string'] >= 5 and 'tie_from' not in p]
|
||||
upper = {generator.model.fraction(n['onset']) for n in notes for p in n['pitches'] if p['tablature']['string'] <= 4 and 'tie_from' not in p}
|
||||
self.assertNotEqual({generator.model.fraction(onset) for onset, _ in bass}, upper)
|
||||
self.assertEqual([generator.model.pitch_number(p) % 12 for _, p in bass], [7, 7])
|
||||
self.assertEqual(len(bass), 2)
|
||||
self.assertIn([generator.model.pitch_number(p) % 12 for _, p in bass], ([0, 0], [7, 7]))
|
||||
self.assertEqual(bass[0][1]['tablature'], bass[1][1]['tablature'])
|
||||
upper_shapes = {tuple((p['tablature']['string'], p['tablature']['fret']) for p in n['pitches'] if p['tablature']['string'] <= 4) for n in notes}
|
||||
self.assertEqual(len(upper_shapes - {()}), 1)
|
||||
|
||||
@@ -38,7 +38,7 @@ class MusicModelTests(unittest.TestCase):
|
||||
self.assertEqual(set(generator.harmony.pitch_classes(generator.harmony.parse_symbol(label))), expected, label)
|
||||
for label, essentials in [('G7b9', {11, 5, 8}), ('C13', {4, 10, 9})]:
|
||||
definition = generator.harmony.parse_symbol(label)
|
||||
pitches = generator.guitar.chord_pitches(definition, generator.guitar.chord_positions(*generator.guitar.chord_requirements(definition))[0])
|
||||
pitches = generator.guitar.chord_pitches(definition, generator.guitar.chord_positions(generator.guitar.chord_requirements(definition))[0])
|
||||
self.assertTrue(essentials.issubset({generator.model.pitch_number(p) % 12 for p in pitches}), label)
|
||||
with self.assertRaises(ValueError):
|
||||
generator.harmony.parse_symbol('Cunrecognized')
|
||||
|
||||
@@ -64,6 +64,11 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, 'write the music out'):
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||
dense = ''.join(tests.test_musicxml.xml_note('G', 1, '64th') for _ in range(64))
|
||||
source.write_text(tests.test_musicxml.xml_score(dense, divisions=16))
|
||||
with self.assertRaisesRegex(generator.engraving.types.EngravingError, r'songs/fixture-electric.json: measure 1 .*complete measure exceeds'):
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
|
||||
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'unavailableGlyph'):
|
||||
generator.__main__.generate_assets(args)
|
||||
|
||||
+53
-36
@@ -17,10 +17,9 @@ def grip(frets, strings=(4, 3, 2, 1)):
|
||||
|
||||
def ranked(symbol, melody=(), profile='electric', prune=False):
|
||||
definition = generator.harmony.parse_symbol(symbol)
|
||||
required, allowed, bass = generator.guitar.chord_requirements(definition, profile)
|
||||
root = int((generator.model.NATURAL[definition['root']['letter']] + generator.model.alteration(definition['root']['alter'])) % 12)
|
||||
candidates = generator.guitar.chord_positions(required, allowed, bass, prune=prune, profile=profile, melody=melody, root=root)
|
||||
return sorted(candidates, key=lambda shape: (generator.guitar.voicing_cost(shape, profile, allowed, root, melody), shape))
|
||||
requirements = generator.guitar.chord_requirements(definition, profile)
|
||||
candidates = generator.guitar.chord_positions(requirements, prune=prune, profile=profile, melody=melody)
|
||||
return sorted(candidates, key=lambda shape: (generator.guitar.voicing_cost(shape, profile, requirements.allowed, melody), shape))
|
||||
|
||||
class VoicingTests(unittest.TestCase):
|
||||
def test_requested_shapes_win_without_a_shape_dictionary(self):
|
||||
@@ -28,34 +27,37 @@ class VoicingTests(unittest.TestCase):
|
||||
with self.subTest(symbol=symbol):
|
||||
self.assertEqual(ranked(symbol, (melody,))[0], grip(expected))
|
||||
def test_four_strings_precede_melody_height_and_root_presence(self):
|
||||
candidates = ranked('CM7', (64,))
|
||||
four = next(c for c in candidates if len(c) == 4 and max(p.midi for p in c) > 64)
|
||||
three = next(c for c in candidates if len(c) == 3 and max(p.midi for p in c) == 64)
|
||||
candidates = ranked('C#M7', (65,))
|
||||
four = next(c for c in candidates if len(c) == 4 and max(p.midi for p in c) > 65)
|
||||
three = next(c for c in candidates if len(c) == 3 and max(p.midi for p in c) == 65)
|
||||
self.assertLess(candidates.index(four), candidates.index(three))
|
||||
candidates = ranked('FM7', (69,))
|
||||
rootless = next(c for c in candidates if len(c) == 4 and 5 not in {p.midi % 12 for p in c})
|
||||
rooted = next(c for c in candidates if len(c) == 3 and 5 in {p.midi % 12 for p in c})
|
||||
self.assertLess(candidates.index(rootless), candidates.index(rooted))
|
||||
def test_melody_on_top_precedes_root_among_four_string_shapes(self):
|
||||
candidates = ranked('C7b9', (64,))
|
||||
candidates = ranked('C#M7', (65,))
|
||||
best = candidates[0]
|
||||
self.assertEqual(len(best), 4)
|
||||
self.assertEqual(max(p.midi for p in best), 64)
|
||||
self.assertNotIn(0, {p.midi % 12 for p in best})
|
||||
self.assertTrue(any(0 in {p.midi % 12 for p in c} and max(p.midi for p in c) > 64 for c in candidates))
|
||||
def test_root_precedes_physical_cost_without_covering_the_melody(self):
|
||||
self.assertEqual(ranked('CM7', (67,))[0], grip((2, 4, 1, 3)))
|
||||
rooted = generator.guitar.voicing_cost(grip((2, 4, 1, 3)), 'electric', (0, 4, 7, 11), 0, (67,))
|
||||
rootless = generator.guitar.voicing_cost(grip((5, 4, 5, 3)), 'electric', (0, 4, 7, 11), 0, (67,))
|
||||
self.assertGreater(rooted.effort, rootless.effort)
|
||||
self.assertEqual(max(p.midi for p in best), 65)
|
||||
self.assertNotIn(1, {p.midi % 12 for p in best})
|
||||
self.assertTrue(any(1 in {p.midi % 12 for p in c} and max(p.midi for p in c) > 65 for c in candidates))
|
||||
def test_lower_position_can_balance_a_wider_span_without_covering_melody(self):
|
||||
candidates = ranked('CM7', (67,))
|
||||
self.assertEqual(candidates[0], grip((2, 4, 1, 3)))
|
||||
self.assertIn(grip((5, 4, 5, 3)), candidates)
|
||||
rooted = generator.guitar.voicing_cost(grip((2, 4, 1, 3)), 'electric', (0, 4, 7, 11), (67,))
|
||||
rootless = generator.guitar.voicing_cost(grip((5, 4, 5, 3)), 'electric', (0, 4, 7, 11), (67,))
|
||||
self.assertEqual(rooted.effort, rootless.effort)
|
||||
self.assertLess(rooted, rootless)
|
||||
def test_essential_extensions_are_never_relaxed_to_add_the_root(self):
|
||||
for symbol in ('C7b9', 'FM7#11', 'C13', 'Cdim7'):
|
||||
definition = generator.harmony.parse_symbol(symbol)
|
||||
required, allowed, _ = generator.guitar.chord_requirements(definition, 'electric')
|
||||
requirements = generator.guitar.chord_requirements(definition, 'electric')
|
||||
for candidate in ranked(symbol):
|
||||
classes = {p.midi % 12 for p in candidate}
|
||||
self.assertTrue(set(required) <= classes <= set(allowed))
|
||||
self.assertTrue(requirements.covers(classes))
|
||||
self.assertTrue(classes <= set(requirements.allowed))
|
||||
def test_explicitly_removed_root_is_not_restored_or_penalized(self):
|
||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_parts.xml_harmony(kind='major-seventh') + tests.test_musicxml.xml_note('E', octave=5)))
|
||||
measure = document['staves'][0]['measures'][0]
|
||||
@@ -67,34 +69,49 @@ class VoicingTests(unittest.TestCase):
|
||||
_, tones = generator.guitar.guitar_events(document)
|
||||
candidates = list(generator.guitar.attack_candidates([note], tones, {}, {}, False))
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all(c.cost.missing_root == 0 and all(p.midi % 12 != 0 for p in c.shape) for c in candidates))
|
||||
def test_frets_zero_through_twelve_have_no_height_cost(self):
|
||||
for profile in ('', 'electric', 'acoustic'):
|
||||
for fret in range(13):
|
||||
self.assertTrue(all(all(p.midi % 12 != 0 for p in c.shape) and any(p.midi % 12 == 7 for p in c.shape) for c in candidates))
|
||||
def test_chord_height_cost_increases_throughout_the_fretboard(self):
|
||||
for profile in ('electric', 'acoustic'):
|
||||
for fret in range(1, 21):
|
||||
with self.subTest(profile=profile, fret=fret):
|
||||
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), profile).effort, 0)
|
||||
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), profile).effort, fret + 2 * max(0, fret - 12))
|
||||
self.assertEqual(generator.guitar.voicing_cost((), profile).effort, 0)
|
||||
def test_height_cost_only_counts_frets_above_twelve(self):
|
||||
for profile in ('', 'electric', 'acoustic'):
|
||||
for fret in range(13, 21):
|
||||
with self.subTest(profile=profile, fret=fret):
|
||||
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4), profile).effort, 2 * (fret - 12))
|
||||
def test_exact_melody_height_scoring_remains_unchanged(self):
|
||||
for fret in range(1, 21):
|
||||
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4)).effort, 2 * max(0, fret - 12))
|
||||
crossing = grip((11, 12, 13, 14))
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 7)
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing, 'acoustic').effort, 19)
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing, 'electric').effort, 21)
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing, 'acoustic').effort, 18)
|
||||
self.assertEqual(generator.guitar.voicing_cost(crossing, 'acoustic').simplicity, 15)
|
||||
def test_span_and_acoustic_simplicity_costs_remain_unchanged(self):
|
||||
shape = grip((0, 2, 3, 4))
|
||||
shape = grip((1, 3, 4, 5))
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape).effort, 4)
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape, 'acoustic').effort, 18)
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape, 'electric').effort, 9)
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape, 'acoustic').effort, 5)
|
||||
self.assertEqual(generator.guitar.voicing_cost(shape, 'acoustic').simplicity, 18)
|
||||
for profile in ('', 'electric', 'acoustic'):
|
||||
self.assertLess(generator.guitar.voicing_cost(grip((8, 8, 8, 8)), profile), generator.guitar.voicing_cost(grip((2, 4, 1, 3)), profile))
|
||||
self.assertLess(generator.guitar.voicing_cost(grip((4, 4, 4, 4)), profile), generator.guitar.voicing_cost(grip((2, 4, 1, 3)), profile))
|
||||
def test_lower_acoustic_half_diminished_and_dominant_grips_win(self):
|
||||
high = [grip((12, 12, 12, 11), (6, 4, 3, 2)), grip((12, 11, 12, 11), (6, 4, 3, 2))]
|
||||
low = [grip((7, 8, 7, 8), (5, 4, 3, 2)), grip((7, 8, 6, 8), (5, 4, 3, 2))]
|
||||
for symbol, upper, lower in zip(('Em7b5', 'A7b9'), high, low):
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol), 'acoustic')
|
||||
candidates = generator.guitar.chord_positions(requirements, profile='acoustic')
|
||||
self.assertIn(upper, candidates)
|
||||
self.assertIn(lower, candidates)
|
||||
self.assertEqual({p.midi % 12 for p in upper}, {p.midi % 12 for p in lower})
|
||||
self.assertEqual(min(p.midi for p in upper), min(p.midi for p in lower))
|
||||
self.assertLess(generator.guitar.voicing_cost(lower, 'acoustic', requirements.allowed), generator.guitar.voicing_cost(upper, 'acoustic', requirements.allowed))
|
||||
costs = [sum(generator.guitar.voicing_cost(s, 'acoustic').effort for s in pair) + generator.guitar.acoustic_movement(*pair) for pair in (low, high)]
|
||||
self.assertEqual(costs, [17, 25])
|
||||
def test_acoustic_simpler_three_note_shape_can_beat_four_notes(self):
|
||||
candidates = ranked('C6', profile='acoustic')
|
||||
candidates = ranked('Csus', profile='acoustic')
|
||||
best = candidates[0]
|
||||
self.assertEqual(len(best), 3)
|
||||
self.assertTrue(any(len(c) == 4 for c in candidates))
|
||||
required, _, _ = generator.guitar.chord_requirements(generator.harmony.parse_symbol('C6'), 'acoustic')
|
||||
self.assertTrue(set(required) <= {p.midi % 12 for p in best})
|
||||
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('Csus'), 'acoustic')
|
||||
self.assertTrue(requirements.covers({p.midi % 12 for p in best}))
|
||||
def test_nearby_position_beats_lower_frets_within_comfort_range(self):
|
||||
low, nearby, previous = (grip((f,) * 4) for f in (3, 8, 9))
|
||||
for profile in ('', 'electric', 'acoustic'):
|
||||
@@ -104,7 +121,7 @@ class VoicingTests(unittest.TestCase):
|
||||
for profile in ('', 'electric', 'acoustic'):
|
||||
self.assertLess(generator.guitar.voicing_cost(high, profile), generator.guitar.voicing_cost(low, profile).moved(generator.guitar.movement(high, low)))
|
||||
def test_candidate_pruning_uses_full_cost_and_preserves_each_center(self):
|
||||
for symbol, profile, melody in (('FM7', 'electric', (69,)), ('C7', 'acoustic', ()), ('Gm7', 'electric', (67,))):
|
||||
for symbol, profile, melody in (('FM7', 'electric', (69,)), ('Gm7', 'electric', (67,))):
|
||||
best = {}
|
||||
for shape in ranked(symbol, melody, profile):
|
||||
best.setdefault(generator.guitar.position_center(shape), shape)
|
||||
|
||||
Reference in New Issue
Block a user