Rhythm Patterns
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
"""Recurring source-position grooves, fills and chord-first bass behavior."""
|
||||
import concurrent.futures
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
import multiprocessing
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
import unittest.mock
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic
|
||||
import generator.arrange.accompaniment
|
||||
import generator.arrange.form
|
||||
import generator.comping
|
||||
import generator.comping_rhythm
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.production
|
||||
import generator.score
|
||||
import generator.vocabulary
|
||||
import tests.solo_fixtures
|
||||
|
||||
def groups(source):
|
||||
measures = source['staves'][0]['measures']
|
||||
carried = generator.accompaniment.carried_chords(measures)
|
||||
return [tuple((m, carried[i], i) for i, m in enumerate(measures[a:a + 2], a)) for a in range(0, len(measures), 2)]
|
||||
|
||||
def signature(measure):
|
||||
return [(generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
|
||||
[(generator.model.pitch_number(p), p.get('tablature'), bool(p.get('tie_from')), bool(p.get('tie_to'))) for p in n['pitches']])
|
||||
for n in generator.score.measure_notes(measure)]
|
||||
|
||||
class RecurringGrooves(unittest.TestCase):
|
||||
def test_cycles_repeat_distinct_patterns_for_arbitrary_lengths(self):
|
||||
for count in (9, 18, 32):
|
||||
source = tests.solo_fixtures.song(count=count, chords=('C7',))
|
||||
for part, width in (('acoustic', 2), ('electric', 4)):
|
||||
canonical = generator.comping.Canonical(source, part, 'cycle')
|
||||
self.assertEqual(canonical.groove_report[0]['cycle_patterns'], width)
|
||||
patterns = [p for p, _ in canonical.patterns.values()]
|
||||
identities = [generator.comping_rhythm.identity(p) for p in patterns[:width]]
|
||||
self.assertEqual(len(set(identities)), width)
|
||||
for i, p in enumerate(patterns):
|
||||
self.assertEqual(generator.comping_rhythm.identity(p), identities[i % width])
|
||||
# The odd final measure is the first half of its cycle's figure.
|
||||
if count % 2:
|
||||
self.assertEqual(canonical.block_rhythms[count - 1].events,
|
||||
tuple(e for e in canonical.block_rhythms[(len(patterns) - 1) % width * 2].events if e.start < 4))
|
||||
def test_whole_form_evaluation_can_reject_locally_best_pattern(self):
|
||||
source = tests.solo_fixtures.song(count=8, chords=('C7',))
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['electric']
|
||||
policy['groove']['cycle_patterns'] = 1
|
||||
policy['patterns'] = [p for p in policy['patterns'] if p['id'] in ('electric.charleston.statement', 'electric.quarters.statement')]
|
||||
realize = generator.comping_rhythm.realize
|
||||
def evaluated(pattern, inputs, *args, **kwargs):
|
||||
candidate = realize(pattern, inputs, *args, **kwargs)
|
||||
# Charleston wins the first two bars, but loses the full eight-bar span.
|
||||
cost = (-1 if inputs[0][2] == 0 else 2) if 'charleston' in pattern['id'] else 0
|
||||
return dataclasses.replace(candidate, contributions=(('test_context', cost),))
|
||||
with generator.vocabulary.using(resources, 'electric_accompaniment'), unittest.mock.patch('generator.comping_rhythm.realize', side_effect=evaluated):
|
||||
selected, _, report = generator.comping_rhythm.choose_cycles(groups(source), 'electric', generator.comping_rhythm.melody_contexts(source), 'coverage')
|
||||
self.assertTrue(all(p['id'] == 'electric.quarters.statement' for p, _, _ in selected))
|
||||
self.assertEqual(report[0]['cost'], 0)
|
||||
def test_melody_and_progression_influence_selection_with_fixed_seed(self):
|
||||
source = tests.solo_fixtures.song(count=16, chords=('C7', 'FM7'))
|
||||
sparse = copy.deepcopy(source)
|
||||
for m in sparse['staves'][0]['measures']:
|
||||
m['voices'][0]['notes'] = [generator.model.make_event(m['id'] + '_held', fractions.Fraction(), fractions.Fraction(1, 2), 'note', [{'letter': 'c', 'alter': 0, 'octave': 4}])]
|
||||
choices = [generator.comping_rhythm.choose_cycles(groups(s), 'electric', generator.comping_rhythm.melody_contexts(s), 'fixed')[2] for s in (source, sparse)]
|
||||
self.assertNotEqual(choices[0][0]['patterns'], choices[1][0]['patterns'])
|
||||
changed = copy.deepcopy(source)
|
||||
for m in changed['staves'][0]['measures']:
|
||||
m['harmonies'].append({**generator.harmony.parse_symbol('G7'), 'id': 'change', 'onset': generator.model.rational(fractions.Fraction(3, 2))})
|
||||
other = generator.comping_rhythm.choose_cycles(groups(changed), 'electric', generator.comping_rhythm.melody_contexts(changed), 'fixed')[2]
|
||||
self.assertNotEqual(choices[0][0]['cost'], other[0]['cost'])
|
||||
def test_sparse_fill_replaces_only_second_bar_and_does_not_change_phase(self):
|
||||
source = tests.solo_fixtures.song(count=32, chords=('C7',))
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['electric']
|
||||
policy['groove']['cycle_patterns'] = 1
|
||||
policy['patterns'] = [p for p in policy['patterns'] if p['id'] in ('electric.charleston.statement', 'electric.charleston.answer')]
|
||||
# Section-ending evidence is source data. Deliberately put it at nearby
|
||||
# positions to exercise spacing, including the cyclic end/start join.
|
||||
for i in (2, 4, 12, 20, 30):
|
||||
source['staves'][0]['measures'][i]['section_label'] = 'Source section'
|
||||
realize = generator.comping_rhythm.realize
|
||||
def evaluated(pattern, inputs, *args, **kwargs):
|
||||
candidate = realize(pattern, inputs, *args, **kwargs)
|
||||
cost = -1 if '.fill.' in pattern['id'] and pattern['id'].endswith('.answer') else 0 if pattern['id'].endswith('.statement') else 1
|
||||
return dataclasses.replace(candidate, contributions=(('test_context', cost),))
|
||||
with generator.vocabulary.using(resources, 'electric_accompaniment'), unittest.mock.patch('generator.comping_rhythm.realize', side_effect=evaluated):
|
||||
selected, locations, _ = generator.comping_rhythm.choose_cycles(groups(source), 'electric', generator.comping_rhythm.melody_contexts(source), 'fills')
|
||||
fills = [v['fill']['source_index'] for v in locations if v['fill']]
|
||||
self.assertGreater(len(fills), 0)
|
||||
self.assertLessEqual(len(fills), 4)
|
||||
for i, a in enumerate(fills):
|
||||
for b in fills[i + 1:]:
|
||||
self.assertGreaterEqual(min(abs(a - b), 32 - abs(a - b)), 8)
|
||||
original = policy['patterns'][0]
|
||||
for pattern, _, _ in selected:
|
||||
self.assertEqual([r for r in pattern['events'] if fractions.Fraction(r['onset']) < 4], [r for r in original['events'] if fractions.Fraction(r['onset']) < 4])
|
||||
self.assertTrue(all(v['cycle_slot'] == 0 for v in locations))
|
||||
def test_real_source_fit_can_select_a_fill_without_mocked_scores(self):
|
||||
source = tests.solo_fixtures.song(count=16, chords=('C7',))
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['electric']
|
||||
policy['groove']['cycle_patterns'] = 1
|
||||
policy['patterns'] = [{'id': 'test.' + name, 'family': 'call_response', 'lengths': ['4', '4'], 'events': [
|
||||
{'onset': '1/2', 'duration': '1/2', 'layer': 'electric'}, {'onset': onset, 'duration': '1/2', 'layer': 'electric'}]}
|
||||
for name, onset in (('base', '11/2'), ('answer', '5'))]
|
||||
# Most of the form favors the offbeat answer. At the final boundary the
|
||||
# source enters on beat two after a breath, so the related on-beat answer
|
||||
# supports that entrance. All contributions come from the real evaluator.
|
||||
source['staves'][0]['measures'][-1]['voices'][0]['notes'].pop(0)
|
||||
with generator.vocabulary.using(resources, 'electric_accompaniment'):
|
||||
_, locations, reports = generator.comping_rhythm.choose_cycles(groups(source), 'electric', generator.comping_rhythm.melody_contexts(source), 'real-fill')
|
||||
self.assertEqual(reports[0]['patterns'], ['test.base'])
|
||||
self.assertEqual([r['fill']['source_index'] for r in locations if r['fill']], [15])
|
||||
def test_meter_change_restarts_compatible_cycle_and_reports_reduction(self):
|
||||
source = tests.solo_fixtures.song(count=8, chords=('C7',))
|
||||
shorter = tests.solo_fixtures.song(meter=3, count=4, chords=('C7',))
|
||||
source['staves'][0]['measures'][4:] = shorter['staves'][0]['measures']
|
||||
canonical = generator.comping.Canonical(source, 'electric', 'meter')
|
||||
self.assertEqual([r['meter'] for r in canonical.groove_report], ['4/4', '3/4'])
|
||||
self.assertTrue(all(r['reduction'] for r in canonical.groove_report))
|
||||
self.assertEqual(canonical.locations[4]['cycle_slot'], 0)
|
||||
def test_partial_entry_uses_second_half_instead_of_restarting(self):
|
||||
source = tests.solo_fixtures.song(count=10, chords=('C7',))
|
||||
canonical = generator.comping.Canonical(source, 'electric', 'entry')
|
||||
measures = source['staves'][0]['measures']
|
||||
full = canonical(measures[3], measures[2]['harmonies'][0], 3)
|
||||
sliced = canonical(measures[3], measures[2]['harmonies'][0], 3, bounds=(3, 10))
|
||||
# The entry may attack a formerly carried chord; actual relative times
|
||||
# and fret positions remain from the same second half of the figure.
|
||||
def music(m):
|
||||
return [(generator.model.fraction(n['onset']), generator.model.fraction(n['duration']), [(generator.model.pitch_number(p), p['tablature']) for p in n['pitches']]) for n in generator.score.measure_notes(m)]
|
||||
self.assertEqual(music(full), music(sliced))
|
||||
def test_final_harmony_change_keeps_unaffected_rhythm_and_grips(self):
|
||||
source = tests.solo_fixtures.song(count=8, chords=('C7',))
|
||||
canonical = generator.comping.Canonical(source, 'acoustic', 'final')
|
||||
original = source['staves'][0]['measures']
|
||||
changed = copy.deepcopy(original)
|
||||
changed[-1]['harmonies'] = [{**generator.harmony.parse_symbol('FM7'), 'id': 'final', 'onset': generator.model.rational(0)}]
|
||||
before = canonical(original[6], original[5]['harmonies'][0], 6)
|
||||
after = canonical(changed[6], changed[5]['harmonies'][0], 6, sources=changed)
|
||||
self.assertEqual(signature(before), signature(after))
|
||||
self.assertEqual(canonical.rhythm_report[-1]['base_pattern'], canonical.locations[6]['base_pattern'])
|
||||
def test_overlap_survives_single_voice_merge_and_tablature(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7',))
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['acoustic']
|
||||
policy['patterns'] = [p for p in policy['patterns'] if p['id'] == 'acoustic.quarters.overlapping']
|
||||
with generator.vocabulary.using(resources, 'acoustic_accompaniment'):
|
||||
canonical = generator.comping.Canonical(source, 'acoustic', 'overlap')
|
||||
source['staves'][0]['measures'] = [canonical(m, None, i) for i, m in enumerate(source['staves'][0]['measures'])]
|
||||
source['staves'][0].update(arrangement_part_type='acoustic', tablature=True)
|
||||
generator.acoustic.validate_comping(source)
|
||||
generator.acoustic.combine(source)
|
||||
generator.model.validate_document(source)
|
||||
generator.fretboard.validate_document(source)
|
||||
generator.acoustic.validate_comping(source)
|
||||
first = source['staves'][0]['measures'][0]
|
||||
self.assertEqual(len(first['voices']), 1)
|
||||
overlap = next(n for n in generator.score.measure_notes(first) if generator.model.fraction(n['onset']) == fractions.Fraction(1, 2))
|
||||
self.assertEqual(len(overlap['pitches']), 4)
|
||||
self.assertTrue(any(p.get('tie_from') and p['tablature']['string'] >= 5 for p in overlap['pitches']))
|
||||
def test_mid_measure_chord_change_bass_without_forced_barline_attack(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7',))
|
||||
m = source['staves'][0]['measures'][0]
|
||||
m['harmonies'].append({**generator.harmony.parse_symbol('FM7'), 'id': 'middle', 'onset': generator.model.rational(2)})
|
||||
pattern = {'id': 'test', 'family': 'quarters', 'lengths': ['4'], 'events': [
|
||||
{'onset': '1/2', 'duration': '1', 'layer': 'upper'}, {'onset': '5/2', 'duration': '1/2', 'layer': 'upper'}]}
|
||||
events, _ = generator.comping_rhythm.fit(pattern, m, None, 'acoustic')
|
||||
self.assertEqual([(e.start, e.lane) for e in events], [(fractions.Fraction(1, 2), 'combined'), (fractions.Fraction(5, 2), 'combined')])
|
||||
# A computational boundary inside continuing harmony must not add bass.
|
||||
inputs = ((source['staves'][0]['measures'][1], source['staves'][0]['measures'][1]['harmonies'][0], 1),)
|
||||
r = generator.comping_rhythm.realize(pattern, inputs, 'acoustic', generator.comping_rhythm.melody_contexts(source))
|
||||
self.assertTrue(all(e.lane == 'upper' for e in r.events))
|
||||
def test_clean_cached_parallel_and_other_role_settings_keep_groove(self):
|
||||
source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=10, chords=('C7', 'FM7')))
|
||||
root = pathlib.Path('data').resolve()
|
||||
roles = ('acoustic_accompaniment', 'electric_accompaniment')
|
||||
clean = {r: generator.production.generate_role(source, r, root) for r in roles}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
for r in roles:
|
||||
cold = generator.production.generate_role(source, r, root, cache_dir=directory)
|
||||
warm = generator.production.generate_role(source, r, root, cache_dir=directory)
|
||||
self.assertTrue(warm['cache_hit'])
|
||||
self.assertEqual(clean[r]['document'], cold['document'])
|
||||
self.assertEqual(clean[r]['document'], warm['document'])
|
||||
generator.production.generate_role(source, roles[1], root, {'seed': 'different'}, directory)
|
||||
self.assertEqual(clean[roles[0]]['document'], generator.production.generate_role(source, roles[0], root, cache_dir=directory)['document'])
|
||||
with concurrent.futures.ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as pool:
|
||||
futures = {r: pool.submit(generator.production._job, (source, r, root, {}, None)) for r in reversed(roles)}
|
||||
for r, future in futures.items():
|
||||
self.assertEqual(clean[r]['document'], future.result()['document'])
|
||||
def test_reuse_audit_checks_rests_and_all_six_roles(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7',))
|
||||
for role in generator.production.ROLES:
|
||||
part = role.split('_')[0]
|
||||
wanted = 'slash' if role.endswith('_solo') else 'vocal' if part == 'vocal' else 'melody' if role.endswith('_melody') else 'accompaniment'
|
||||
section = generator.arrange.form.ArrangementSection('Repeated role', 0, 2, 1, wanted)
|
||||
doc = copy.deepcopy(source)
|
||||
doc['staves'][0]['measures'] *= 2
|
||||
doc = copy.deepcopy(doc)
|
||||
with unittest.mock.patch('generator.arrange.form.full_arrangement_sections', return_value=[section, section]):
|
||||
result = generator.arrange.accompaniment.audit(source, {role: doc}, role_documents=True)
|
||||
self.assertEqual(result[role]['reused_bars'], 2)
|
||||
# Remove music only in the second occurrence; reuse must fail.
|
||||
doc['staves'][0]['measures'][2] = copy.deepcopy(doc['staves'][0]['measures'][2])
|
||||
n = doc['staves'][0]['measures'][2]['voices'][0]['notes'][0]
|
||||
n.update(kind='rest', pitches=[])
|
||||
with self.assertRaisesRegex(ValueError, 'canonical'):
|
||||
generator.arrange.accompaniment.audit(source, {role: doc}, role_documents=True)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -53,15 +53,17 @@ class LayeredComping(unittest.TestCase):
|
||||
self.assertTrue(any(c.events[0].start > 0 for c in choices['acoustic']))
|
||||
self.assertTrue(all(c.events[0].lane in ('bass', 'combined') for c in choices['acoustic'] if c.events))
|
||||
self.assertTrue(any(e.end - e.start > 1 for c in choices['electric'] for e in c.events))
|
||||
def test_separate_short_attacks_and_only_synchronized_long_combinations(self):
|
||||
def test_separate_lanes_can_overlap_and_short_combined_attacks_are_valid(self):
|
||||
F = fractions.Fraction
|
||||
attack = generator.comping_rhythm.Attack
|
||||
generator.comping_rhythm.validate_events((attack('bass', F(0), F(1)), attack('upper', F(1), F(3, 2))), 'acoustic')
|
||||
generator.comping_rhythm.validate_events((attack('combined', F(0), F(3, 2)),), 'acoustic')
|
||||
for events in ((attack('combined', F(0), F(1)),), (attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(2)),)):
|
||||
generator.comping_rhythm.validate_events((attack('combined', F(0), F(1)),), 'acoustic')
|
||||
generator.comping_rhythm.validate_events((attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(3, 2))), 'acoustic')
|
||||
for events in ((attack('combined', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(1)), attack('bass', F(1, 2), F(1))), (attack('bass', F(0), F(2)),)):
|
||||
with self.assertRaises(ValueError):
|
||||
generator.comping_rhythm.validate_events(events, 'acoustic')
|
||||
def test_bass_first_reassigns_layers_without_moving_or_adding_attacks(self):
|
||||
def test_bass_first_keeps_pattern_timing_and_does_not_reset_at_barlines(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7',))
|
||||
flat = generator.comping_rhythm.flatten(tuple((m, None, i) for i, m in enumerate(source['staves'][0]['measures'])))
|
||||
patterns = self.resources['accompaniment']['instruments']['acoustic']['patterns']
|
||||
@@ -71,10 +73,11 @@ class LayeredComping(unittest.TestCase):
|
||||
continue
|
||||
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
|
||||
self.assertEqual([(e.start, e.end - e.start) for e in events], [(fractions.Fraction(r['onset']), fractions.Fraction(r['duration'])) for r in pattern['events']])
|
||||
for start in (0, 4):
|
||||
local = [e for e in events if start <= e.start < start + 4]
|
||||
if local:
|
||||
self.assertIn(local[0].lane, ('bass', 'combined'))
|
||||
if events:
|
||||
self.assertIn(events[0].lane, ('bass', 'combined'))
|
||||
pattern = {'lengths': ['4', '4'], 'events': [{'onset': '0', 'duration': '1', 'layer': 'bass'}, {'onset': '4', 'duration': '1', 'layer': 'upper'}]}
|
||||
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
|
||||
self.assertEqual(events[-1].lane, 'upper')
|
||||
self.assertEqual(before, patterns)
|
||||
def test_missing_harmony_reassigns_first_survivor_before_anticipation(self):
|
||||
m = copy.deepcopy(self.source['staves'][0]['measures'][0])
|
||||
@@ -86,24 +89,22 @@ class LayeredComping(unittest.TestCase):
|
||||
{'onset': '3/2', 'duration': '1', 'layer': 'upper', 'anticipate': True},
|
||||
{'onset': '7/2', 'duration': '1/2', 'layer': 'bass'}]}
|
||||
events, changes = generator.comping_rhythm.fit(pattern, m, None, 'acoustic', anticipate=True)
|
||||
self.assertEqual([(e.lane, e.start, e.end, e.harmonic_time) for e in events], [('bass', fractions.Fraction(3, 2), 2, None), ('upper', fractions.Fraction(7, 2), 4, None)])
|
||||
self.assertEqual([(e.lane, e.start, e.end, e.harmonic_time) for e in events], [('combined', fractions.Fraction(3, 2), fractions.Fraction(5, 2), 2), ('bass', fractions.Fraction(7, 2), 4, None)])
|
||||
self.assertIn('layer_assignment', {kind for kind, _ in changes})
|
||||
limited, _ = generator.comping_rhythm.fit(pattern, m, None, 'acoustic', anticipate=True, limit=fractions.Fraction(2))
|
||||
self.assertEqual(limited, (events[0],))
|
||||
self.assertEqual([(e.lane, e.start, e.end, e.harmonic_time) for e in limited], [('combined', fractions.Fraction(3, 2), 2, None)])
|
||||
def test_combined_first_attack_and_carried_hold_do_not_require_reattacks(self):
|
||||
attack, F = generator.comping_rhythm.Attack, fractions.Fraction
|
||||
valid = (attack('combined', F(3), F(5)), attack('bass', F(6), F(7)))
|
||||
generator.comping_rhythm.validate_events(valid, 'acoustic', (4, 4))
|
||||
generator.comping_rhythm.validate_events(valid[:1], 'acoustic', (4, 4))
|
||||
invalid = (valid[0], attack('upper', F(6), F(7)))
|
||||
with self.assertRaisesRegex(ValueError, 'first acoustic attack'):
|
||||
generator.comping_rhythm.validate_events(invalid, 'acoustic', (4, 4))
|
||||
generator.comping_rhythm.validate_events((valid[0], attack('upper', F(6), F(7))), 'acoustic', (4, 4))
|
||||
def test_completed_score_rejects_upper_first_even_after_a_rest(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7',))
|
||||
pattern = {'id': 'test.delayed', 'family': 'call_response', 'lengths': ['4', '4'], 'events': [
|
||||
{'onset': '1', 'duration': '1/2', 'layer': 'upper'}, {'onset': '2', 'duration': '1', 'layer': 'bass'},
|
||||
{'onset': '5', 'duration': '1', 'layer': 'combined'}]}
|
||||
# The final combined event must be longer than one beat.
|
||||
# A long combined event remains available alongside short overlaps.
|
||||
pattern['events'][-1]['duration'] = '2'
|
||||
resources = copy.deepcopy(self.resources)
|
||||
resources['accompaniment']['instruments']['acoustic']['patterns'] = [pattern]
|
||||
@@ -135,7 +136,7 @@ class LayeredComping(unittest.TestCase):
|
||||
for c in anticipated:
|
||||
for e in c.events:
|
||||
if e.harmonic_time is not None:
|
||||
self.assertIn(e.lane, ('upper', 'electric'))
|
||||
self.assertIn(e.lane, ('bass', 'combined', 'upper', 'electric'))
|
||||
self.assertEqual(e.harmonic_time - e.start, fractions.Fraction(1, 2))
|
||||
self.assertLess(e.harmonic_time, e.end)
|
||||
self.assertTrue(any(all(e.harmonic_time is None for e in c.events) for c in candidates))
|
||||
|
||||
Reference in New Issue
Block a user