110 lines
6.3 KiB
Python
110 lines
6.3 KiB
Python
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import fractions
|
|
|
|
import generator.harmony
|
|
import generator.model
|
|
import generator.pitch
|
|
import generator.scales.library
|
|
import generator.scales.timeline
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class Choice:
|
|
span: generator.scales.timeline.ChordSpan
|
|
scale: generator.scales.library.Scale | None
|
|
reason: str
|
|
|
|
def root_number(chord: dict) -> fractions.Fraction:
|
|
root = chord['root']
|
|
return (generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])) % 12
|
|
|
|
def dominant(chord: dict) -> bool:
|
|
degrees = generator.harmony.degrees(chord)
|
|
return degrees.get(3) == 0 and degrees.get(7) == -1
|
|
|
|
def destination(chord: dict, following: dict | None, library: generator.scales.library.Library):
|
|
if following and dominant(chord) and (root_number(chord) - root_number(following)) % 12 == 7:
|
|
third = generator.harmony.degrees(following).get(3)
|
|
if third in (-1, 0):
|
|
return library.scale('harmonic-minor' if third == -1 else 'major', 1, generator.model.pitch_name(following['root']))
|
|
return None
|
|
|
|
def preference(chord: dict, following: dict | None, after: dict | None, home: generator.scales.library.Scale, library: generator.scales.library.Library) -> tuple[generator.scales.library.Scale, str]:
|
|
if any(root_number(item).denominator != 1 for item in (chord, following, after) if item):
|
|
return home, 'non-semitone harmony cannot establish a destination in the permitted catalog'
|
|
if target := destination(chord, following, library):
|
|
return target, 'dominant approaching ' + generator.model.pitch_name(following['root']) + (' minor' if target.pattern.family == 'harmonic-minor' else ' major')
|
|
degrees = generator.harmony.degrees(chord)
|
|
if following and degrees.get(3) == -1 and degrees.get(7) == -1 and dominant(following) and (root_number(following) - root_number(chord)) % 12 == 5:
|
|
tonic = generator.pitch.shift_pitch({**chord['root'], 'octave': 4}, -1, -2)
|
|
target = destination(following, after, library) or library.scale('harmonic-minor' if degrees.get(5) == -1 else 'major', 1, generator.model.pitch_name(tonic))
|
|
if set(generator.harmony.pitch_classes(chord)) | set(generator.harmony.pitch_classes(following)) <= target.pitches:
|
|
return target, 'shared ' + ('minor ii-V' if target.pattern.family == 'harmonic-minor' else 'major ii-V') + ' destination ' + target.root
|
|
if following and degrees.get(3) == -1 and degrees.get(5) == -1 and degrees.get(7) == -2 and (root_number(following) - root_number(chord)) % 12 == 1:
|
|
return library.scale('harmonic-minor', 1, generator.model.pitch_name(following['root'])), 'leading-tone diminished resolution to ' + generator.model.pitch_name(following['root'])
|
|
return home, 'declared tonal center ' + home.label()
|
|
|
|
def following_indices(timeline: tuple[generator.scales.timeline.ChordSpan, ...]) -> list[int | None]:
|
|
result = [None] * len(timeline)
|
|
for index in range(len(timeline) - 2, -1, -1):
|
|
here, there = timeline[index], timeline[index + 1]
|
|
if not there.chord or here.key != there.key:
|
|
continue
|
|
fields = ('root', 'kind', 'degrees', 'bass')
|
|
same = here.chord and all(here.chord.get(k) == there.chord.get(k) for k in fields)
|
|
result[index] = result[index + 1] if same else index + 1
|
|
return result
|
|
|
|
def select(timeline: tuple[generator.scales.timeline.ChordSpan, ...], solo: generator.scales.timeline.Solo, library: generator.scales.library.Library) -> tuple[Choice, ...]:
|
|
collections = library.collections()
|
|
following = following_indices(timeline)
|
|
rows, layers = [], []
|
|
for index, original in enumerate(timeline):
|
|
if original.end <= solo.start or original.start >= solo.end:
|
|
continue
|
|
span = dataclasses.replace(original, start=max(original.start, solo.start), end=min(original.end, solo.end), beat=original.beat + max(0, solo.start - original.start))
|
|
try:
|
|
home = library.for_key(span.key)
|
|
except ValueError as error:
|
|
raise ValueError(f'measure {span.source_measure}, beat {span.beat}: {error}') from error
|
|
next_index = following[index]
|
|
after_index = following[next_index] if next_index is not None else None
|
|
next_chord = timeline[next_index].chord if next_index is not None else None
|
|
after = timeline[after_index].chord if after_index is not None else None
|
|
target, reason = preference(span.chord, next_chord, after, home, library) if span.chord else (home, 'no harmony')
|
|
# Name the home collection from the declared tonic, not its relative major.
|
|
if target.pitches == home.pitches:
|
|
target = home
|
|
required = set(generator.harmony.pitch_classes(span.chord)) if span.chord else set()
|
|
options = [target if scale.pitches == target.pitches else home if scale.pitches == home.pitches else scale for scale in collections if required <= scale.pitches] if span.chord else []
|
|
options = options or [None]
|
|
layer = []
|
|
for scale in options:
|
|
local = (int(scale is not None and scale.pitches != target.pitches), 0, len(scale.pitches ^ target.pitches) if scale else 0)
|
|
predecessors = []
|
|
for previous_index, (previous_scale, cost, _) in enumerate(layers[-1] if layers else [(None, (0, 0, 0), None)]):
|
|
changed = int(scale is not None and previous_scale is not None and scale.pitches != previous_scale.pitches)
|
|
predecessors.append((tuple(a + b for a, b in zip(cost, (local[0], changed, local[2]))), previous_index))
|
|
cost, predecessor = min(predecessors)
|
|
layer.append((scale, cost, predecessor))
|
|
rows.append((span, target, reason))
|
|
layers.append(layer)
|
|
if not layers:
|
|
return ()
|
|
position = min(range(len(layers[-1])), key=lambda i: layers[-1][i][1])
|
|
choices = []
|
|
for (span, target, reason), layer in reversed(list(zip(rows, layers))):
|
|
scale, _, position = layer[position]
|
|
if span.chord and scale is None:
|
|
reason = 'No exact match within the permitted catalog.'
|
|
elif scale and scale.pitches != target.pitches:
|
|
reason = 'compatible alternative to ' + target.label() + '; selected for collection continuity and closest contextual pitches (' + reason + ')'
|
|
choices.append(Choice(span, scale, reason))
|
|
result = []
|
|
for choice in reversed(choices):
|
|
if result and choice.scale and result[-1].scale and choice.scale.pitches == result[-1].scale.pitches:
|
|
choice = dataclasses.replace(choice, scale=result[-1].scale)
|
|
result.append(choice)
|
|
return tuple(result)
|