Accompaniment
ci / checks (push) Successful in 49m53s

This commit is contained in:
khodowany
2026-10-06 01:59:55 -05:00
parent 04bb44b9d0
commit df83cae38c
14 changed files with 916 additions and 156 deletions
+4 -1
View File
@@ -131,7 +131,10 @@ class AcousticTests(unittest.TestCase):
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']))
for measure in combined['staves'][0]['measures']:
self.assertIn(len(measure['voices']), (1, 2))
if len(measure['voices']) == 2:
self.assertEqual({v['id'] for v in measure['voices']}, {'upper', 'bass'})
def test_engraved_chord_has_one_stem_and_no_competing_rests(self):
document = acoustic_document([([(0, 4)], [(1, 2), (3, 1)])])
self.combine(document)
+185
View File
@@ -0,0 +1,185 @@
"""Independent rhythm contracts, including real overlapping guitar voices."""
import copy
import dataclasses
import fractions
import pathlib
import unittest
import generator.accompaniment
import generator.arrange.accompaniment
import generator.comping
import generator.comping_rhythm
import generator.fretboard
import generator.harmonic_time
import generator.harmony
import generator.model
import generator.production
import generator.score
import generator.solo.analysis
import generator.solo.decoder
import generator.solo.harmonic
import generator.solo.learned
import generator.vocabulary
import tests.solo_fixtures
def sounding(document):
return [(m['number'], v['id'], n['onset'], n['duration'], [(p['letter'], p['alter'], p['octave'], p['duration'], p['tablature'], bool(p.get('tie_from')), bool(p.get('tie_to'))) for p in n['pitches']]) for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] if n['pitches']]
class LayeredComping(unittest.TestCase):
def setUp(self):
self.source = tests.solo_fixtures.song(count=6, chords=('C7', 'C7', 'FM7', 'FM7', 'Dm7', 'G7'))
self.resources = generator.vocabulary.load()
def test_candidates_preserve_all_counts_and_articulations(self):
m = self.source['staves'][0]['measures'][0]
policy = self.resources['accompaniment']
for lane, expected in (('electric', {0, 1, 2}), ('bass', {1, 2}), ('upper', {1, 2, 3})):
candidates = generator.comping_rhythm.lane_candidates(m, m['harmonies'][0], lane, (), policy, 'test')
self.assertEqual({len(c.events) for c in candidates}, expected)
self.assertTrue(any(e.end - e.start <= fractions.Fraction(1, 2) for c in candidates for e in c.events) if lane != 'bass' else True)
self.assertTrue(any(e.end - e.start >= 2 for c in candidates for e in c.events))
if lane == 'bass':
self.assertTrue(all(c.events[0].start == 0 for c in candidates))
def test_ranges_downbeats_and_overlap(self):
canonical = generator.comping.Canonical(self.source, 'acoustic', 'light')
carried = generator.accompaniment.carried_chords(self.source['staves'][0]['measures'])
for i, (m, prior) in enumerate(zip(self.source['staves'][0]['measures'], carried)):
result = canonical(m, prior, i)
voices = {v['id']: v['notes'] for v in result['voices']}
self.assertEqual(set(voices), {'bass', 'upper'})
self.assertTrue(voices['bass'][0]['pitches'])
self.assertEqual(generator.model.fraction(voices['bass'][0]['onset']), 0)
for lane, high in (('bass', 2), ('upper', 3)):
attacks = [n for n in voices[lane] if n['pitches'] and not any(p.get('tie_from') for p in n['pitches'])]
self.assertTrue(1 <= len(attacks) <= high)
self.assertTrue(any(fractions.Fraction(r['overlap_beats']) > 0 for r in canonical.rhythm_report))
self.assertTrue(all(abs(sum(r['contributions'].values()) - r['selection_cost']) < 1e-10 for r in canonical.rhythm_report))
def test_combined_crowding_favors_shared_attacks(self):
m = self.source['staves'][0]['measures'][0]
candidates = generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'joint')
self.assertTrue(any(dict(c.contributions)['combined_crowding'] == 0 for c in candidates))
self.assertTrue(any(len({e.start for e in c.events}) < len(c.events) for c in candidates))
shared = (generator.comping_rhythm.Attack('bass', fractions.Fraction(), fractions.Fraction(2)), generator.comping_rhythm.Attack('upper', fractions.Fraction(), fractions.Fraction(1)))
separated = (shared[0], dataclasses.replace(shared[1], start=fractions.Fraction(1, 2)))
self.assertLess(generator.comping_rhythm.crowding(shared), generator.comping_rhythm.crowding(separated))
def test_harmonic_changes_share_one_measure_budget(self):
m = copy.deepcopy(self.source['staves'][0]['measures'][0])
m['harmonies'] = [{**generator.harmony.parse_symbol(s), 'id': str(i), 'onset': generator.model.rational(t)} for i, (s, t) in enumerate((('C7', 0), ('FM7', 1), ('G7', 2)))]
for part in ('acoustic', 'electric'):
choices = generator.comping_rhythm.candidates(m, None, part, (), 'changes')
for c in choices:
for lane, high in (('bass', 2), ('upper', 3), ('electric', 2)):
count = sum(e.lane == lane for e in c.events)
self.assertLessEqual(count, high + 1)
if count > high:
self.assertIn((lane, 'extra attack for harmonic changes'), c.exceptions)
for e in c.events:
self.assertLessEqual(e.end, 1 if e.start < 1 else 2 if e.start < 2 else 4)
if part == 'electric':
self.assertTrue(all(c.exceptions for c in choices))
def test_no_chord_is_silent_and_partial_measure_is_bounded(self):
m = copy.deepcopy(self.source['staves'][0]['measures'][0])
m['harmonies'][0]['kind'] = 'none'
self.assertTrue(all(not c.events for c in generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'silence')))
m['harmonies'][0] = {**generator.harmony.parse_symbol('C7'), 'id': 'chord', 'onset': generator.model.rational(0)}
m['duration'] = generator.model.rational(fractions.Fraction(1, 2))
self.assertTrue(all(0 <= e.start < e.end <= fractions.Fraction(1, 2) for c in generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'pickup') for e in c.events))
def test_two_other_meters_preserve_full_bar_bounds(self):
for meter in (3, 5):
source = tests.solo_fixtures.song(meter=meter, count=2, chords=('C7',))
for part in ('acoustic', 'electric'):
canonical = generator.comping.Canonical(source, part, 'meter')
for rhythm in canonical.rhythms.values():
self.assertTrue(all(0 <= e.start < e.end <= meter for e in rhythm.events))
def test_melody_changes_the_objective_without_changing_seed(self):
e = generator.comping_rhythm.Attack('electric', fractions.Fraction(1), fractions.Fraction(3, 2))
m = self.source['staves'][0]['measures'][0]
harmony = generator.comping_rhythm.spans(m, None)
busy = tuple((fractions.Fraction(i), fractions.Fraction(i + 1)) for i in range(4))
rest = ((fractions.Fraction(), fractions.Fraction(1, 2)),)
first = dict(generator.comping_rhythm.lane_score((e,), 'electric', busy, harmony, fractions.Fraction(4)))
second = dict(generator.comping_rhythm.lane_score((e,), 'electric', rest, harmony, fractions.Fraction(4)))
self.assertGreater(first['melody_support'], second['melody_support'])
self.assertNotEqual(generator.comping_rhythm.candidates(m, None, 'electric', busy, 'same'), generator.comping_rhythm.candidates(m, None, 'electric', rest, 'same'))
def test_tempo_changes_metadata_but_not_accompaniment_music(self):
changed = copy.deepcopy(self.source)
changed['metadata']['tempo'] = 240
changed['staves'][0]['measures'][0]['tempos'] = [{'onset': generator.model.rational(0), 'bpm': generator.model.rational(240), 'written': {'type': 'quarter', 'dots': 0}}]
a, b = map(generator.production.SourceAnalysis.read, (self.source, changed))
self.assertEqual(a.accompaniment_key(), b.accompaniment_key())
for role in ('acoustic_accompaniment', 'electric_accompaniment'):
first = generator.production.generate_role(a, role, pathlib.Path('data'))
second = generator.production.generate_role(b, role, pathlib.Path('data'))
self.assertEqual(sounding(first['document']), sounding(second['document']))
self.assertEqual(first['details'], second['details'])
self.assertNotEqual(first['trace']['cache_key'], second['trace']['cache_key'])
def test_assembly_keeps_overlaps_and_canonical_reuse(self):
source = generator.production.SourceAnalysis.read(self.source)
results = {role: generator.production.generate_role(source, role, pathlib.Path('data')) for role in generator.production.ROLES if not role.endswith('_solo')}
# Silence in source-defined solo slots is sufficient to test union;
# no generated solo participates in these accompaniment decisions.
for part in ('acoustic', 'electric'):
empty = copy.deepcopy(results[part + '_accompaniment'])
for m in empty['document']['staves'][0]['measures']:
for v in m['voices']:
for n in v['notes']:
n.update(kind='rest', pitches=[])
results[part + '_solo'] = empty
assembled = generator.production.assemble(source, results)
self.assertTrue(any(len(m['voices']) == 2 for m in assembled['acoustic']['staves'][0]['measures']))
audit = generator.arrange.accompaniment.audit(self.source, assembled)
self.assertGreater(audit['acoustic']['reused_bars'], 0)
for doc in assembled.values():
generator.model.validate_document(doc)
generator.fretboard.validate_document(doc)
def test_combined_voices_reject_conflicting_strings_and_fret_spans(self):
for string, fret in ((5, 3), (2, 10)):
doc = tests.solo_fixtures.song(count=1, chords=('C',))
staff = doc['staves'][0]
staff['tablature'] = True
m = staff['measures'][0]
pitches = [{'letter': 'c', 'alter': 0, 'octave': 4, 'tablature': {'string': 5, 'fret': 3}}, {'letter': 'c', 'alter': 0, 'octave': 4, 'tablature': {'string': string, 'fret': fret}}]
m['voices'] = [{'id': str(i), 'notes': [generator.model.make_event(str(i), fractions.Fraction(i), fractions.Fraction(2), 'note', [p])]} for i, p in enumerate(pitches)]
with self.assertRaisesRegex(ValueError, 'overlapping guitar voices'):
generator.fretboard.validate_document(doc)
def test_sustained_join_is_one_attack_and_keeps_position(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',))
canonical = generator.comping.Canonical(source, 'electric', 'tie')
inputs = [(m, p, i) for i, (m, p) in enumerate(zip(source['staves'][0]['measures'], generator.accompaniment.carried_chords(source['staves'][0]['measures'])))]
chord = inputs[0][0]['harmonies'][0]
grip = generator.comping.voicings(generator.comping.semantic_key(chord), 'electric')[0]
for m, p, i in inputs:
note = generator.model.make_event('held' + str(i), fractions.Fraction(), fractions.Fraction(4), 'note', generator.fretboard.chord_pitches(chord, grip))
note['voice'] = 'electric'
canonical.cache[canonical.key(m, p, i)]['voices'] = [{'id': 'electric', 'notes': [note]}]
canonical._rhythm_reports[canonical.key(m, p, i)]['layers']['electric'].update(attacks=1, tied_continuations=0)
canonical.connect_holds(inputs)
a, b = [canonical.cache[canonical.key(*args)]['voices'][0]['notes'][0] for args in inputs]
for p, q in zip(a['pitches'], b['pitches']):
self.assertEqual(p['tie_to'], q['id'])
self.assertEqual(q['tie_from'], p['id'])
self.assertEqual(p['tablature'], q['tablature'])
self.assertEqual(canonical.rhythm_report[1]['layers']['electric']['attacks'], 0)
class SoloRhythmConditioning(unittest.TestCase):
def test_melody_rhythm_and_progression_change_learned_probabilities(self):
examples = []
for held, future, duration in ((False, 'FM7', 1), (True, 'FM7', 2), (False, 'Dm7', 3)):
source = tests.solo_fixtures.song(count=2, chords=('C7', future))
if held:
m = source['staves'][0]['measures'][0]
m['voices'][0]['notes'] = [generator.model.make_event('held', fractions.Fraction(), fractions.Fraction(4), 'note', [{'letter': 'c', 'alter': 0, 'octave': 4}])]
analysis = generator.solo.analysis.analyze(source)
harmony = generator.solo.harmonic.Harmony(generator.harmonic_time.read(source['staves'][0]))
examples.append({'analysis': analysis, 'harmony': harmony, 'events': [(fractions.Fraction(), fractions.Fraction(duration), 64), (fractions.Fraction(duration), fractions.Fraction(4), 67)]})
model = generator.solo.learned.Model(generator.solo.learned.fit(examples, {'purpose': 'independently authored conditional-rhythm test'}))
probabilities = []
for example in examples:
decoder = generator.solo.decoder.Decoder(example['analysis'], example['harmony'], model, 'acoustic', fractions.Fraction(), fractions.Fraction(4), seed='fixed')
harmonic, root, phase, source, _ = decoder.context(fractions.Fraction())
context = generator.solo.learned.contexts(harmonic, phase, source, ())
probabilities.append(model.probability(generator.solo.learned.token(fractions.Fraction(1), 64, None, root), context))
self.assertGreater(probabilities[0], probabilities[1])
self.assertGreater(probabilities[0], probabilities[2])
if __name__ == '__main__':
unittest.main()
+150
View File
@@ -0,0 +1,150 @@
"""Accompaniment geometry and whole-form movement, using authored small cases."""
import copy
import fractions
import itertools
import pathlib
import tempfile
import unittest
import generator.accompaniment
import generator.comping
import generator.fretboard
import generator.guitar
import generator.harmony
import generator.model
import generator.production
import generator.score
import generator.vocabulary
import tests.solo_fixtures
def options(symbol, part):
return generator.comping.voicings(generator.comping.semantic_key(generator.harmony.parse_symbol(symbol)), part)
def path_cost(path, costs, part, left=(), right=(), close=False):
total = [sum(costs[i][v][j] for i, v in enumerate(path)) for j in range(4)]
total[1] += sum(generator.comping.movement(part, a, b) for a, b in zip((left, *path), path))
total[1] += generator.comping.movement(part, path[-1], path[0] if close else right)
return tuple(total)
class CompingVoicings(unittest.TestCase):
def test_acoustic_geometry_complete_triads_and_component_fret_limits(self):
for symbol in ('C', 'Cm', 'F#', 'Bbm', 'C7', 'Cm6', 'Em7b5', 'A7b9', 'C7/E', 'C/D'):
with self.subTest(chord=symbol):
chord = generator.harmony.parse_symbol(symbol)
required = generator.comping.requirements(chord, 'acoustic')
for grip in options(symbol, 'acoustic'):
self.assertIn(tuple(p.string_number for p in grip), ((5, 4, 3, 2), (6, 4, 3, 2)))
self.assertLessEqual(grip.bass.fret, 12)
self.assertTrue(all(1 <= p.fret <= 20 for p in grip))
self.assertTrue(generator.fretboard.has_fret_anchor(grip[1:]))
self.assertTrue(generator.fretboard.within_fret_span(grip))
self.assertEqual(grip.bass.midi, min(p.midi for p in grip))
self.assertTrue(required.covers({p.midi % 12 for p in grip}))
self.assertTrue(generator.fretboard.playable_attack((p, fractions.Fraction(1)) for p in grip))
if symbol in ('C', 'Cm', 'F#', 'Bbm'):
self.assertEqual({p.midi % 12 for p in grip}, set(required.allowed))
def test_bass_thirds_only_for_plain_major_and_minor(self):
for symbol, expected in (('C', {0, 4, 7}), ('Cm', {0, 3, 7}), ('C7', {0, 7}), ('Cm6', {0, 7}), ('Em7b5', {4, 10})):
with self.subTest(chord=symbol):
required = generator.comping.requirements(generator.harmony.parse_symbol(symbol), 'acoustic')
self.assertEqual(set(required.basses), expected)
self.assertEqual({v.bass.midi % 12 for v in options(symbol, 'acoustic')}, expected)
self.assertEqual(set(generator.comping.requirements(generator.harmony.parse_symbol('Cdim'), 'acoustic').basses), {0, 6})
def test_explicit_slash_or_inversion_overrides_default_bass(self):
inversion = generator.harmony.parse_symbol('C7')
inversion['inversion'] = 1
for chord, expected in ((generator.harmony.parse_symbol('C7/E'), 4), (generator.harmony.parse_symbol('C/D'), 2), (inversion, 4)):
self.assertEqual(generator.comping.requirements(chord, 'acoustic').basses, (expected,))
grips = generator.comping.voicings(generator.comping.semantic_key(chord), 'acoustic')
self.assertEqual({v.bass.midi % 12 for v in grips}, {expected})
def test_electric_uses_melody_voicing_families_and_chord_semantics(self):
for symbol in ('C', 'Cm', 'C7', 'CmM7', 'Csus', 'Cdim7', 'Caug7', 'C7b9', 'C13', 'C/E'):
with self.subTest(chord=symbol):
required = generator.fretboard.chord_requirements(generator.harmony.parse_symbol(symbol), respect_bass=False)
melody = generator.guitar.chord_positions(required, profile='electric', prune=False)
self.assertEqual(set(options(symbol, 'electric')), set(melody))
for grip in options(symbol, 'electric'):
self.assertIn(tuple(p.string_number for p in grip), generator.guitar.ELECTRIC_STRINGS)
self.assertTrue(required.covers({p.midi % 12 for p in grip}))
# Bass responsibility belongs to the acoustic voicing, as in electric melody.
self.assertEqual(options('C/E', 'electric'), options('C', 'electric'))
def test_impossible_grip_reports_source_location_without_relaxation(self):
source = tests.solo_fixtures.song(count=1, chords=('C5',))
for part in ('acoustic', 'electric'):
with self.subTest(part=part), self.assertRaisesRegex(ValueError, 'source measure 1, beat 1: C5: no legal'):
generator.comping.Canonical(source, part, 'test')
def test_complete_path_beats_greedy_and_matches_exhaustive_search(self):
for part in ('acoustic', 'electric'):
with self.subTest(part=part):
chords = ('C', 'C', 'Dm7')
layers = tuple(options(s, part) for s in chords)
costs = tuple({v: generator.comping.local_cost(part, generator.comping.semantic_key(generator.harmony.parse_symbol(s)), v, 55) for v in layer} for s, layer in zip(chords, layers))
path, cost = generator.comping.select_grips(layers, costs, part, 'test')
greedy = []
for i, layer in enumerate(layers):
greedy.append(min(layer, key=lambda v: (path_cost((*greedy, v), costs[:i + 1], part), v)))
self.assertLess(cost, path_cost(greedy, costs, part))
self.assertEqual(cost, min(path_cost(p, costs, part) for p in itertools.product(*layers)))
self.assertEqual(cost, path_cost(path, costs, part))
self.assertEqual((path, cost), generator.comping.select_grips(tuple(tuple(reversed(l)) for l in layers), costs, part, 'test'))
def test_repeat_join_and_neighbor_boundaries_participate_in_selection(self):
for part in ('acoustic', 'electric'):
layers = tuple(options(s, part) for s in ('C', 'Dm7', 'G7'))
costs = tuple({v: (0, 0, 0, 0) for v in layer} for layer in layers)
for endpoints in ({'close': True}, {'left': options('Am7', part)[0], 'right': options('FM7', part)[-1]}):
with self.subTest(part=part, endpoints=endpoints):
path, cost = generator.comping.select_grips(layers, costs, part, 'test', **endpoints)
self.assertEqual(cost, min(path_cost(p, costs, part, **endpoints) for p in itertools.product(*layers)))
self.assertEqual(cost, path_cost(path, costs, part, **endpoints))
def test_rest_does_not_reset_the_previous_hand_position(self):
source = tests.solo_fixtures.song(count=3, chords=('C', 'C', 'Dm7'))
source['staves'][0]['measures'][1]['harmonies'][0]['kind'] = 'none'
for part in ('acoustic', 'electric'):
canonical = generator.comping.Canonical(source, part, 'rest')
self.assertEqual({i for i, _, _ in canonical.order}, {0, 2})
expected = generator.comping.movement(part, canonical.order[0][2], canonical.order[1][2])
self.assertEqual(next(r['movement'] for r in canonical.report if r['source_index'] == 2), expected)
def test_harmony_variants_are_independent_of_request_order(self):
source = tests.solo_fixtures.song(count=3, chords=('C7', 'FM7', 'G7'))
measures = source['staves'][0]['measures']
carried = generator.accompaniment.carried_chords(measures)
variants = []
for index, symbol in ((1, 'Dm7'), (2, 'Am7')):
changed = copy.deepcopy(measures[index])
changed['harmonies'][0] = {**generator.harmony.parse_symbol(symbol), 'id': 'variant', 'onset': generator.model.rational(0)}
variants.append((changed, carried[index], index))
for part in ('acoustic', 'electric'):
a, b = (generator.comping.Canonical(source, part, 'variants') for _ in range(2))
forward = {args[2]: a(*args) for args in variants}
reverse = {args[2]: b(*args) for args in reversed(variants)}
self.assertEqual(forward, reverse)
self.assertEqual(a.report, b.report)
self.assertEqual(a.base, b.base)
self.assertEqual(a(measures[0], carried[0], 0), b(measures[0], carried[0], 0))
def test_register_preference_does_not_change_rhythm(self):
resources = generator.vocabulary.load()
source = tests.solo_fixtures.song(count=4)
measures = source['staves'][0]['measures']
carried = generator.accompaniment.carried_chords(measures)
def rhythm(canonical):
return [[(n['onset'], n['duration'], n['written'], n['kind']) for n in generator.score.measure_notes(canonical(m, p, i))] for i, (m, p) in enumerate(zip(measures, carried))]
for part in ('acoustic', 'electric'):
with generator.vocabulary.using(resources, part + '_accompaniment', {'center': 45}):
low = generator.comping.Canonical(source, part, 'fixed-rhythm')
with generator.vocabulary.using(resources, part + '_accompaniment', {'center': 70}):
high = generator.comping.Canonical(source, part, 'fixed-rhythm')
self.assertEqual(rhythm(low), rhythm(high))
def test_accompaniment_cache_matches_clean_generation(self):
source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=3))
for part in ('acoustic', 'electric'):
with tempfile.TemporaryDirectory() as directory:
role = part + '_accompaniment'
cold = generator.production.generate_role(source, role, pathlib.Path('data'), cache_dir=directory)
warm = generator.production.generate_role(source, role, pathlib.Path('data'), cache_dir=directory)
clean = generator.production.generate_role(source, role, pathlib.Path('data'))
self.assertTrue(warm['cache_hit'])
self.assertEqual(cold['document'], warm['document'])
self.assertEqual(cold['document'], clean['document'])
if __name__ == '__main__':
unittest.main()
+5 -3
View File
@@ -46,20 +46,22 @@ class GuitarPartTests(unittest.TestCase):
self.assertEqual(set(requirements.allowed), {0, 7})
self.assertFalse(generator.acoustic_voicing.candidates(requirements))
self.assertFalse(generator.guitar.chord_positions(requirements, profile='electric'))
def test_sparse_acoustic_uses_compact_playable_chords(self):
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.model.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(2 <= len(n['pitches']) <= 3 for n in sounding))
self.assertTrue(all(len(n['pitches']) in (1, 3) for n in sounding))
for n in sounding:
self.assertIn(sorted(p['tablature']['string'] for p in n['pitches']), ([2, 3, 4], [5], [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')
notes = tests.test_musicxml.notes(result)
notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] if v['id'] == 'bass' for n in v['notes']]
self.assertEqual({min(generator.model.pitch_number(p) for p in n['pitches']) % 12 for n in notes if n['pitches']}, {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)
+1 -1
View File
@@ -125,7 +125,7 @@ class IndependentRoles(unittest.TestCase):
def signature(m):
return [(n['onset'], n['duration'], [(generator.model.pitch_number(p), p['tablature']) for p in n['pitches']]) for v in m['voices'] for n in v['notes']]
self.assertEqual([signature(m) for m in measures[4:8]], [signature(m) for m in measures[8:12]])
self.assertTrue(all(len(n['pitches']) <= 3 for m in measures for v in m['voices'] for n in v['notes']))
self.assertTrue(all(len(n['pitches']) in (0, 1, 3) for m in measures for v in m['voices'] for n in v['notes']))
class TypedExpressionContracts(unittest.TestCase):
def setUp(self):