"""Written chord, source-key motion, and bounded expressive treatments. There is no preselected scale map. Optional colors never rewrite a chord; permission follows the actual sounded interval and validated resolution. """ from __future__ import annotations import dataclasses import functools import fractions import generator.harmony import generator.model import generator.solo.types import generator.solo.profiles import generator.solo.expressions import generator.solo.recognition @dataclasses.dataclass(frozen=True) class Roles: basic: frozenset specified: frozenset degrees: tuple @functools.lru_cache(maxsize=512) def degree_roles(kind, root, degrees): basic, specified, labels = set(), set(), {} for degree, alter in degrees: pc = int(root + generator.harmony.degree_semitones(degree, alter)) % 12 structural = degree in (1, 3, 5, 6, 7) or degree in (2, 4) and kind in ('suspended-second', 'suspended-fourth') (basic if structural else specified).add(pc) labels.setdefault(pc, []).append(degree) return Roles(frozenset(basic), frozenset(specified - basic), tuple((p, tuple(v)) for p, v in sorted(labels.items()))) def roles(chord): if not chord or chord['kind'] == 'none': return Roles(frozenset(), frozenset(), ()) root = chord['root'] pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter']) return degree_roles(chord['kind'], pc, tuple(sorted(generator.harmony.degrees(chord).items()))) @dataclasses.dataclass(frozen=True) class Span: start: fractions.Fraction end: fractions.Fraction roles: Roles chord: str key: dict pitches: frozenset @dataclasses.dataclass(frozen=True) class Result: valid: bool exposure: float pending: tuple = () error: str = '' contracts: tuple = () class Harmony: def __init__(self, spans, profile=None): self.profile = profile or generator.solo.profiles.UNRESTRICTED self.source_spans = spans self.spans = [] self.color_eligibility = {} for s in spans: r = roles(s.chord) key = frozenset((generator.model.NATURAL[l] + int(generator.model.fraction(a))) % 12 for l, a in zip(generator.model.LETTERS, s.key['alterations'])) self.spans.append(Span(s.start, s.end, r, generator.harmony.symbol(s.chord) if s.chord else 'N.C.', s.key, key | r.basic | r.specified)) self.spans = tuple(self.spans) @functools.lru_cache(maxsize=2048) def at(self, time): return next((s for s in self.spans if s.start <= time < s.end), None) def segments(self, n): return tuple((s, max(n.start, s.start), min(n.end, s.end)) for s in self.spans if s.start < n.end and s.end > n.start) @functools.lru_cache(maxsize=8192) def members(self, start, end): spans = [s for s in self.spans if s.start < end and s.end > start] return frozenset.intersection(*(s.roles.basic | s.roles.specified for s in spans)) if spans else frozenset() @functools.lru_cache(maxsize=8192) def permitted(self, start, end): spans = [s for s in self.spans if s.start < end and s.end > start] return frozenset.intersection(*(s.pitches if s.chord != 'N.C.' else frozenset() for s in spans)) if spans else frozenset() def function(self, number, span): pc = number % 12 return 'basic' if pc in span.roles.basic else 'specified_extension' if pc in span.roles.specified else 'diatonic_motion' if pc in span.pitches else 'chromatic' def step(self, a, b, time): return 0 < abs(a - b) <= 2 def connected_motion(self, notes, index): if not 0 < index < len(notes) - 1: return '' a, n, b = notes[index - 1:index + 2] if a.end != n.start or n.end != b.start: return '' left = n.pitch.number() - a.pitch.number() right = b.pitch.number() - n.pitch.number() return 'neighbor' if 0 < abs(left) <= 2 and left == -right else 'passing' if 0 < abs(left) <= 2 and 0 < abs(right) <= 2 and left * right > 0 else '' @functools.lru_cache(maxsize=8192) def _ordinary(self, start, end, number): covered = start for s in self.spans: a, b = max(start, s.start), min(end, s.end) if a >= b: continue if a != covered or s.chord == 'N.C.': return False covered = b pc = number % 12 if pc not in s.roles.basic | s.roles.specified and (pc not in s.pitches or end - start >= 2): return False return covered == end and end > start def ordinary(self, n): return self._ordinary(n.start, n.end, n.pitch.number()) @staticmethod def identified(n): return n if n.event_id else dataclasses.replace(n, event_id=f'event:{n.start}:{n.end}:{n.pitch.label()}') def evaluate(self, notes, *, until=None, complete=False, expressions=(), infer=True): notes = tuple(self.identified(n) for n in generator.solo.types.sounding_notes(notes)) expressions = tuple(dataclasses.replace(c, events=tuple(self.identified(n) for n in c.events), target=self.identified(c.target), preparation=self.identified(c.preparation) if c.preparation else None) for c in expressions) frontier = until if until is not None else max((n.end for n in notes), default=fractions.Fraction()) permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, complete or until is None) if error: return Result(False, 0, error=error) if infer and not pending and (complete or until is None) and any(not self.ordinary(n) and n.event_id not in permissions for n in notes): expressions, error = generator.solo.recognition.recognize(notes, self, expressions) if error: return Result(False, 0, error=error) permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, True) if error: return Result(False, 0, error=error) exposed = sounded = fractions.Fraction() for n in notes: covered = n.start duration = n.end - n.start sounded += duration for s, a, b in self.segments(n): if a != covered or s.chord == 'N.C.': return Result(False, 0, error=f'{n.event_id}: missing written/carried harmony') covered = b permission = permissions.get(n.event_id) if permission and permission.kind == 'anticipation' and a >= permission.boundary: permission = None pc = n.pitch.number() % 12 if pc in s.roles.basic | s.roles.specified or permission: continue if pc not in s.pitches: return Result(False, 0, error=f'{n.event_id}: unsupported chromatic event') if duration >= 2: return Result(False, 0, error=f'{n.event_id}: sustained nonchord tone needs an explicit treatment') exposed += (b - a) / 4 if covered != n.end: return Result(False, 0, error=f'{n.event_id}: incomplete harmonic coverage') return Result(True, float(exposed / max(fractions.Fraction(1, 1000), sounded)), pending, contracts=tuple(expressions)) def describe(self, notes, expressions=()): notes = tuple(self.identified(n) for n in generator.solo.types.sounding_notes(notes)) checked = self.evaluate(notes, expressions=expressions, complete=True) if not checked.valid: raise ValueError(checked.error) permissions = {n.event_id: c for c in checked.contracts for n in c.events} return [{'start': str(a), 'end': str(b), 'attack': str(n.start), 'sounding_duration': str(n.end - n.start), 'pitch': n.pitch.label(), 'chord': s.chord, 'function': self.function(n.pitch.number(), s), 'expression': permissions[n.event_id].kind if n.event_id in permissions else '', 'expression_rule': permissions[n.event_id].rule if n.event_id in permissions else '', 'protected': n.role == 'quote'} for n in notes for s, a, b in self.segments(n)]