Files
product-vamp/tests/test_fret_policy.py
T
2026-10-01 12:06:02 -05:00

164 lines
9.1 KiB
Python

import copy
import fractions
import pathlib
import tempfile
import unittest
import unittest.mock
import generator.acoustic
import generator.acoustic_voicing
import generator.electric
import generator.fretboard
import generator.guitar
import generator.harmony
import generator.model
import generator.score
import generator.solo.analysis
import generator.solo.planning
import generator.solo.placement
import generator.solo.realization
import tests.solo_fixtures
import tests.test_acoustic_voicing
import tests.test_musicxml
import tests.test_solo
import tests.test_solo_development
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_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), 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.model.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.model.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.model.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.model.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.model.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.write_json(output, target)
self.assertFalse(output.exists())
def test_solo_candidates_and_anchors_are_reachable_before_placement(self):
choices = tests.test_solo_development.choices(end=32)
options = generator.solo.realization.options(choices, fractions.Fraction(), fractions.Fraction(4), (59, 84))
self.assertTrue(options)
self.assertLessEqual(max(p.number() for p in options), 76)
source = tests.test_solo.song(count=8, chords=('CM7',))
plans = generator.solo.planning.plans(generator.solo.analysis.analyze(source), source['staves'][0]['measures'], choices, fractions.Fraction(32), 'electric', tests.solo_fixtures.library())
self.assertTrue(plans)
for plan in plans:
self.assertLessEqual(plan.register[1], 76)
self.assertTrue(plan.anchor_options)
for anchors in plan.anchor_options:
self.assertTrue(all(generator.solo.placement.positions(a.pitch.number()) for a in anchors))
motif = generator.solo.planning.new_motif(tests.solo_fixtures.library(), fractions.Fraction(4), choices=choices, register=(70, 84), seed='high')
self.assertTrue(all(generator.solo.placement.positions(n.pitch.number()) for n in motif.notes))
high = tests.test_solo_development.note(0, 1, 'f', octave=5)
self.assertIsNone(generator.solo.placement.place((high,), fractions.Fraction(120)))