251 lines
14 KiB
Python
251 lines
14 KiB
Python
"""Chord function on exact sounding intervals, separate from scale permission."""
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import fractions
|
|
import functools
|
|
|
|
import generator.harmony
|
|
import generator.model
|
|
import generator.solo.policy
|
|
import generator.solo.types
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class Roles:
|
|
basic: frozenset[int]
|
|
specified: frozenset[int]
|
|
degrees: tuple[tuple[int, tuple[int, ...]], ...]
|
|
|
|
@functools.lru_cache(maxsize=512)
|
|
def degree_roles(kind, root, degrees):
|
|
basic, specified, labels = set(), set(), {}
|
|
for degree, alteration in degrees:
|
|
pitch = int(root + generator.harmony.degree_semitones(degree, alteration)) % 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(pitch)
|
|
labels.setdefault(pitch, []).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']
|
|
number = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
|
|
return degree_roles(chord['kind'], number, tuple(sorted(generator.harmony.degrees(chord).items())))
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class Span:
|
|
start: fractions.Fraction
|
|
end: fractions.Fraction
|
|
roles: Roles
|
|
scale: object
|
|
chord: str
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class Result:
|
|
valid: bool
|
|
exposure: float
|
|
pending: tuple[generator.solo.types.HarmonicTreatment, ...] = ()
|
|
error: str = ''
|
|
|
|
class Harmony:
|
|
def __init__(self, choices, profile=None):
|
|
from generator.solo.profiles import UNRESTRICTED
|
|
self.profile = profile or UNRESTRICTED
|
|
self.choices = choices
|
|
self.spans = tuple(Span(c.span.start, c.span.end, roles(c.span.chord), c.scale, generator.harmony.symbol(c.span.chord) if c.span.chord else 'N.C.') for c in choices)
|
|
self._at = {}
|
|
self._segments = {}
|
|
self._members = {}
|
|
self.evaluations = {}
|
|
def at(self, time):
|
|
if time not in self._at:
|
|
self._at[time] = next((s for s in self.spans if s.start <= time < s.end), None)
|
|
return self._at[time]
|
|
def segments(self, note):
|
|
key = (note.start, note.end)
|
|
if key not in self._segments:
|
|
self._segments[key] = tuple((s, max(note.start, s.start), min(note.end, s.end)) for s in self.spans if s.start < note.end and s.end > note.start)
|
|
return self._segments[key]
|
|
def members(self, start, end):
|
|
key = (start, end)
|
|
if key not in self._members:
|
|
spans = [s for s in self.spans if s.start < end and s.end > start]
|
|
self._members[key] = frozenset.intersection(*(s.roles.basic for s in spans)) if spans else frozenset()
|
|
return self._members[key]
|
|
def function(self, number, span):
|
|
pitch = number % 12
|
|
return 'basic' if pitch in span.roles.basic else 'specified_extension' if pitch in span.roles.specified else 'mapped_motion' if span.scale and pitch in span.scale.pitches else 'outside_collection'
|
|
def step(self, first, second, time):
|
|
span = self.at(time)
|
|
if not span or not span.scale:
|
|
return False
|
|
try:
|
|
return abs(generator.solo.types.scale_index(span.scale, first) - generator.solo.types.scale_index(span.scale, second)) == 1
|
|
except ValueError:
|
|
return False
|
|
def connected_motion(self, notes, index):
|
|
if not 0 < index < len(notes) - 1:
|
|
return ''
|
|
before, note, after = notes[index - 1:index + 2]
|
|
if before.end != note.start or note.end != after.start:
|
|
return ''
|
|
a, b, c = (n.pitch.number() for n in (before, note, after))
|
|
if self.step(a, b, note.start) and self.step(b, c, note.start):
|
|
return 'neighbor' if a == c else 'passing' if (b - a) * (c - b) > 0 else ''
|
|
return ''
|
|
def enclosure_events(self, notes, enclosures, until=None):
|
|
"""Validate whole gestures before granting any exception; never trust labels."""
|
|
notes = generator.solo.types.sounding_notes(notes)
|
|
events = {}
|
|
for note in notes:
|
|
if note.event_id:
|
|
if note.event_id in events:
|
|
return {}, 'duplicate sounding event ID in enclosure context'
|
|
events[note.event_id] = note
|
|
accepted, intervals = {}, []
|
|
for contract in enclosures:
|
|
upper, lower, target = contract.upper, contract.lower, contract.target
|
|
if len({n.event_id for n in (upper, lower, target)}) != 3 or not all(n.event_id for n in (upper, lower, target)):
|
|
return {}, contract.id + ': enclosure requires three distinct event IDs'
|
|
for expected in (upper, lower, target):
|
|
actual = events.get(expected.event_id)
|
|
if not actual or (actual.start, actual.end, actual.pitch) != (expected.start, expected.end, expected.pitch):
|
|
return {}, contract.id + ': missing or changed enclosure event ' + expected.event_id
|
|
approach_start = contract.approach_start if contract.approach_start is not None else upper.start
|
|
if not upper.start <= approach_start or upper.end-approach_start != fractions.Fraction(1,2) or lower.end-lower.start != fractions.Fraction(1,2) or upper.end != lower.start or lower.end != target.start:
|
|
return {}, contract.id + ': enclosure must be two contiguous eighths immediately before its target'
|
|
if not 0 < upper.pitch.number()-target.pitch.number() <= 3 or lower.pitch.number() != target.pitch.number()-1:
|
|
return {}, contract.id + ': enclosure does not surround the exact target register'
|
|
if any(n.start < target.start and n.end > upper.start and n.event_id not in (upper.event_id,lower.event_id) for n in notes):
|
|
return {}, contract.id + ': interrupted or overlapping enclosure'
|
|
if any(a < target.start and b > upper.start for a,b in intervals):
|
|
return {}, contract.id + ': overlapping enclosure contracts'
|
|
if until is not None and target.end > until:
|
|
return {}, contract.id + ': enclosure resolution exceeds the available arrangement'
|
|
span = self.at(target.start)
|
|
tail = contract.protected_tail
|
|
if not span or span.chord != contract.chord or target.pitch.number()%12 not in self.members(target.start,target.end):
|
|
return {}, contract.id + ': target is not a chord member throughout its hold'
|
|
if contract.target_role not in ('intermediate','terminal') or tail.pitch != target.pitch.number() or not target.start <= tail.start < tail.end <= target.end:
|
|
return {}, contract.id + ': invalid protected destination tail'
|
|
for note in (upper, lower, target):
|
|
covered=note.start
|
|
for region,start,end in self.segments(note):
|
|
if start != covered or not region.scale:
|
|
return {}, contract.id + ': missing harmony in enclosure'
|
|
if note is not lower and note.pitch.number()%12 not in region.scale.pitches:
|
|
return {}, contract.id + ': upper approach or target outside mapped collection'
|
|
covered=end
|
|
if covered != note.end:
|
|
return {}, contract.id + ': incomplete harmony in enclosure'
|
|
# The upper neighbor is the nearest common mapped pitch above T.
|
|
allowed=generator.solo.types.permitted(self.choices,upper.start,upper.end)
|
|
if any(p%12 in allowed for p in range(target.pitch.number()+1,upper.pitch.number())):
|
|
return {}, contract.id + ': upper approach is not the nearest mapped neighbor'
|
|
accepted[lower.event_id]=contract
|
|
accepted[upper.event_id]=contract
|
|
intervals.append((upper.start,target.start))
|
|
return accepted, ''
|
|
|
|
def evaluate(self, notes, treatments=(), *, until=None, complete=False, enclosures=(), expressions=()):
|
|
notes = generator.solo.types.sounding_notes(notes)
|
|
from generator.solo.expressions import validate_frontier
|
|
explicit_frontier=until is not None
|
|
until = until if until is not None else max((n.end for n in notes), default=fractions.Fraction())
|
|
permissions, expression_pending, expression_error = validate_frontier(notes,expressions,self,until,complete or not explicit_frontier)
|
|
if expression_error:return Result(False,0,error=expression_error)
|
|
ornaments, error = self.enclosure_events(notes, enclosures, until) if enclosures else ({}, '')
|
|
if error:
|
|
return Result(False, 0, error=error)
|
|
pending, valid_contracts = list(expression_pending), []
|
|
for treatment in treatments:
|
|
if treatment.start >= until:
|
|
continue
|
|
if treatment.kind == 'color':
|
|
spans = [s for s in self.spans if s.start < treatment.end and s.end > treatment.start]
|
|
if not spans or any(treatment.pitch % 12 not in s.roles.basic | s.roles.specified for s in spans):
|
|
return Result(False, 0, error=f'{treatment.id}: color is not specified by the overlapped harmony')
|
|
valid_contracts.append(treatment)
|
|
continue
|
|
if treatment.kind != 'suspension' or treatment.resolution is None or treatment.deadline is None:
|
|
return Result(False, 0, error=f'{treatment.id}: incomplete harmonic treatment')
|
|
held = next((n for n in notes if n.pitch.number() == treatment.pitch and n.start <= treatment.start - treatment.preparation and n.end > treatment.start), None)
|
|
if held is None:
|
|
return Result(False, 0, error=f'{treatment.id}: suspension lacks sounding preparation')
|
|
if treatment.pitch % 12 not in self.members(treatment.start - treatment.preparation, treatment.start):
|
|
return Result(False, 0, error=f'{treatment.id}: preparation is not a chord member')
|
|
resolution = next((n for n in notes if n.start == held.end and n.pitch.number() == treatment.resolution and n.start <= treatment.deadline), None)
|
|
if resolution:
|
|
span = self.at(resolution.start)
|
|
if not span or treatment.resolution % 12 not in span.roles.basic or not self.step(treatment.pitch, treatment.resolution, resolution.start):
|
|
return Result(False, 0, error=f'{treatment.id}: resolution is not an adjacent chord member')
|
|
elif complete or until > treatment.deadline or any(n.start >= held.end for n in notes if n is not held):
|
|
return Result(False, 0, error=f'{treatment.id}: unresolved suspension at deadline {treatment.deadline}')
|
|
else:
|
|
pending.append(treatment)
|
|
valid_contracts.append(treatment)
|
|
exposed, sounding = 0.0, 0.0
|
|
for index, note in enumerate(notes):
|
|
number = note.pitch.number()
|
|
duration = note.end - note.start
|
|
sounding += float(duration)
|
|
covered = note.start
|
|
for span, start, end in self.segments(note):
|
|
contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end), None)
|
|
enclosure = ornaments.get(note.event_id)
|
|
expression = permissions.get(note.event_id)
|
|
exception = enclosure is not None and note.event_id == enclosure.lower.event_id or expression is not None and (expression.kind != 'anticipation' or end <= expression.boundary)
|
|
if start > covered or not span.scale or number % 12 not in span.scale.pitches and not exception:
|
|
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony')
|
|
covered = end
|
|
function = self.function(number, span)
|
|
brief_enclosure = enclosure and (exception or enclosure.approach_start is None or enclosure.approach_start == note.start)
|
|
if function == 'basic' or note.role == 'quote' or brief_enclosure or expression:
|
|
continue
|
|
if duration >= generator.solo.policy.DEFAULT.long_note and contract is None:
|
|
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end} over {span.chord}: sustained {function} without a planned treatment')
|
|
connected = duration < generator.solo.policy.DEFAULT.stopping_note and self.connected_motion(notes, index)
|
|
cost = 0 if contract or connected else 1 if duration >= generator.solo.policy.DEFAULT.stopping_note else 0.25
|
|
exposed += float(end - start) * cost
|
|
if covered < note.end:
|
|
return Result(False, 0, error=f'{note.pitch.label()} at {covered}: missing harmonic coverage')
|
|
return Result(True, exposed / max(sounding, 0.001), tuple(pending))
|
|
def describe(self, notes, treatments=(), enclosures=(), expressions=()):
|
|
notes = generator.solo.types.sounding_notes(notes)
|
|
from generator.solo.expressions import validate
|
|
permissions, error = validate(notes, expressions, self)
|
|
if error:
|
|
raise ValueError(error)
|
|
ornaments, error = self.enclosure_events(notes,enclosures) if enclosures else ({}, '')
|
|
if error:
|
|
raise ValueError(error)
|
|
result = []
|
|
for index, note in enumerate(notes):
|
|
for span, start, end in self.segments(note):
|
|
contracts = [t for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end]
|
|
expression = permissions.get(note.event_id)
|
|
result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': self.connected_motion(notes, index), 'enclosure': ornaments[note.event_id].id if note.event_id in ornaments else '', 'expression': expression.kind if expression else '', 'expression_rule': expression.rule if expression else '', 'scale_exception': bool(note.event_id in ornaments or expression) and self.function(note.pitch.number(), span) == 'outside_collection', 'protected': note.role == 'quote', 'treatments': [t.id for t in contracts]})
|
|
return result
|
|
|
|
def anchor_note(anchor, notes, *, until=None):
|
|
for note in notes:
|
|
if note.pitch.number() != anchor.pitch.number():
|
|
continue
|
|
arrival = note.start if anchor.onset_required else max(note.start, anchor.earliest)
|
|
needed = max(arrival + anchor.duration, anchor.hold_until or arrival)
|
|
if anchor.earliest <= arrival <= anchor.latest and note.start <= arrival < note.end and note.end >= min(needed, until if until is not None else needed):
|
|
return note
|
|
return None
|
|
|
|
def anchors_valid(anchors, notes, until, *, complete=False, closure='open'):
|
|
for anchor in anchors:
|
|
if anchor.earliest >= until and not complete:
|
|
continue
|
|
note = anchor_note(anchor, notes, until=None if complete else until)
|
|
if note is None and (complete or until > anchor.latest):
|
|
return False, f'{anchor.id}: required {anchor.role} arrival {anchor.earliest}-{anchor.latest} on {anchor.pitch.label()} not completed'
|
|
if note and anchor.role == 'terminal' and closure == 'closed' and any(n.start > note.start for n in notes):
|
|
return False, f'{anchor.id}: terminal anchor touched then abandoned'
|
|
return True, ''
|