Files
product-vamp/tests/test_acoustic.py
T
khodowany 11222a9f8c
ci / checks (push) Successful in 1h11m24s
Generation
2026-09-26 15:51:20 -05:00

149 lines
8.4 KiB
Python

import copy
import fractions
import pathlib
import unittest
import unittest.mock
import generator.acoustic
import generator.arrange.generate
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.types
import generator.model
import generator.playback
import generator.practice.generate
import tests.rhythm_fixtures
import tests.test_accompaniment
import tests.test_musicxml
import tests.test_parts
def sounding(document):
return sorted(tuple(generator.model.fraction(event[key]) for key in ('onset', 'duration', 'pitch')) for event in generator.playback.compile_timeline(document)['events'])
def acoustic_document(bars):
document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * len(bars)))
staff = document['staves'][0]
staff.update(arrangement_part_type='acoustic', tablature=True)
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)))):
notes, cursor = [], fractions.Fraction()
for onset, duration in attacks:
onset, duration = fractions.Fraction(onset), fractions.Fraction(duration)
if onset > cursor:
notes.append(generator.model.make_event(f'{measure["id"]}-{lane}-{len(notes)}', cursor, onset - cursor, 'rest'))
pitches = [{'letter': letter, 'alter': generator.model.rational(0), 'octave': 4, 'tablature': {'string': string, 'fret': fret}} for letter, string, fret in positions]
notes.append(generator.model.make_event(f'{measure["id"]}-{lane}-{len(notes)}', onset, duration, 'note', pitches))
cursor = onset + duration
if cursor < 4:
notes.append(generator.model.make_event(f'{measure["id"]}-{lane}-{len(notes)}', cursor, 4 - cursor, 'rest'))
for note in notes:
note.update(voice=lane, stem='down' if lane == 'bass' else 'up')
measure['voices'].append({'id': lane, 'notes': notes})
generator.model.validate_document(document)
return document
class AcousticTests(unittest.TestCase):
def combine(self, document):
before = sounding(document)
generator.acoustic.combine(document)
generator.model.validate_document(document)
self.assertEqual(sounding(document), before)
self.assertTrue(all(len(m['voices']) == 1 for m in document['staves'][0]['measures']))
for measure in document['staves'][0]['measures']:
for note in measure['voices'][0]['notes']:
self.assertNotIn('stem', note)
self.assertNotIn('beams', note)
for pitch in note['pitches']:
self.assertNotIn('stem', pitch)
once = copy.deepcopy(document)
generator.acoustic.combine(document)
self.assertEqual(document, once)
return [m['voices'][0]['notes'] for m in document['staves'][0]['measures']]
def test_simultaneous_attacks_share_one_chord(self):
notes = self.combine(acoustic_document([([(0, 2), (3, 1)], [(0, 2), (3, 1)])]))[0]
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 2, 'note'), (2, 1, 'rest'), (3, 1, 'note')])
self.assertEqual([len(n['pitches']) for n in notes], [3, 0, 3])
def test_sustained_bass_ties_through_upper_attacks_and_releases(self):
notes = self.combine(acoustic_document([([(0, 4)], [(1, 1), (3, 1)])]))[0]
self.assertTrue(all(n['kind'] == 'note' for n in notes))
self.assertEqual([len(n['pitches']) for n in notes], [1, 3, 1, 3])
for first, second in zip(notes, notes[1:]):
self.assertEqual(first['pitches'][0]['tie_to'], second['pitches'][0]['id'])
self.assertEqual(first['pitches'][0]['tablature'], second['pitches'][0]['tablature'])
def test_repeated_pitch_is_an_attack_not_a_tie(self):
notes = self.combine(acoustic_document([([(0, 2), (2, 2)], [(0, 4)])]))[0]
self.assertEqual(len(notes), 2)
self.assertNotIn('tie_to', notes[0]['pitches'][0])
self.assertNotIn('tie_from', notes[1]['pitches'][0])
for first, second in zip(notes[0]['pitches'][1:], notes[1]['pitches'][1:]):
self.assertEqual(first['tie_to'], second['id'])
def test_existing_tie_boundary_does_not_add_a_redundant_event(self):
document = acoustic_document([([(0, 1), (1, 1)], [(2, 2)])])
first, second = document['staves'][0]['measures'][0]['voices'][0]['notes'][:2]
first['pitches'][0]['tie_to'] = second['pitches'][0]['id']
second['pitches'][0]['tie_from'] = first['pitches'][0]['id']
notes = self.combine(document)[0]
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 2, 'note'), (2, 2, 'note')])
def test_cross_measure_ties_preserve_both_rhythms(self):
document = acoustic_document([([(0, 4)], [(2, 2)]), ([(0, 2)], [(0, 1), (2, 1)])])
first, second = document['staves'][0]['measures']
for lane in range(2):
old = next(n for n in first['voices'][lane]['notes'] if n['pitches'])
new = second['voices'][lane]['notes'][0]
for a, b in zip(old['pitches'], new['pitches']):
a['tie_to'], b['tie_from'] = b['id'], a['id']
notes = self.combine(document)
self.assertTrue(all('tie_from' in p for p in notes[1][0]['pitches']))
self.assertEqual(notes[1][-1]['kind'], 'rest')
def test_silence_collapses_to_a_single_measure_rest(self):
notes = self.combine(acoustic_document([([], [])]))[0]
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 4, 'rest')])
self.assertTrue(notes[0]['measure_rest'])
def test_pickup_keeps_its_actual_duration(self):
document = acoustic_document([([(0, 1)], [(0, 1)])])
measure = document['staves'][0]['measures'][0]
measure.update(duration=generator.model.rational(1), implicit=True)
for voice in measure['voices']:
voice['notes'] = voice['notes'][:1]
notes = self.combine(document)[0]
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 1, 'note')])
def test_five_eighths_uses_conventional_tied_notes_not_a_tuplet(self):
notes = self.combine(acoustic_document([([(0, fractions.Fraction(5, 2))], [])]))[0]
self.assertEqual([n['written'] for n in notes], [{'type': 'half', 'dots': 0}, {'type': 'eighth', 'dots': 0}, {'type': 'quarter', 'dots': 1}])
self.assertEqual(notes[0]['pitches'][0]['tie_to'], notes[1]['pitches'][0]['id'])
def test_unrelated_voices_and_solo_slashes_are_unchanged(self):
document = acoustic_document([([(0, 4)], [(0, 4)])])
for part in ('electric', 'vocal', None):
document['staves'][0]['arrangement_part_type'] = part
before = copy.deepcopy(document)
generator.acoustic.combine(document)
self.assertEqual(document, before)
document['staves'][0]['arrangement_part_type'] = 'acoustic'
measure = document['staves'][0]['measures'][0]
measure['voices'] = [{'id': 'voice-arranged', 'notes': [generator.model.make_event('slash', fractions.Fraction(), fractions.Fraction(4), 'note') | {'voice': 'voice-arranged', 'notehead': 'slash'}]}]
before = copy.deepcopy(document)
generator.acoustic.combine(document)
self.assertEqual(document, before)
def test_song_and_practice_integration_preserve_playback(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note()))
rhythms = tests.rhythm_fixtures.library()
for builder in (generator.arrange.generate.arrange_document, generator.practice.generate.practice_document):
with unittest.mock.patch('generator.acoustic.combine'):
separate = builder(source, 'acoustic', rhythms=rhythms)
combined = builder(source, 'acoustic', rhythms=rhythms)
self.assertEqual(sounding(separate), sounding(combined))
generator.model.validate_document(combined)
self.assertTrue(all(len(m['voices']) == 1 for m in combined['staves'][0]['measures']))
def test_engraved_chord_has_one_stem_and_no_competing_rests(self):
document = acoustic_document([([(0, 4)], [(1, 1), (3, 1)])])
self.combine(document)
fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
layout = generator.engraving.layout.layout_score(document, generator.engraving.types.Style(), fonts)
primitives = [p for page in layout.pages for p in page.primitives]
self.assertFalse([p for p in primitives if p.role in ('rest', 'tab-rest')])
for note in document['staves'][0]['measures'][0]['voices'][0]['notes']:
self.assertEqual(len([p for p in primitives if p.role == 'stem' and note['id'] in p.sources]), 1)
self.assertTrue([p for p in primitives if p.role == 'tie'])