229 lines
14 KiB
Python
229 lines
14 KiB
Python
import generator.score_io
|
|
import generator.validation
|
|
import copy
|
|
import fractions
|
|
import pathlib
|
|
import tempfile
|
|
import unittest
|
|
import unittest.mock
|
|
|
|
import generator.acoustic
|
|
import generator.acoustic_voicing
|
|
import generator.chord_families
|
|
import generator.comping
|
|
import generator.electric
|
|
import generator.fretboard
|
|
import generator.guitar
|
|
import generator.harmony
|
|
import generator.model
|
|
import tests.solo_fixtures
|
|
import tests.test_acoustic_voicing
|
|
import tests.test_musicxml
|
|
import tests.test_voicing
|
|
import tests.score_events
|
|
|
|
def position(string, fret):
|
|
return generator.fretboard.TabPosition(string, fret, dict(generator.fretboard.STANDARD_TUNING)[string] + fret)
|
|
|
|
def document(positions):
|
|
result = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
|
note = tests.test_musicxml.notes(result)[0]
|
|
spellings = (('c', 0), ('c', 1), ('d', 0), ('e', -1), ('e', 0), ('f', 0), ('f', 1), ('g', 0), ('a', -1), ('a', 0), ('b', -1), ('b', 0))
|
|
pitches = []
|
|
for p in positions:
|
|
letter, alter = spellings[p.midi % 12]
|
|
pitches.append({'letter': letter, 'alter': alter, 'octave': p.midi // 12 - 1, 'tablature': {'string': p.string_number, 'fret': p.fret}})
|
|
note.update(generator.model.make_event(note['id'], fractions.Fraction(), fractions.Fraction(4), 'note', pitches))
|
|
result['staves'][0]['tablature'] = True
|
|
return result
|
|
|
|
class FretPolicyTests(unittest.TestCase):
|
|
def test_span_tiers_group_one_two_and_three_positions(self):
|
|
for frets, span, tier, legal in (((7,), 1, 0, True), ((7, 8), 2, 0, True), ((7, 9), 3, 0, True), ((7, 10), 4, 1, True), ((7, 11), 5, 1, False)):
|
|
grip = tuple(position(s, f) for s, f in zip((2, 1), frets))
|
|
self.assertEqual(generator.fretboard.fret_span(grip), span)
|
|
self.assertEqual(generator.fretboard.span_penalty(grip), tier)
|
|
self.assertEqual(generator.fretboard.within_fret_span(grip), legal)
|
|
self.assertEqual(generator.fretboard.fret_span(()), 0)
|
|
def test_primary_comping_grip_beats_family_and_arbitrarily_less_movement(self):
|
|
chord = generator.harmony.parse_symbol('C')
|
|
key = generator.comping.semantic_key(chord)
|
|
primary = tests.test_acoustic_voicing.voicing((7, 5, 5, 5))
|
|
fallback = tests.test_acoustic_voicing.voicing((8, 5, 5, 5), 6)
|
|
self.assertFalse(generator.chord_families.acoustic(chord, primary)['preferred'])
|
|
self.assertTrue(generator.chord_families.acoustic(chord, fallback)['preferred'])
|
|
self.assertTrue(all(v in generator.comping.voicings(key, 'acoustic') for v in (primary, fallback)))
|
|
costs = {v: generator.comping.local_cost('acoustic', key, v, 55) for v in (primary, fallback)}
|
|
# Movement/family wins cannot purchase entry into the four-fret tier.
|
|
costs[primary] = costs[primary]._replace(effort=100000)
|
|
path, cost = generator.comping.select_grips(((primary, fallback),), (costs,), 'acoustic', 'tiers', left=fallback, right=fallback)
|
|
self.assertEqual(path, (primary,))
|
|
self.assertEqual(cost.extended_grips, 0)
|
|
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost(), fallback)]
|
|
legacy = generator.acoustic_voicing.advance((primary, fallback), generator.comping.requirements(chord, 'acoustic'), states)
|
|
self.assertEqual(min(legacy, key=lambda s: s.cost).voicing, primary)
|
|
def test_primary_electric_grip_beats_highest_melody_string_and_pruning(self):
|
|
for prune in (False, True):
|
|
choices = tests.test_voicing.ranked('G7', (65,), prune=prune)
|
|
best = choices[0]
|
|
self.assertEqual(generator.fretboard.span_penalty(best), 0)
|
|
self.assertEqual(min(p.string_number for p in best if p.midi == 65), 2)
|
|
self.assertEqual(max(p.midi for p in best), 65)
|
|
self.assertTrue(any(generator.fretboard.span_penalty(v) and any(p.midi == 65 and p.string_number == 1 for p in v) for v in choices))
|
|
primary_cost = generator.guitar.voicing_cost(best, 'electric', melody=(65,)).moved(100000)
|
|
self.assertTrue(all(primary_cost < generator.guitar.voicing_cost(v, 'electric', melody=(65,)) for v in choices if generator.fretboard.span_penalty(v)))
|
|
def test_previously_missing_chords_select_reported_fallbacks(self):
|
|
for symbol in ('Bb9', 'BbM9', 'Bb7#9', 'Bb6add9'):
|
|
with self.subTest(chord=symbol):
|
|
source = tests.solo_fixtures.song(count=1, chords=(symbol,))
|
|
canonical = generator.comping.Canonical(source, 'acoustic', 'fallback')
|
|
report = canonical.report[0]
|
|
self.assertEqual(report['fret_span'], 4)
|
|
self.assertEqual(report['span_group'], 'fallback')
|
|
self.assertEqual(report['span_candidates']['primary'], 0)
|
|
self.assertGreater(report['span_candidates']['fallback'], 0)
|
|
self.assertEqual(canonical.path_cost.extended_grips, 1)
|
|
required = generator.comping.requirements(generator.harmony.parse_symbol(symbol), 'acoustic')
|
|
self.assertTrue(required.covers({p['sounding_midi'] % 12 for p in report['positions']}))
|
|
def test_future_held_notes_can_require_a_retained_four_fret_history(self):
|
|
source = document((position(4, 5), position(2, 2))) # G3, C#4
|
|
measure = source['staves'][0]['measures'][0]
|
|
for p in tests.test_musicxml.notes(source)[0]['pitches']:
|
|
p.pop('tablature')
|
|
incoming = generator.model.make_event('incoming', fractions.Fraction(1), fractions.Fraction(3), 'note', [{'letter': 'a', 'alter': 0, 'octave': 3, 'tablature': {'string': 3, 'fret': 2}}])
|
|
measure['voices'].append({'id': 'other', 'notes': [generator.model.make_event('rest', fractions.Fraction(), fractions.Fraction(1), 'rest'), incoming]})
|
|
# G/C# also have primary placements. Neither permits this held A/string 3.
|
|
self.assertFalse(generator.fretboard.span_penalty((position(4, 5), position(3, 6))))
|
|
before = tests.score_events.timeline(source)
|
|
generator.guitar.place_guitar(source)
|
|
generator.validation.validate_document(source)
|
|
generator.fretboard.validate_document(source)
|
|
self.assertEqual(tests.score_events.timeline(source), before)
|
|
self.assertEqual([p['tablature'] for p in measure['voices'][0]['notes'][0]['pitches']], [{'string': 4, 'fret': 5}, {'string': 2, 'fret': 2}])
|
|
def test_tied_four_fret_grip_is_charged_once_and_keeps_its_anchor(self):
|
|
source = document((position(2, 12), position(1, 15)))
|
|
original = tests.test_musicxml.notes(source)[0]
|
|
pieces = [generator.model.make_event(f'split_{i}', fractions.Fraction(2 * i), fractions.Fraction(2), 'note', original['pitches']) for i in range(2)]
|
|
for a, b in zip(pieces[0]['pitches'], pieces[1]['pitches']):
|
|
a['tie_to'], b['tie_from'] = b['id'], a['id']
|
|
source['staves'][0]['measures'][0]['voices'][0]['notes'] = pieces
|
|
_, tones = generator.guitar.guitar_events(source)
|
|
states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())}
|
|
for i, n in enumerate(pieces):
|
|
states = generator.guitar.advance_positions([n], fractions.Fraction(2 * i), tones, states, True)
|
|
self.assertEqual(min(s.cost for s in states.values()).extended_grips, 1)
|
|
generator.guitar.place_guitar(source)
|
|
generator.validation.validate_document(source)
|
|
generator.fretboard.validate_document(source)
|
|
self.assertEqual([p['tablature'] for p in pieces[0]['pitches']], [p['tablature'] for p in pieces[1]['pitches']])
|
|
# Removing the low anchor early still leaves an illegal high single tail.
|
|
pieces[0]['pitches'][0].pop('tie_to')
|
|
pieces[1]['pitches'].pop(0)
|
|
with self.assertRaisesRegex(ValueError, 'held/tied tails'):
|
|
generator.fretboard.validate_document(source)
|
|
def test_single_limits_and_alternate_string(self):
|
|
for midi, expected in ((40, False), (41, True), (72, True), (76, True), (77, False), (84, False)):
|
|
with self.subTest(midi=midi):
|
|
positions = generator.fretboard.single_positions_for_pitch(fractions.Fraction(midi))
|
|
self.assertEqual(bool(positions), expected)
|
|
self.assertTrue(all(1 <= p.fret <= 12 for p in positions))
|
|
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('C', octave=5)))
|
|
generator.guitar.place_guitar(source)
|
|
self.assertEqual(tests.test_musicxml.notes(source)[0]['pitches'][0]['tablature'], {'string': 1, 'fret': 8})
|
|
def test_explicit_high_single_and_unreachable_pitch_fail_without_rewriting(self):
|
|
for p in (position(2, 13), position(1, 13)):
|
|
source = document((p,))
|
|
before = copy.deepcopy(source)
|
|
with self.assertRaisesRegex(ValueError, 'single notes need frets 1-12'):
|
|
generator.guitar.place_guitar(source)
|
|
self.assertEqual(source, before)
|
|
with self.assertRaisesRegex(ValueError, 'single notes require frets 1-12'):
|
|
generator.fretboard.validate_document(source)
|
|
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('F', octave=5)))
|
|
before = copy.deepcopy(source)
|
|
with self.assertRaisesRegex(ValueError, 'single notes need frets 1-12'):
|
|
generator.guitar.place_guitar(source)
|
|
self.assertEqual(source, before)
|
|
def test_chord_anchor_and_span_are_hard_constraints(self):
|
|
for frets, valid in (((12, 14), True), ((13, 14), False), ((12, 15), True), ((12, 16), False), ((0, 2), False)):
|
|
with self.subTest(frets=frets):
|
|
source = document(tuple(position(s, f) for s, f in zip((2, 1), frets)))
|
|
if valid:
|
|
generator.guitar.place_guitar(source)
|
|
generator.validation.validate_document(source)
|
|
generator.fretboard.validate_document(source)
|
|
else:
|
|
with self.assertRaises(ValueError):
|
|
generator.guitar.place_guitar(source)
|
|
with self.assertRaises(ValueError):
|
|
generator.fretboard.validate_document(source)
|
|
def test_high_chord_tail_cannot_outlast_anchor(self):
|
|
source = document((position(2, 12), position(1, 14)))
|
|
anchor = tests.test_musicxml.notes(source)[0]['pitches'][0]
|
|
anchor.update(duration=generator.model.rational(2), written=generator.model.written_for(fractions.Fraction(2)))
|
|
generator.validation.validate_document(source)
|
|
with self.assertRaisesRegex(ValueError, 'held/tied tails'):
|
|
generator.fretboard.validate_document(source)
|
|
with self.assertRaisesRegex(ValueError, 'no guitar placement'):
|
|
generator.guitar.place_guitar(source)
|
|
def test_notation_ties_preserve_chord_policy_and_fixed_positions(self):
|
|
source = document((position(2, 12), position(1, 14)))
|
|
original = tests.test_musicxml.notes(source)[0]
|
|
split = [generator.model.make_event(f'split_{i}', fractions.Fraction(2 * i), fractions.Fraction(2), 'note', original['pitches']) for i in range(2)]
|
|
for a, b in zip(split[0]['pitches'], split[1]['pitches']):
|
|
a['tie_to'], b['tie_from'] = b['id'], a['id']
|
|
source['staves'][0]['measures'][0]['voices'][0]['notes'] = split
|
|
generator.guitar.place_guitar(source)
|
|
generator.validation.validate_document(source)
|
|
generator.fretboard.validate_document(source)
|
|
self.assertEqual([p['tablature'] for p in split[0]['pitches']], [p['tablature'] for p in split[1]['pitches']])
|
|
split[0]['pitches'][0].pop('tie_to')
|
|
split[1]['pitches'].pop(0)
|
|
generator.validation.validate_document(source)
|
|
with self.assertRaisesRegex(ValueError, 'held/tied tails'):
|
|
generator.fretboard.validate_document(source)
|
|
def test_another_voice_cannot_supply_a_single_notes_anchor(self):
|
|
source = document((position(2, 12), position(1, 13)))
|
|
measure = source['staves'][0]['measures'][0]
|
|
pitches = tests.test_musicxml.notes(source)[0]['pitches']
|
|
measure['voices'] = [{'id': f'voice_{i}', 'notes': [generator.model.make_event(f'n_{i}', fractions.Fraction(), fractions.Fraction(4), 'note', [p])]} for i, p in enumerate(pitches)]
|
|
with self.assertRaisesRegex(ValueError, 'single notes require'):
|
|
generator.fretboard.validate_document(source)
|
|
with self.assertRaisesRegex(ValueError, 'no guitar placement'):
|
|
generator.guitar.place_guitar(source)
|
|
def test_harmonization_fallback_obeys_single_limit(self):
|
|
for p, valid in ((position(2, 13), False), (position(1, 8), True)):
|
|
source = document((p,))
|
|
note = tests.test_musicxml.notes(source)[0]
|
|
note.update(_harmony=generator.harmony.parse_symbol('CM'), _guitar_profile='electric', _harmonize=True)
|
|
with unittest.mock.patch('generator.guitar.chord_positions', return_value=()):
|
|
if valid:
|
|
with self.assertLogs('generator.guitar', level='WARNING'):
|
|
generator.guitar.place_guitar(source)
|
|
self.assertEqual(len(note['pitches']), 1)
|
|
else:
|
|
with self.assertRaisesRegex(ValueError, 'single notes need frets 1-12'):
|
|
generator.guitar.place_guitar(source)
|
|
def test_acoustic_played_parts_need_independent_anchors(self):
|
|
source = tests.test_acoustic_voicing.planned_document([[(0, 'CM')]])
|
|
spans = generator.acoustic.harmony_spans(source)
|
|
for frets in ((13, 12, 12, 12), (12, 13, 13, 13)):
|
|
grip = tests.test_acoustic_voicing.voicing(frets)
|
|
self.assertTrue(generator.acoustic_voicing.playable(grip))
|
|
with unittest.mock.patch('generator.acoustic_voicing.candidates', return_value=(grip,)):
|
|
with self.assertRaisesRegex(ValueError, 'played bass notes.*played upper chords'):
|
|
generator.acoustic_voicing.select(spans)
|
|
generator.acoustic_voicing.place(source)
|
|
generator.fretboard.validate_document(source)
|
|
def test_copied_unanchored_voicing_cannot_be_published(self):
|
|
source = document(tuple(position(s, 13) for s in (3, 2, 1)))
|
|
target = document(tuple(position(s, 12) for s in (3, 2, 1)))
|
|
reference, note = tests.test_musicxml.notes(source)[0], tests.test_musicxml.notes(target)[0]
|
|
generator.electric.reuse_voicings([generator.electric.VoicingLink(note, (reference,))])
|
|
generator.validation.validate_document(target)
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
output = pathlib.Path(directory) / 'score.json'
|
|
with self.assertRaisesRegex(ValueError, 'each sounding chord requires an anchor'):
|
|
generator.score_io.write_json(output, target)
|
|
self.assertFalse(output.exists())
|