198 lines
13 KiB
Python
198 lines
13 KiB
Python
import dataclasses
|
|
import fractions
|
|
import unittest
|
|
|
|
import generator.harmony
|
|
import generator.model
|
|
import generator.scales.selection
|
|
import generator.scales.timeline
|
|
import generator.solo.harmonic
|
|
import generator.solo.motifs
|
|
import generator.solo.scoring
|
|
import generator.solo.types
|
|
import tests.scale_fixtures
|
|
import tests.test_solo_development
|
|
|
|
F = fractions.Fraction
|
|
note = tests.test_solo_development.note
|
|
|
|
def choices(symbols, duration=4, scale=None):
|
|
scale = scale or tests.scale_fixtures.library().scale('major', 6, 'D')
|
|
return tuple(generator.scales.selection.Choice(generator.scales.timeline.ChordSpan(F(i * duration), F((i + 1) * duration), i + 1, str(i + 1), F(1), generator.model.key_from_fifths(-1), generator.harmony.parse_symbol(symbol)), scale, 'fixture') for i, symbol in enumerate(symbols))
|
|
|
|
def anchor(role='terminal', pitch='e', earliest=2, latest=2, duration=2, onset=False):
|
|
return generator.solo.types.Anchor('arrival', F(earliest), F(latest), note(0, 1, pitch).pitch, frozenset({4}), (55, 79), F(duration), role, 'C7', onset, F(4) if role == 'terminal' else None)
|
|
|
|
class HarmonicPassageTests(unittest.TestCase):
|
|
def test_same_collection_distinguishes_chord_function(self):
|
|
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7', 'FM7', 'BbM7')))
|
|
self.assertEqual([s.roles.basic for s in harmony.spans], [frozenset(v) for v in ({7, 10, 2, 5}, {0, 4, 7, 10}, {5, 9, 0, 4}, {10, 2, 5, 9})])
|
|
def test_semantic_degrees_keep_alterations_omissions_suspensions_and_slash_bass(self):
|
|
for symbol in ('C7b5', 'C9', 'Csus4', 'C7no5', 'C/E', 'C/D'):
|
|
chord = generator.harmony.parse_symbol(symbol)
|
|
roles = generator.solo.harmonic.roles(chord)
|
|
self.assertFalse(roles.basic & roles.specified)
|
|
self.assertNotIn(2, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C/D')).basic)
|
|
self.assertIn(2, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C9')).specified)
|
|
self.assertIn(5, generator.solo.harmonic.roles(generator.harmony.parse_symbol('Csus4')).basic)
|
|
self.assertNotIn(7, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C7no5')).basic)
|
|
def test_ordinary_long_destination_requires_chord_member(self):
|
|
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
|
|
self.assertTrue(harmony.evaluate((note(0, 2, 'e'),)).valid)
|
|
self.assertFalse(harmony.evaluate((note(0, 2, 'f'),)).valid)
|
|
self.assertLess(harmony.evaluate((note(0, 1, 'e'),)).exposure, harmony.evaluate((note(0, 1, 'f'),)).exposure)
|
|
def test_connected_passing_motion_is_permitted(self):
|
|
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
|
|
notes = (note(0, F(1, 2), 'g'), note(F(1, 2), 1, 'f'), note(1, 2, 'e'))
|
|
self.assertEqual(harmony.connected_motion(notes, 1), 'passing')
|
|
self.assertTrue(harmony.evaluate(notes).valid)
|
|
self.assertEqual(harmony.evaluate(notes).exposure, 0)
|
|
def test_common_tone_hold_needs_no_reattack(self):
|
|
harmony = generator.solo.harmonic.Harmony(choices(('Em7b5', 'A7'), scale=tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')))
|
|
self.assertTrue(harmony.evaluate((note(2, 6, 'g'),)).valid)
|
|
def test_held_tension_detected_without_scale_change(self):
|
|
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
|
|
notes = (note(2, 6, 'f'),)
|
|
self.assertFalse(harmony.evaluate(notes).valid)
|
|
self.assertEqual([v['function'] for v in harmony.describe(notes)], ['basic', 'mapped_motion'])
|
|
def test_prepared_suspension_requires_actual_resolution(self):
|
|
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
|
|
treatment = generator.solo.types.HarmonicTreatment('suspension', 'suspension', 65, F(4), F(5), 64, F(5))
|
|
notes = (note(2, F(9, 2), 'f'), note(F(9, 2), 6, 'e'))
|
|
self.assertTrue(harmony.evaluate(notes, (treatment,), complete=True).valid)
|
|
partial = (note(2, F(9, 2), 'f'),)
|
|
result = harmony.evaluate(partial, (treatment,), until=F(9, 2))
|
|
self.assertTrue(result.valid)
|
|
self.assertEqual(result.pending, (treatment,))
|
|
self.assertFalse(harmony.evaluate(partial, (treatment,), until=6, complete=True).valid)
|
|
self.assertFalse(harmony.evaluate((note(4, F(9, 2), 'f'), note(F(9, 2), 6, 'e')), (treatment,), complete=True).valid)
|
|
self.assertFalse(harmony.evaluate((note(2, F(9, 2), 'f'), note(F(9, 2), 6, 'g')), (treatment,), complete=True).valid)
|
|
def test_symbol_specified_color_requires_a_plan(self):
|
|
harmony = generator.solo.harmonic.Harmony(choices(('C9',)))
|
|
notes = (note(0, 2, 'd'),)
|
|
color = generator.solo.types.HarmonicTreatment('ninth', 'color', 62, F(0), F(2))
|
|
self.assertFalse(harmony.evaluate(notes).valid)
|
|
self.assertTrue(harmony.evaluate(notes, (color,)).valid)
|
|
ordinary = generator.solo.harmonic.Harmony(choices(('C7',)))
|
|
self.assertFalse(ordinary.evaluate(notes, (color,)).valid)
|
|
def test_quotation_tension_preserved_and_reported(self):
|
|
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
|
|
notes = (note(0, 2, 'f', role='quote'),)
|
|
self.assertTrue(harmony.evaluate(notes).valid)
|
|
report = harmony.describe(notes)
|
|
self.assertTrue(report[0]['protected'])
|
|
self.assertEqual(report[0]['function'], 'mapped_motion')
|
|
def test_partition_invariance_for_roles_duration_activity_and_score(self):
|
|
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
|
|
whole = (note(2, 6, 'f'),)
|
|
split = (note(2, 3, 'f'), note(3, 4, 'f', continuation=True), note(4, 6, 'f', continuation=True))
|
|
self.assertEqual(harmony.evaluate(whole), harmony.evaluate(split))
|
|
self.assertEqual(harmony.describe(whole), harmony.describe(split))
|
|
plan = tests.test_solo_development.plan()
|
|
self.assertEqual(generator.solo.scoring.features(plan, whole, harmony.choices), generator.solo.scoring.features(plan, split, harmony.choices))
|
|
def test_intermediate_arrival_does_not_require_closure(self):
|
|
notes = (note(0, 1, 'g'), note(1, 2, 'e'), note(2, 3, 'f'), note(3, 4, 'g'))
|
|
middle = anchor('intermediate', earliest=1, latest=1, duration=1)
|
|
self.assertTrue(generator.solo.harmonic.anchors_valid((middle,), notes, F(4), complete=True)[0])
|
|
def test_terminal_touch_then_departure_is_not_closure(self):
|
|
terminal = dataclasses.replace(anchor(), duration=F(1, 2), hold_until=None)
|
|
notes = (note(2, F(5, 2), 'e'), note(F(5, 2), 4, 'g'))
|
|
self.assertFalse(generator.solo.harmonic.anchors_valid((terminal,), notes, F(4), complete=True, closure='closed')[0])
|
|
self.assertTrue(generator.solo.harmonic.anchors_valid((anchor(),), (note(2, 4, 'e'),), F(4), complete=True, closure='closed')[0])
|
|
def test_existing_hold_can_satisfy_anchor_only_when_allowed(self):
|
|
notes = (note(0, 4, 'e'),)
|
|
self.assertIsNotNone(generator.solo.harmonic.anchor_note(anchor(), notes))
|
|
self.assertIsNone(generator.solo.harmonic.anchor_note(anchor(onset=True), notes))
|
|
def test_single_unrelated_note_is_not_perfect_motif_evidence(self):
|
|
motif = generator.solo.types.Motif('idea', (note(0, 1, 'c'), note(1, 2, 'd'), note(2, 3, 'g')), F(3), ())
|
|
for treatment in ('contour_reference', 'rhythmic_reference', 'opening_reference', 'answer'):
|
|
self.assertGreater(generator.solo.scoring.motif_fidelity((note(0, 1, 'e'),), motif, treatment), 0)
|
|
def test_scale_index_round_trips_negative_and_octave_crossings(self):
|
|
for scale in (tests.scale_fixtures.library().scale('major', 6, 'D'), tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')):
|
|
for index in range(-21, 28):
|
|
self.assertEqual(generator.solo.types.scale_index(scale, generator.solo.types.scale_pitch(scale, index).number()), index)
|
|
scale = tests.scale_fixtures.library().scale('major', 6, 'D')
|
|
self.assertEqual(generator.solo.types.scale_index(scale, 65) - generator.solo.types.scale_index(scale, 64), 1)
|
|
|
|
class PassageStateTests(unittest.TestCase):
|
|
def test_eight_bar_cadence_carries_realized_identity_and_anchor_line(self):
|
|
import tests.test_solo
|
|
solo = tests.test_solo.compose(tests.test_solo.song(count=8, chords=('Gm7', 'C7', 'FM7', 'BbM7', 'Em7b5', 'A7', 'Dm7', 'Dm7')))
|
|
self.assertEqual(len(solo.passages), 1)
|
|
self.assertEqual((solo.passages[0].start, solo.passages[0].end), (0, 32))
|
|
self.assertEqual([(p.plan.start, p.plan.end) for p in solo.phrases], [(0, 16), (16, 32)])
|
|
first, second = solo.phrases
|
|
self.assertEqual(first.plan.ending, 'connected')
|
|
self.assertEqual(first.state.motifs, second.state.motifs[:len(first.state.motifs)])
|
|
self.assertEqual(first.anchors, second.anchors)
|
|
self.assertTrue(any(a.role == 'intermediate' for a in second.anchors))
|
|
self.assertTrue(all(generator.solo.harmonic.anchor_note(a, second.state.notes) for a in second.anchors))
|
|
heard = {m.id: m for m in second.state.motifs}
|
|
self.assertGreaterEqual(len(heard), 5)
|
|
for motif in list(heard.values())[1:]:
|
|
self.assertIn(motif.parent, heard)
|
|
self.assertTrue(motif.chord_degrees)
|
|
self.assertTrue(generator.solo.harmonic.anchors_valid(second.anchors, solo.notes(), solo.end, complete=True, closure='closed')[0])
|
|
def test_development_references_realization_instead_of_source_template(self):
|
|
plan = tests.test_solo_development.plan()
|
|
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
|
|
gesture = generator.solo.types.Gesture('heard', F(0), F(4), 'statement', 'opening_reference')
|
|
notes = (note(0, 1, 'e'), note(1, 2, 'f'), note(2, 4, 'g'))
|
|
valid, _, realized = generator.solo.motifs.complete(gesture, plan.motif, notes, harmony)
|
|
self.assertTrue(valid)
|
|
following = dataclasses.replace(gesture, id='answer', start=F(4), end=F(8), relationship='answer')
|
|
self.assertEqual(generator.solo.motifs.reference(plan, following, (realized,)), realized)
|
|
self.assertEqual(realized.contour(), (1, 2))
|
|
self.assertNotEqual(realized.contour(), plan.motif.contour())
|
|
def test_concrete_pool_includes_continuation_from_actual_incoming_register(self):
|
|
import generator.solo.realization
|
|
import tests.solo_fixtures
|
|
plan = tests.test_solo_development.plan()
|
|
incoming = note(0, 4, 'c', octave=5)
|
|
state = generator.solo.types.ContinuationState('passage', (incoming,), last=incoming)
|
|
cell = generator.solo.realization.Cell(F(4), F(6), (('moving', ((F(0), F(1, 2)), (F(1, 2), F(1)), (F(1), F(3, 2)), (F(3, 2), F(2)))),))
|
|
pool = generator.solo.realization.candidates(cell, plan, choices(('C7', 'C7')), tests.solo_fixtures.library(), state)
|
|
self.assertTrue(pool)
|
|
self.assertTrue(any(c.notes[0].pitch.number() == incoming.pitch.number() for c in pool))
|
|
self.assertTrue(any(c.notes[0].continuation for c in pool))
|
|
def test_pending_suspension_guides_next_cells_candidate_pool(self):
|
|
import generator.solo.realization
|
|
import tests.solo_fixtures
|
|
harmony = choices(('Gm7', 'C7'))
|
|
held = note(2, F(9, 2), 'f')
|
|
contract = generator.solo.types.HarmonicTreatment('pending', 'suspension', 65, F(4), F(9, 2), 64, F(5))
|
|
state = generator.solo.types.ContinuationState('passage', (held,), treatments=(contract,), last=held)
|
|
cell = generator.solo.realization.Cell(F(9, 2), F(6), (('answer', ((F(0), F(1, 2)), (F(1, 2), F(1)), (F(1), F(3, 2)))),))
|
|
pool = generator.solo.realization.candidates(cell, tests.test_solo_development.plan(), harmony, tests.solo_fixtures.library(), state)
|
|
self.assertTrue(pool)
|
|
self.assertTrue(all(c.notes[0].pitch.number() == 64 for c in pool))
|
|
for candidate in pool:
|
|
checked = generator.solo.harmonic.Harmony(harmony).evaluate((*state.notes, *candidate.notes), candidate.treatments, until=6, complete=True)
|
|
self.assertTrue(checked.valid)
|
|
self.assertFalse(checked.pending)
|
|
def test_incompatible_anchor_cannot_retime_a_protected_quote(self):
|
|
import generator.solo.realization
|
|
import tests.test_solo
|
|
import tests.solo_fixtures
|
|
import tests.rhythm_fixtures
|
|
plan = tests.test_solo_development.plan()
|
|
demanded = generator.solo.types.Anchor('incompatible', F(1, 4), F(1, 4), plan.motif.notes[1].pitch, frozenset({plan.motif.notes[1].pitch.number() % 12}), plan.register, F(1, 2), 'opening', 'CM7', True)
|
|
plan = dataclasses.replace(plan, treatment='exact_quote', anchor_options=((demanded,),))
|
|
source = tests.test_solo.song(count=2, chords=('CM7',))
|
|
with self.assertRaises(generator.solo.realization.SearchExhausted):
|
|
generator.solo.realization.compose(plan, source['staves'][0]['measures'], tests.test_solo_development.choices(), tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), F(120))
|
|
def test_phrase_lookahead_does_not_double_count_shared_passage_prefix(self):
|
|
import generator.solo.composer
|
|
first = dataclasses.replace(tests.test_solo_development.plan(), passage_id='shared', start=F(0), end=F(4))
|
|
second = dataclasses.replace(first, id='second', start=F(4), end=F(8))
|
|
left = generator.solo.types.Phrase(first, (generator.solo.types.Decision(F(0), F(4), 'fixture', 'invented', (note(0, 4, 'c'),)),), 1.0)
|
|
right = generator.solo.types.Phrase(first, (generator.solo.types.Decision(F(0), F(4), 'fixture', 'invented', (note(0, 4, 'g'),)),), 0.5)
|
|
def realize(plan, previous):
|
|
if plan.id == first.id:
|
|
return (left, right)
|
|
score = 0.6 if previous.pitch.letter == 'c' else 0.7
|
|
continuation = dataclasses.replace(previous, start=F(4), end=F(8))
|
|
return tuple([generator.solo.types.Phrase(second, (generator.solo.types.Decision(F(4), F(8), 'fixture', 'answer', (continuation,)),), score)])
|
|
chosen = generator.solo.composer.choose_phrases((first, second), realize)
|
|
self.assertEqual(chosen[0], left)
|