Files
product-vamp/tests/test_acoustic.py
T
2026-10-05 19:44:33 -05:00

145 lines
8.2 KiB
Python

import copy
import fractions
import pathlib
import unittest
import unittest.mock
import generator.accompaniment
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 tests.arrangement_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, (('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)
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):
generator.acoustic.prepare(document)
generator.model.validate_document(document)
prepared = copy.deepcopy(document)
generator.acoustic.prepare(document)
self.assertEqual(document, prepared)
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)
self.assertNotIn('tie_from', pitch)
self.assertNotIn('tie_to', 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_bass_releases_at_next_upper_attack_without_reattacking(self):
notes = self.combine(acoustic_document([([(0, 4)], [(1, 1), (3, 1)])]))[0]
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 1, 'note'), (1, 1, 'note'), (2, 1, 'rest'), (3, 1, 'note')])
self.assertEqual([len(n['pitches']) for n in notes], [1, 2, 0, 2])
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])
self.assertEqual([len(n['pitches']) for n in notes], [3, 1])
def test_simultaneous_tones_release_at_shortest_planned_duration(self):
notes = self.combine(acoustic_document([([(0, 4)], [(0, 1)])]))[0]
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 1, 'note'), (1, 3, 'rest')])
self.assertEqual(len(notes[0]['pitches']), 3)
def test_barline_releases_notes_before_next_measures_attacks(self):
document = acoustic_document([([(0, 4)], [(2, 2)]), ([(0, 2)], [(0, 1), (2, 1)])])
notes = self.combine(document)
self.assertEqual(tests.test_accompaniment.timing(notes[0]), [(0, 2, 'note'), (2, 2, 'note')])
self.assertEqual(tests.test_accompaniment.timing(notes[1]), [(0, 1, 'note'), (1, 1, 'rest'), (2, 1, 'note'), (3, 1, 'rest')])
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_shortens_to_one_note_without_ties_or_reattacks(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': 'half', 'dots': 0}])
self.assertEqual(tests.test_accompaniment.timing(notes), [(0, 2, 'note'), (2, 2, 'rest')])
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.prepare(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.prepare(document)
generator.acoustic.combine(document)
self.assertEqual(document, before)
def test_song_and_section_integration_preserve_playback(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note()))
for builder in (generator.arrange.generate.arrange_document, tests.arrangement_fixtures.section_document):
with unittest.mock.patch('generator.acoustic.combine'):
separate = builder(source, 'acoustic')
combined = builder(source, 'acoustic')
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, 2), (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.assertFalse([p for p in primitives if p.role in ('tie', 'tab_tie')])