Files
product-vamp/tests/test_guitar_parts.py
T
khodowany bc7baea7c4
ci / checks (push) Failing after 23m22s
Cleanup
2026-10-08 02:13:45 -05:00

146 lines
10 KiB
Python

import generator.score
import generator.validation
import copy
import fractions
import unittest
import generator.acoustic_voicing
import generator.fretboard
import generator.guitar
import generator.harmony
import generator.model
import tests.score_events
import tests.arrangement_fixtures
import tests.test_accompaniment
import tests.test_musicxml
import tests.test_parts
class GuitarPartTests(unittest.TestCase):
def section(self, content, part='electric', divisions=1):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=divisions))
result = tests.arrangement_fixtures.section_document(source, part)
generator.validation.validate_document(result)
return result
def test_profile_shapes_cover_defining_chord_tones(self):
for symbol in ('CM', 'Cm', 'C7', 'CmM7', 'Csus', 'Cdim7', 'Caug7', 'C7b9', 'C13', 'CM/E'):
for profile in ('acoustic', 'electric'):
with self.subTest(symbol=symbol, profile=profile):
definition = generator.harmony.parse_symbol(symbol)
requirements = generator.fretboard.chord_requirements(definition, respect_bass=profile != 'electric')
candidates = generator.acoustic_voicing.candidates(requirements) if profile == 'acoustic' else 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, ((5, 4, 3, 2), (6, 4, 3, 2)) if profile == 'acoustic' else generator.guitar.ELECTRIC_STRINGS)
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.fretboard.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.fretboard.chord_requirements(chord, respect_bass=False).required)
candidates = generator.guitar.chord_positions(generator.fretboard.chord_requirements(chord, respect_bass=False), profile='electric')
self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates))
def test_power_chord_uses_four_fret_fallback_without_inventing_tones(self):
requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C5'))
self.assertEqual(set(requirements.allowed), {0, 7})
for candidates in (generator.acoustic_voicing.candidates(requirements), generator.guitar.chord_positions(requirements, profile='electric')):
self.assertTrue(candidates)
self.assertTrue(all(generator.fretboard.fret_span(c) == 4 for c in candidates))
self.assertTrue(all({p.midi % 12 for p in c} == {0, 7} for c in candidates))
def test_acoustic_uses_bass_and_three_upper_notes(self):
result = self.section(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
generator.validation.validate_document(result)
generator.fretboard.validate_document(result)
sounding = [n for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m) if n['pitches']]
self.assertTrue(sounding)
self.assertTrue(all(len(n['pitches']) in (1, 3, 4) for n in sounding))
for n in sounding:
self.assertIn(sorted(p['tablature']['string'] for p in n['pitches']), ([2, 3, 4], [5], [6], [2, 3, 4, 5], [2, 3, 4, 6]))
def test_explicit_bass_is_preserved_for_triads_and_extended_chords(self):
for symbol, expected in (('CM/E', 4), ('C7b9/E', 4)):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
chord = generator.harmony.parse_symbol(symbol)
source['staves'][0]['measures'][0]['harmonies'] = [{**chord, 'id': 'h1', 'onset': generator.model.rational(0)}]
result = tests.arrangement_fixtures.section_document(source, 'acoustic')
bass = [p for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m) for p in n['pitches'] if p['tablature']['string'] in (5, 6)]
self.assertEqual({generator.model.pitch_number(p) % 12 for p in bass}, {expected})
def test_long_melody_chords_and_short_notes(self):
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', 2, 'half', octave=4) + tests.test_musicxml.xml_note('G', 1, 'quarter', octave=4) + tests.test_musicxml.xml_note('C', 1, 'quarter', octave=5)
notes = tests.test_musicxml.notes(self.section(content))
self.assertIn(len(notes[0]['pitches']), (3, 4))
self.assertEqual([len(n['pitches']) for n in notes[1:]], [1, 1])
self.assertEqual(generator.model.pitch_number(notes[0]['pitches'][0]), 76)
self.assertIn(sorted(p['tablature']['string'] for p in notes[0]['pitches']), ([1, 2, 3], [2, 3, 4], [1, 2, 3, 4]))
def test_tied_quarters_qualify_and_keep_all_strings(self):
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='<tie type="stop"/>') + '<note><rest/><duration>2</duration><type>half</type></note>'
result = self.section(content)
first, second = tests.test_musicxml.notes(result)[:2]
self.assertIn(len(first['pitches']), (3, 4))
self.assertEqual(len(first['pitches']), len(second['pitches']))
for before, after in zip(first['pitches'], second['pitches']):
self.assertEqual(before['tie_to'], after['id'])
self.assertEqual(after['tablature'], before['tablature'])
self.assertEqual({generator.model.fraction(e['duration']) for e in tests.score_events.timeline(result)['events']}, {2})
def test_chord_change_reattacks_even_for_final_eighth(self):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=7) + tests.test_musicxml.xml_note('G', 8, 'whole', octave=4)
result = self.section(content, divisions=2)
notes = tests.test_musicxml.notes(result)
self.assertEqual([generator.model.fraction(n['duration']) for n in notes], [fractions.Fraction(7, 2), fractions.Fraction(1, 2)])
for note in notes:
self.assertIn(len(note['pitches']), (3, 4))
self.assertFalse(any('tie_from' in p or 'tie_to' in p for p in note['pitches']))
self.assertEqual(generator.model.pitch_number(note['pitches'][0]), 79)
self.assertEqual({generator.model.fraction(e['onset']) for e in tests.score_events.timeline(result)['events']}, {0, fractions.Fraction(7, 2)})
def test_cross_measure_ties_continue_only_without_a_chord_change(self):
for root in ('C', 'F'):
with self.subTest(root=root):
first = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', octave=4, extra='<tie type="start"/>')
second = tests.test_parts.xml_harmony(root) + tests.test_musicxml.xml_note('E', octave=4, extra='<tie type="stop"/>')
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([first, second]))
result = tests.arrangement_fixtures.section_document(source, 'electric')
generator.validation.validate_document(result)
notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes']]
self.assertTrue(all(len(n['pitches']) in (3, 4) for n in notes))
self.assertEqual(bool(notes[0]['pitches'][0].get('tie_to')), root == 'C')
self.assertEqual({generator.model.fraction(e['onset']) for e in tests.score_events.timeline(result)['events']}, {0} if root == 'C' else {0, 4})
def test_split_tuplet_keeps_exact_timing_and_span_end(self):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=10)
for index in range(3):
extra = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes><normal-type>half</normal-type></time-modification>'
if index != 1:
extra += '<notations><tuplet type="' + ('start' if index == 0 else 'stop') + '"/></notations>'
content += tests.test_musicxml.xml_note('E', 4, 'half', octave=4, extra=extra)
result = self.section(content, divisions=3)
notes = tests.test_musicxml.notes(result)
self.assertEqual([generator.model.fraction(n['duration']) for n in notes], [fractions.Fraction(4, 3)] * 2 + [fractions.Fraction(2, 3)] * 2)
self.assertEqual(result['staves'][0]['tuplets'][0]['end'], notes[-1]['id'])
self.assertTrue(all(len(n['pitches']) in (3, 4) for n in notes))
self.assertFalse(notes[-1]['pitches'][0].get('tie_from'))
def test_no_chord_releases_harmony_without_reattacking_melody(self):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony(kind='none', offset=2) + tests.test_musicxml.xml_note('E', octave=4)
result = self.section(content)
first, second = tests.test_musicxml.notes(result)
self.assertIn(len(first['pitches']), (3, 4))
self.assertEqual(len(second['pitches']), 1)
self.assertEqual(first['pitches'][0]['tie_to'], second['pitches'][0]['id'])
timeline = tests.score_events.timeline(result)['events']
self.assertEqual(sum(generator.model.fraction(e['duration']) == 4 for e in timeline), 1)
self.assertTrue(all(generator.model.fraction(e['onset']) == 0 for e in timeline))
def test_unplayable_harmonization_preserves_low_melody(self):
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('C', octave=3)
with self.assertLogs('generator.guitar', level='WARNING'):
result = self.section(content)
note = tests.test_musicxml.notes(result)[0]
self.assertEqual(len(note['pitches']), 1)
self.assertEqual(note['pitches'][0]['tablature'], {'string': 5, 'fret': 3})
def test_song_melody_only_is_raised_an_octave(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4)))
before = copy.deepcopy(source)
for builder in (tests.arrangement_fixtures.section_document, tests.arrangement_fixtures.melody_performance):
for part in ('electric', 'vocal'):
result = builder(source, part)
notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] if n['pitches']]
expected = 76 if part == 'electric' else 64
self.assertEqual({generator.model.pitch_number(n['pitches'][0]) for n in notes}, {expected})
self.assertEqual(source, before)