@@ -37,8 +37,9 @@ def attacks(document: dict) -> tuple[generator.solo.types.Note, ...]:
|
||||
return tuple(sorted(result, key=lambda n: (n.start, -n.pitch.number(), n.sources)))
|
||||
|
||||
def analyze(document):
|
||||
measures=generator.score.source_staff(document)['measures']
|
||||
boundaries=[fractions.Fraction()]
|
||||
for m in measures:boundaries.append(boundaries[-1]+generator.model.fraction(m['duration']))
|
||||
tempo=next((generator.model.fraction(t['bpm'])*generator.model.written_length(t['written']) for m in measures for t in m.get('tempos',[]) if 'bpm' in t),fractions.Fraction(120))
|
||||
return Analysis(tuple(boundaries),tempo,attacks(document),boundaries[generator.score.body_start_measure_index(measures)])
|
||||
measures = generator.score.source_staff(document)['measures']
|
||||
boundaries = [fractions.Fraction()]
|
||||
for m in measures:
|
||||
boundaries.append(boundaries[-1] + generator.model.fraction(m['duration']))
|
||||
tempo = next((generator.model.fraction(t['bpm']) * generator.model.written_length(t['written']) for m in measures for t in m.get('tempos', []) if 'bpm' in t), fractions.Fraction(120))
|
||||
return Analysis(tuple(boundaries), tempo, attacks(document), boundaries[generator.score.body_start_measure_index(measures)])
|
||||
|
||||
+77
-58
@@ -8,71 +8,90 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fractions import Fraction as F
|
||||
from generator import musicxml,parts,training_io,harmonic_time
|
||||
from generator.solo import analysis,types,placement,profiles,harmonic
|
||||
import pathlib
|
||||
import fractions
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.training_io
|
||||
import generator.harmonic_time
|
||||
import generator.solo.analysis
|
||||
import generator.solo.types
|
||||
import generator.solo.placement
|
||||
import generator.solo.profiles
|
||||
import generator.solo.harmonic
|
||||
import generator.production
|
||||
|
||||
|
||||
def sounding(evidence,offset=F()):
|
||||
result=[]
|
||||
for i,event in enumerate(evidence['sounding_events']):
|
||||
p=event['pitch'];number=F(event['sounding_midi'])
|
||||
if number.denominator!=1:raise ValueError('Microtonal guitar solo is unsupported')
|
||||
pitch=types.Pitch(p['step'].lower(),F(p['alter']),p['octave']+(int(number)-int(F(event['encoded_midi'])))//12)
|
||||
result.append(types.Note(F(event['start'])-offset,F(event['end'])-offset,pitch,role='authored',event_id=f'authored_{i}'))
|
||||
def sounding(evidence, offset=fractions.Fraction()):
|
||||
result = []
|
||||
for i, event in enumerate(evidence['sounding_events']):
|
||||
p = event['pitch']
|
||||
number = fractions.Fraction(event['sounding_midi'])
|
||||
if number.denominator != 1:
|
||||
raise ValueError('Microtonal guitar solo is unsupported')
|
||||
pitch = generator.solo.types.Pitch(p['step'].lower(), fractions.Fraction(p['alter']), p['octave'] + (int(number) - int(fractions.Fraction(event['encoded_midi']))) // 12)
|
||||
result.append(generator.solo.types.Note(fractions.Fraction(event['start']) - offset, fractions.Fraction(event['end']) - offset, pitch, role='authored', event_id=f'authored_{i}'))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def validate_evidence(document,evidence):
|
||||
def validate_evidence(document, evidence):
|
||||
"""Training uses the same source-form harmonic context as solo playback."""
|
||||
h=harmonic.Harmony(harmonic_time.solo_source(document['staves'][0]))
|
||||
checked=h.evaluate(sounding(evidence),complete=True)
|
||||
if not checked.valid:raise ValueError('Unsupported harmonic relationship in authored solo: '+checked.error)
|
||||
h = generator.solo.harmonic.Harmony(generator.harmonic_time.solo_source(document['staves'][0]))
|
||||
checked = h.evaluate(sounding(evidence), complete=True)
|
||||
if not checked.valid:
|
||||
raise ValueError('Unsupported harmonic relationship in authored solo: ' + checked.error)
|
||||
return checked
|
||||
|
||||
|
||||
def lookup(source,role,data_dir):
|
||||
from generator.production import SourceAnalysis
|
||||
root=Path(data_dir);manifest=root/'training/manifest.json'
|
||||
if not manifest.exists():return None
|
||||
value=json.loads(manifest.read_text())
|
||||
if value.get('version')!=1:raise ValueError('Unsupported training manifest')
|
||||
matches=[]
|
||||
def lookup(source, role, data_dir):
|
||||
root = pathlib.Path(data_dir)
|
||||
manifest = root / 'training/manifest.json'
|
||||
if not manifest.exists():
|
||||
return None
|
||||
value = json.loads(manifest.read_text())
|
||||
if value.get('version') != 1:
|
||||
raise ValueError('Unsupported training manifest')
|
||||
matches = []
|
||||
for entry in value['examples']:
|
||||
if entry['slot']!=(1 if role=='acoustic' else 2):continue
|
||||
path=(manifest.parent/entry['source']).resolve()
|
||||
if not path.is_relative_to((root/'songs').resolve()):raise ValueError('Registered sources must be in data/songs')
|
||||
doc=parts.prepare_document(musicxml.load_musicxml(path))
|
||||
if SourceAnalysis.read(doc).musical_key==source.musical_key:
|
||||
target=(manifest.parent/entry['solo']).resolve()
|
||||
if not target.is_relative_to(manifest.parent.resolve()):raise ValueError('Registered solo must be inside data/training')
|
||||
evidence=training_io.read(target,role,pitch_convention=entry['pitch_convention'])
|
||||
training_io.validate_harmony(doc,evidence,entry['id'])
|
||||
matches.append({**entry,'path':target,'sha256':hashlib.sha256(target.read_bytes()).hexdigest()})
|
||||
if len(matches)>1:raise ValueError('Multiple authored solos registered for the same source music and role')
|
||||
if entry['slot'] != (1 if role == 'acoustic' else 2):
|
||||
continue
|
||||
path = (manifest.parent / entry['source']).resolve()
|
||||
if not path.is_relative_to((root / 'songs').resolve()):
|
||||
raise ValueError('Registered sources must be in data/songs')
|
||||
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
|
||||
if generator.production.SourceAnalysis.read(doc).musical_key == source.musical_key:
|
||||
target = (manifest.parent / entry['solo']).resolve()
|
||||
if not target.is_relative_to(manifest.parent.resolve()):
|
||||
raise ValueError('Registered solo must be inside data/training')
|
||||
evidence = generator.training_io.read(target, role, pitch_convention=entry['pitch_convention'])
|
||||
generator.training_io.validate_harmony(doc, evidence, entry['id'])
|
||||
matches.append({**entry, 'path': target, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
|
||||
if len(matches) > 1:
|
||||
raise ValueError('Multiple authored solos registered for the same source music and role')
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def realize(entry,source_analysis,measures,part,start,end,initial=None):
|
||||
evidence=training_io.read(entry['path'],part,pitch_convention=entry['pitch_convention'])
|
||||
if evidence['discrepancies']:raise ValueError(entry['id']+': invalid authored timing/ties: '+str(evidence['discrepancies']))
|
||||
expected=[(a,b-a) for a,b in zip(source_analysis.boundaries,source_analysis.boundaries[1:])]
|
||||
if [(F(m['start']),F(m['duration'])) for m in evidence['measures']]!=expected:raise ValueError(entry['id']+': source/solo forms differ; register and retrain the correct pair')
|
||||
notes=sounding(evidence,source_analysis.body_offset)
|
||||
def realize(entry, source_analysis, measures, part, start, end, initial=None):
|
||||
evidence = generator.training_io.read(entry['path'], part, pitch_convention=entry['pitch_convention'])
|
||||
if evidence['discrepancies']:
|
||||
raise ValueError(entry['id'] + ': invalid authored timing/ties: ' + str(evidence['discrepancies']))
|
||||
expected = [(a, b - a) for a, b in zip(source_analysis.boundaries, source_analysis.boundaries[1:])]
|
||||
if [(fractions.Fraction(m['start']), fractions.Fraction(m['duration'])) for m in evidence['measures']] != expected:
|
||||
raise ValueError(entry['id'] + ': source/solo forms differ; register and retrain the correct pair')
|
||||
notes = sounding(evidence, source_analysis.body_offset)
|
||||
for n in notes:
|
||||
if n.start<start or n.end>end:raise ValueError(f'{entry["id"]}: authored event {n.start}–{n.end} conflicts with arrangement window {start}–{end}; no clipping applied')
|
||||
profile=profiles.for_part(part,source_analysis)
|
||||
illegal=next((n for n in notes if not placement.positions(n.pitch.number(),profile)),None)
|
||||
if illegal:raise ValueError(f'{entry["id"]}: {illegal.pitch.label()} at {illegal.start} is outside strings 1–6/frets 1–12; authored solo left unchanged')
|
||||
placed=placement.place(tuple(notes),source_analysis.tempo,profile=profile)
|
||||
if placed is None:raise ValueError(entry['id']+': no legal complete guitar placement')
|
||||
spans=tuple(dataclasses.replace(s,start=s.start+start,end=s.end+start) for s in harmonic_time.read({'measures':measures},initial=initial))
|
||||
checked=harmonic.Harmony(spans).evaluate(placed[1],complete=True)
|
||||
if not checked.valid:raise ValueError(entry['id']+': '+checked.error)
|
||||
phrase=types.Phrase('authored',start,end,placed[1],'authored','authored','handoff',theme=entry['id'],expressions=checked.contracts)
|
||||
return types.Solo(part,spans,(phrase,),source_analysis.tempo,end,
|
||||
{'construction':'authored_repertoire','authored_id':entry['id'],'authored_sha256':entry['sha256'],
|
||||
'pitch_convention':entry['pitch_convention'],'exact_sounding_events':True,
|
||||
'note':'Authored music is preserved and passes automatic contextual harmonic validation.',
|
||||
'annotations':evidence['annotations']},profile,start)
|
||||
if n.start < start or n.end > end:
|
||||
raise ValueError(f'{entry["id"]}: authored event {n.start}–{n.end} conflicts with arrangement window {start}–{end}; no clipping applied')
|
||||
profile = generator.solo.profiles.for_part(part, source_analysis)
|
||||
illegal = next((n for n in notes if not generator.solo.placement.positions(n.pitch.number(), profile)), None)
|
||||
if illegal:
|
||||
raise ValueError(f'{entry["id"]}: {illegal.pitch.label()} at {illegal.start} is outside strings 1–6/frets 1–12; authored solo left unchanged')
|
||||
placed = generator.solo.placement.place(tuple(notes), source_analysis.tempo, profile=profile)
|
||||
if placed is None:
|
||||
raise ValueError(entry['id'] + ': no legal complete guitar placement')
|
||||
spans = tuple(dataclasses.replace(s, start=s.start + start, end=s.end + start) for s in generator.harmonic_time.read({'measures': measures}, initial=initial))
|
||||
checked = generator.solo.harmonic.Harmony(spans).evaluate(placed[1], complete=True)
|
||||
if not checked.valid:
|
||||
raise ValueError(entry['id'] + ': ' + checked.error)
|
||||
phrase = generator.solo.types.Phrase('authored', start, end, placed[1], 'authored', 'authored', 'handoff', theme=entry['id'], expressions=checked.contracts)
|
||||
return generator.solo.types.Solo(part, spans, (phrase,), source_analysis.tempo, end,
|
||||
{'construction': 'authored_repertoire', 'authored_id': entry['id'], 'authored_sha256': entry['sha256'],
|
||||
'pitch_convention': entry['pitch_convention'], 'exact_sounding_events': True,
|
||||
'note': 'Authored music is preserved and passes automatic contextual harmonic validation.',
|
||||
'annotations': evidence['annotations']}, profile, start)
|
||||
|
||||
+61
-46
@@ -1,57 +1,72 @@
|
||||
"""The sole production solo composer: frozen learned model plus music validity."""
|
||||
import dataclasses
|
||||
import time
|
||||
from fractions import Fraction as F
|
||||
from pathlib import Path
|
||||
from generator import harmonic_time, vocabulary
|
||||
from generator.solo import types, profiles, harmonic, placement, learned, decoder, groups
|
||||
import fractions
|
||||
import pathlib
|
||||
import generator.harmonic_time
|
||||
import generator.vocabulary
|
||||
import generator.solo.types
|
||||
import generator.solo.profiles
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.placement
|
||||
import generator.solo.learned
|
||||
import generator.solo.decoder
|
||||
import generator.solo.groups
|
||||
|
||||
|
||||
def compose(analysis,measures,part,end,*,learned_model=None,initial=None,expressions_enabled=True,start=F()):
|
||||
began=time.monotonic()
|
||||
spans=tuple(dataclasses.replace(s,start=s.start+start,end=s.end+start) for s in harmonic_time.read({'measures':measures},initial=initial))
|
||||
profile=profiles.for_part(part,analysis);harmony=harmonic.Harmony(spans,profile)
|
||||
model=learned_model or learned.Model.load(Path(__file__).resolve().parents[2]/'data/models/solo.json')
|
||||
settings=vocabulary.role(part+'_solo')
|
||||
builder=decoder.Decoder(analysis,harmony,model,part,start,end,expressions_enabled=expressions_enabled,
|
||||
attempts=settings.get('attempts',4),seed=settings.get('choice_seed',''))
|
||||
selected=builder.compose();decoded=time.monotonic()
|
||||
placed=placement.place(selected.notes,analysis.tempo,profile=profile)
|
||||
if placed is None:raise decoder.SearchExhausted('learned solo has no complete legal guitar path')
|
||||
def compose(analysis, measures, part, end, *, learned_model=None, initial=None, expressions_enabled=True, start=fractions.Fraction()):
|
||||
began = time.monotonic()
|
||||
spans = tuple(dataclasses.replace(s, start=s.start + start, end=s.end + start) for s in generator.harmonic_time.read({'measures': measures}, initial=initial))
|
||||
profile = generator.solo.profiles.for_part(part, analysis)
|
||||
harmony = generator.solo.harmonic.Harmony(spans, profile)
|
||||
model = learned_model or generator.solo.learned.Model.load(pathlib.Path(__file__).resolve().parents[2] / 'data/models/solo.json')
|
||||
settings = generator.vocabulary.role(part + '_solo')
|
||||
builder = generator.solo.decoder.Decoder(analysis, harmony, model, part, start, end, expressions_enabled=expressions_enabled, attempts=settings.get('attempts', 4), seed=settings.get('choice_seed', ''))
|
||||
selected = builder.compose()
|
||||
decoded = time.monotonic()
|
||||
placed = generator.solo.placement.place(selected.notes, analysis.tempo, profile=profile)
|
||||
if placed is None:
|
||||
raise generator.solo.decoder.SearchExhausted('learned solo has no complete legal guitar path')
|
||||
# Phrase labels describe heard rests/arrivals. They do not impose a new
|
||||
# endpoint or reset the learned history at barlines or arbitrary windows.
|
||||
notes=placed[1];ends=[n.end for i,n in enumerate(notes[:-1]) if notes[i+1].start-n.end>=1]
|
||||
boundaries=[start,*[t for t in ends if start<t<end],end];phrases=[]
|
||||
home=tuple(n for n in notes if n.start<(ends[0] if ends else min(end,start+8)))
|
||||
for i,(a,b) in enumerate(zip(boundaries,boundaries[1:])):
|
||||
ns=tuple(n for n in notes if a<=n.start<b)
|
||||
cs=tuple(c for c in selected.contracts if a<=c.target.start<b)
|
||||
ending='handoff' if b==end else 'settle'
|
||||
phrases.append(types.Phrase(f'learned_{i+1}',a,b,ns,'learned_sequence','learned',ending,
|
||||
expressions=cs,groups=groups.describe(tuple((n.start,n.end) for n in ns)),
|
||||
theme='introduced' if i==0 else 'conditional_history',identity=home if i==0 else ()))
|
||||
result=types.Solo(part,spans,tuple(phrases),analysis.tempo,end,
|
||||
{'construction':'learned_joint_events','version':learned.VERSION,'decode_seconds':decoded-began,
|
||||
'placement_seconds':time.monotonic()-decoded,'selection_total':selected.cost,
|
||||
'objective':'seeded sampling from validated learned joint-event likelihoods; score is diagnostic, not a complete-solo ranking',
|
||||
'budget':{'attempts':builder.attempts},
|
||||
'counts':dict(builder.counts),'frontiers':builder.trace,'completed_candidates':[
|
||||
{'rank':i+1,'cost':s.cost,'events':[(str(a),str(b),p) for a,b,p in s.events]} for i,s in enumerate(builder.completed)],
|
||||
'expressions_enabled':expressions_enabled},profile,start)
|
||||
validate(result);return result
|
||||
|
||||
notes = placed[1]
|
||||
ends = [n.end for i, n in enumerate(notes[:-1]) if notes[i + 1].start - n.end >= 1]
|
||||
boundaries = [start, *[t for t in ends if start < t < end], end]
|
||||
phrases = []
|
||||
home = tuple(n for n in notes if n.start < (ends[0] if ends else min(end, start + 8)))
|
||||
for i, (a, b) in enumerate(zip(boundaries, boundaries[1:])):
|
||||
ns = tuple(n for n in notes if a <= n.start < b)
|
||||
cs = tuple(c for c in selected.contracts if a <= c.target.start < b)
|
||||
ending = 'handoff' if b == end else 'settle'
|
||||
phrases.append(generator.solo.types.Phrase(f'learned_{i + 1}', a, b, ns, 'learned_sequence', 'learned', ending,
|
||||
expressions=cs, groups=generator.solo.groups.describe(tuple((n.start, n.end) for n in ns)),
|
||||
theme='introduced' if i == 0 else 'conditional_history', identity=home if i == 0 else ()))
|
||||
result = generator.solo.types.Solo(part, spans, tuple(phrases), analysis.tempo, end,
|
||||
{'construction': 'learned_joint_events', 'version': generator.solo.learned.VERSION, 'decode_seconds': decoded - began,
|
||||
'placement_seconds': time.monotonic() - decoded, 'selection_total': selected.cost,
|
||||
'objective': 'seeded sampling from validated learned joint-event likelihoods; score is diagnostic, not a complete-solo ranking',
|
||||
'budget': {'attempts': builder.attempts},
|
||||
'counts': dict(builder.counts), 'frontiers': builder.trace, 'completed_candidates': [
|
||||
{'rank': i + 1, 'cost': s.cost, 'events': [(str(a), str(b), p) for a, b, p in s.events]} for i, s in enumerate(builder.completed)],
|
||||
'expressions_enabled': expressions_enabled}, profile, start)
|
||||
validate(result)
|
||||
return result
|
||||
|
||||
def validate(solo):
|
||||
previous=None
|
||||
previous = None
|
||||
for n in solo.notes():
|
||||
if n.start<solo.start or n.end>solo.end or n.end<=n.start or previous and previous.end>n.start:raise ValueError('overlapping/out-of-bounds sounding event')
|
||||
if n.position not in placement.positions(n.pitch.number(),solo.profile):raise ValueError('invalid guitar placement')
|
||||
previous=n
|
||||
checked=harmonic.Harmony(solo.harmony).evaluate(solo.notes(),until=solo.end,complete=True,
|
||||
if n.start < solo.start or n.end > solo.end or n.end <= n.start or previous and previous.end > n.start:
|
||||
raise ValueError('overlapping/out-of-bounds sounding event')
|
||||
if n.position not in generator.solo.placement.positions(n.pitch.number(), solo.profile):
|
||||
raise ValueError('invalid guitar placement')
|
||||
previous = n
|
||||
checked = generator.solo.harmonic.Harmony(solo.harmony).evaluate(solo.notes(), until=solo.end, complete=True,
|
||||
expressions=tuple(c for p in solo.phrases for c in p.expressions))
|
||||
if not checked.valid:raise ValueError(checked.error)
|
||||
cursor=solo.start
|
||||
if not checked.valid:
|
||||
raise ValueError(checked.error)
|
||||
cursor = solo.start
|
||||
for p in solo.phrases:
|
||||
if p.start!=cursor or p.end<=p.start:raise ValueError('phrase coverage gap or overlap')
|
||||
cursor=p.end
|
||||
if cursor!=solo.end:raise ValueError('incomplete solo duration')
|
||||
if p.start != cursor or p.end <= p.start:
|
||||
raise ValueError('phrase coverage gap or overlap')
|
||||
cursor = p.end
|
||||
if cursor != solo.end:
|
||||
raise ValueError('incomplete solo duration')
|
||||
|
||||
+319
-252
@@ -11,297 +11,364 @@ import dataclasses
|
||||
import hashlib
|
||||
import random
|
||||
import math
|
||||
from fractions import Fraction as F
|
||||
from generator import model as music, vocabulary
|
||||
from generator.solo import expressions, groups, learned, types
|
||||
|
||||
|
||||
class SearchExhausted(ValueError):pass
|
||||
import fractions
|
||||
import generator.model
|
||||
import generator.vocabulary
|
||||
import generator.solo.expressions
|
||||
import generator.solo.groups
|
||||
import generator.solo.learned
|
||||
import generator.solo.types
|
||||
import generator.harmony
|
||||
|
||||
class SearchExhausted(ValueError):
|
||||
pass
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class State:
|
||||
events:tuple=()
|
||||
notes:tuple=()
|
||||
contracts:tuple=()
|
||||
history:tuple=()
|
||||
previous:int|None=None
|
||||
cost:float=0.
|
||||
|
||||
|
||||
def event_key(events):return tuple((a,b,-1 if p is None else p) for a,b,p in events)
|
||||
events: tuple = ()
|
||||
notes: tuple = ()
|
||||
contracts: tuple = ()
|
||||
history: tuple = ()
|
||||
previous: int | None = None
|
||||
cost: float = 0.
|
||||
|
||||
def event_key(events): return tuple((a, b, -1 if p is None else p) for a, b, p in events)
|
||||
|
||||
class Decoder:
|
||||
def __init__(self,analysis,harmony,model,role,start,end,*,expressions_enabled=True,attempts=4,seed=''):
|
||||
if type(attempts) is not int or not 1<=attempts<=16:raise ValueError('attempts must be between 1 and 16')
|
||||
self.analysis,self.harmony,self.model,self.role=analysis,harmony,model,role
|
||||
self.start,self.end=start,end
|
||||
self.attempts=attempts
|
||||
source=tuple((str(n.start),str(n.end),n.pitch.number()) for n in analysis.melody)
|
||||
self.seed=seed or hashlib.sha256(repr((source,role,learned.VERSION)).encode()).hexdigest()
|
||||
self.expressions_enabled=expressions_enabled
|
||||
self.low,self.high=harmony.profile.register
|
||||
limits=vocabulary.data('roles')['difficulty']
|
||||
self.durations=tuple(d for d in model.durations if 0<d<=4 and d.denominator in (1,2,4) and
|
||||
(d.denominator!=4 or analysis.tempo<=limits['sixteenth_max_bpm']))
|
||||
self.allow_triplets=analysis.tempo<=limits['triplet_max_bpm']
|
||||
self._actions={};self._context={};self._pitches={};self._colors={};self._permissions={};self.trace=[];self.counts=collections.Counter()
|
||||
|
||||
def context(self,time):
|
||||
def __init__(self, analysis, harmony, model, role, start, end, *, expressions_enabled=True, attempts=4, seed=''):
|
||||
if type(attempts) is not int or not 1 <= attempts <= 16:
|
||||
raise ValueError('attempts must be between 1 and 16')
|
||||
self.analysis, self.harmony, self.model, self.role = analysis, harmony, model, role
|
||||
self.start, self.end = start, end
|
||||
self.attempts = attempts
|
||||
source = tuple((str(n.start), str(n.end), n.pitch.number()) for n in analysis.melody)
|
||||
self.seed = seed or hashlib.sha256(repr((source, role, generator.solo.learned.VERSION)).encode()).hexdigest()
|
||||
self.expressions_enabled = expressions_enabled
|
||||
self.low, self.high = harmony.profile.register
|
||||
limits = generator.vocabulary.data('roles')['difficulty']
|
||||
self.durations = tuple(d for d in model.durations if 0 < d <= 4 and d.denominator in (1, 2, 4) and
|
||||
(d.denominator != 4 or analysis.tempo <= limits['sixteenth_max_bpm']))
|
||||
self.allow_triplets = analysis.tempo <= limits['triplet_max_bpm']
|
||||
self._actions = {}
|
||||
self._context = {}
|
||||
self._pitches = {}
|
||||
self._colors = {}
|
||||
self._permissions = {}
|
||||
self.trace = []
|
||||
self.counts = collections.Counter()
|
||||
def context(self, time):
|
||||
if time not in self._context:
|
||||
harmonic,root,future=learned.chord_context(self.harmony,time)
|
||||
self._context[time]=(harmonic,root,learned.phase_at(self.analysis,time),learned.source_feature(self.analysis,time,root),future)
|
||||
harmonic, root, future = generator.solo.learned.chord_context(self.harmony, time)
|
||||
self._context[time] = (harmonic, root, generator.solo.learned.phase_at(self.analysis, time), generator.solo.learned.source_feature(self.analysis, time, root), future)
|
||||
return self._context[time]
|
||||
|
||||
def pitch(self,number,time):
|
||||
span=self.harmony.at(time)
|
||||
identity=(number,span.start if span else None)
|
||||
if identity not in self._pitches:self._pitches[identity]=self._spell(number,time)
|
||||
def pitch(self, number, time):
|
||||
span = self.harmony.at(time)
|
||||
identity = (number, span.start if span else None)
|
||||
if identity not in self._pitches:
|
||||
self._pitches[identity] = self._spell(number, time)
|
||||
return self._pitches[identity]
|
||||
|
||||
def _spell(self,number,time):
|
||||
span=self.harmony.at(time)
|
||||
if span and span.chord!='N.C.':
|
||||
chord=expressions.parsed_chord(span.chord)
|
||||
from generator import harmony as chords
|
||||
for degree,alter in chords.degrees(chord).items():
|
||||
p=expressions.degree_pitch(chord,degree,alter,number)
|
||||
if p.number()==number:return p
|
||||
for letter,alter in zip(music.LETTERS,span.key['alterations']):
|
||||
accidental=music.fraction(alter)
|
||||
if (music.NATURAL[letter]+accidental)%12==number%12:
|
||||
return types.Pitch(letter,accidental,int((number-music.NATURAL[letter]-accidental)//12)-1)
|
||||
names=(('C',0),('D',-1),('D',0),('E',-1),('E',0),('F',0),('F',1),('G',0),('A',-1),('A',0),('B',-1),('B',0))
|
||||
letter,alter=names[number%12]
|
||||
return types.Pitch(letter.lower(),F(alter),number//12-1)
|
||||
|
||||
def note(self,a,b,p,role='learned'):
|
||||
pitch=p if isinstance(p,types.Pitch) else self.pitch(p,a)
|
||||
return types.Note(a,b,pitch,role=role,event_id=f'{a}:{b}:{pitch.number()}')
|
||||
|
||||
def color_bases(self,n):
|
||||
key=(n.start,n.end,n.pitch.number())
|
||||
if key in self._colors:return self._colors[key]
|
||||
bases=[]
|
||||
regions=[(None,n)]
|
||||
regions.extend((s.start,dataclasses.replace(n,start=s.start)) for s in self.harmony.spans
|
||||
if n.start<s.start<n.end and s.start-n.start<=F(vocabulary.data('harmony')['anticipation_max']))
|
||||
for boundary,arrived in regions:
|
||||
exposed=[s for s,_,_ in self.harmony.segments(arrived) if n.pitch.number()%12 not in s.roles.basic|s.roles.specified]
|
||||
if not exposed:continue
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
if arrived.end-arrived.start>F(rule['max_beats']):continue
|
||||
if any(s.chord=='N.C.' or not expressions.eligible_color(rule,s,self.harmony) or
|
||||
expressions.degree_pitch(expressions.parsed_chord(s.chord),rule['degree'],rule['alter'],n.pitch.number()).number()!=n.pitch.number()
|
||||
for s in exposed):continue
|
||||
bases.append((rule,boundary))
|
||||
self._colors[key]=tuple(bases)
|
||||
def _spell(self, number, time):
|
||||
span = self.harmony.at(time)
|
||||
if span and span.chord != 'N.C.':
|
||||
chord = generator.solo.expressions.parsed_chord(span.chord)
|
||||
for degree, alter in generator.harmony.degrees(chord).items():
|
||||
p = generator.solo.expressions.degree_pitch(chord, degree, alter, number)
|
||||
if p.number() == number:
|
||||
return p
|
||||
for letter, alter in zip(generator.model.LETTERS, span.key['alterations']):
|
||||
accidental = generator.model.fraction(alter)
|
||||
if (generator.model.NATURAL[letter] + accidental) % 12 == number % 12:
|
||||
return generator.solo.types.Pitch(letter, accidental, int((number - generator.model.NATURAL[letter] - accidental) // 12) - 1)
|
||||
names = (('C', 0), ('D', -1), ('D', 0), ('E', -1), ('E', 0), ('F', 0), ('F', 1), ('G', 0), ('A', -1), ('A', 0), ('B', -1), ('B', 0))
|
||||
letter, alter = names[number % 12]
|
||||
return generator.solo.types.Pitch(letter.lower(), fractions.Fraction(alter), number // 12 - 1)
|
||||
def note(self, a, b, p, role='learned'):
|
||||
pitch = p if isinstance(p, generator.solo.types.Pitch) else self.pitch(p, a)
|
||||
return generator.solo.types.Note(a, b, pitch, role=role, event_id=f'{a}:{b}:{pitch.number()}')
|
||||
def color_bases(self, n):
|
||||
key = (n.start, n.end, n.pitch.number())
|
||||
if key in self._colors:
|
||||
return self._colors[key]
|
||||
bases = []
|
||||
regions = [(None, n)]
|
||||
regions.extend((s.start, dataclasses.replace(n, start=s.start)) for s in self.harmony.spans
|
||||
if n.start < s.start < n.end and s.start - n.start <= fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']))
|
||||
for boundary, arrived in regions:
|
||||
exposed = [s for s, _, _ in self.harmony.segments(arrived) if n.pitch.number() % 12 not in s.roles.basic | s.roles.specified]
|
||||
if not exposed:
|
||||
continue
|
||||
for rule in generator.vocabulary.data('harmony')['colors']:
|
||||
if arrived.end - arrived.start > fractions.Fraction(rule['max_beats']):
|
||||
continue
|
||||
if any(s.chord == 'N.C.' or not generator.solo.expressions.eligible_color(rule, s, self.harmony) or
|
||||
generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(s.chord), rule['degree'], rule['alter'], n.pitch.number()).number() != n.pitch.number()
|
||||
for s in exposed):
|
||||
continue
|
||||
bases.append((rule, boundary))
|
||||
self._colors[key] = tuple(bases)
|
||||
return self._colors[key]
|
||||
|
||||
def color_contract(self,n,target,rule,boundary):
|
||||
span=self.harmony.at(boundary if boundary is not None else n.start)
|
||||
if not span:return None
|
||||
kind='anticipated_color' if boundary is not None else 'color'
|
||||
return types.ExpressionContract(n.event_id+':'+rule['id']+':'+str(boundary),kind,rule['id'],(n,),target,span.chord,boundary)
|
||||
|
||||
def permission(self,n):
|
||||
key=(n.start,n.end,n.pitch.number())
|
||||
if key not in self._permissions:self._permissions[key]=self._permission(n)
|
||||
def color_contract(self, n, target, rule, boundary):
|
||||
span = self.harmony.at(boundary if boundary is not None else n.start)
|
||||
if not span:
|
||||
return None
|
||||
kind = 'anticipated_color' if boundary is not None else 'color'
|
||||
return generator.solo.types.ExpressionContract(n.event_id + ':' + rule['id'] + ':' + str(boundary), kind, rule['id'], (n,), target, span.chord, boundary)
|
||||
def permission(self, n):
|
||||
key = (n.start, n.end, n.pitch.number())
|
||||
if key not in self._permissions:
|
||||
self._permissions[key] = self._permission(n)
|
||||
return self._permissions[key]
|
||||
|
||||
def _permission(self,n):
|
||||
def _permission(self, n):
|
||||
"""Find justified color/anticipation support, never authorize by label."""
|
||||
# Source spans are contiguous. These are precisely the ordinary-event
|
||||
# branches of Harmony.evaluate, without rebuilding an empty contract map.
|
||||
if self.harmony.at(n.start) and n.end<=self.harmony.spans[-1].end:
|
||||
if n.pitch.number()%12 in self.harmony.members(n.start,n.end):return ()
|
||||
if n.end-n.start<2 and n.pitch.number()%12 in self.harmony.permitted(n.start,n.end):return ()
|
||||
if not self.expressions_enabled:return None
|
||||
span=self.harmony.at(n.start)
|
||||
if not span or span.chord=='N.C.':return None
|
||||
for rule,boundary in self.color_bases(n):
|
||||
if rule.get('resolve'):continue
|
||||
c=self.color_contract(n,n,rule,boundary)
|
||||
if self.harmony.evaluate((n,),complete=True,expressions=(c,),infer=False).valid:return (c,)
|
||||
for arriving in self.harmony.spans:
|
||||
if n.start<arriving.start<n.end and arriving.start-n.start<=F(vocabulary.data('harmony')['anticipation_max']):
|
||||
c=types.ExpressionContract(n.event_id+':anticipation','anticipation','early_chord_tone',(n,),n,arriving.chord,arriving.start)
|
||||
if self.harmony.evaluate((n,),complete=True,expressions=(c,),infer=False).valid:return (c,)
|
||||
return None
|
||||
|
||||
def tails(self,n,target):
|
||||
"""Finite resolution timing: direct, real space, or one valid intervening note."""
|
||||
gap=target.start-n.end
|
||||
if gap<0:return
|
||||
if gap==0:
|
||||
yield ((n.start,n.end,n.pitch.number()),(target.start,target.end,target.pitch.number())),(n,target)
|
||||
return
|
||||
yield ((n.start,n.end,n.pitch.number()),(n.end,target.start,None),(target.start,target.end,target.pitch.number())),(n,target)
|
||||
windows={(n.end,target.start)} if gap<=1 else set()
|
||||
if gap>=1:windows|={(n.end,n.end+F(1,2)),(target.start-F(1,2),target.start)}
|
||||
for a,b in sorted(windows):
|
||||
for number in range(max(self.low,n.pitch.number()-3),min(self.high,n.pitch.number()+3)+1):
|
||||
mid=self.note(a,b,number)
|
||||
if number in (n.pitch.number(),target.pitch.number()) or not self.harmony.ordinary(mid):continue
|
||||
events=[(n.start,n.end,n.pitch.number())]
|
||||
if a>n.end:events.append((n.end,a,None))
|
||||
events.append((a,b,number))
|
||||
if b<target.start:events.append((b,target.start,None))
|
||||
events.append((target.start,target.end,target.pitch.number()))
|
||||
yield tuple(events),(n,mid,target)
|
||||
|
||||
def resolutions(self,n):
|
||||
"""Joint color/delayed-approach actions; the destination is never optional."""
|
||||
requests=[]
|
||||
for rule,boundary in self.color_bases(n):
|
||||
if self.harmony.at(n.start) and n.end <= self.harmony.spans[-1].end:
|
||||
if n.pitch.number() % 12 in self.harmony.members(n.start, n.end):
|
||||
return ()
|
||||
if n.end - n.start < 2 and n.pitch.number() % 12 in self.harmony.permitted(n.start, n.end):
|
||||
return ()
|
||||
if not self.expressions_enabled:
|
||||
return None
|
||||
span = self.harmony.at(n.start)
|
||||
if not span or span.chord == 'N.C.':
|
||||
return None
|
||||
for rule, boundary in self.color_bases(n):
|
||||
if rule.get('resolve'):
|
||||
for gap in (F(),F(1,2),F(1)):
|
||||
if gap<=F(vocabulary.data('harmony')['resolution_gap']):requests.append(('color',rule,boundary,n.end+gap,(-2,-1,1,2)))
|
||||
delay=vocabulary.data('lead_ins')['delayed_approach']
|
||||
if n.end-n.start<=F(delay['max_note']) and n.pitch.number()%12 not in self.harmony.permitted(n.start,n.end):
|
||||
for arrival in map(F,delay['arrivals']):
|
||||
if n.end<=n.start+arrival<=n.start+F(delay['deadline']):requests.append(('delayed',delay,None,n.start+arrival,delay['steps']))
|
||||
for kind,rule,boundary,arrival,steps in requests:
|
||||
continue
|
||||
c = self.color_contract(n, n, rule, boundary)
|
||||
if self.harmony.evaluate((n,), complete=True, expressions=(c,), infer=False).valid:
|
||||
return tuple([c])
|
||||
for arriving in self.harmony.spans:
|
||||
if n.start < arriving.start < n.end and arriving.start - n.start <= fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']):
|
||||
c = generator.solo.types.ExpressionContract(n.event_id + ':anticipation', 'anticipation', 'early_chord_tone', (n,), n, arriving.chord, arriving.start)
|
||||
if self.harmony.evaluate((n,), complete=True, expressions=(c,), infer=False).valid:
|
||||
return tuple([c])
|
||||
return None
|
||||
def tails(self, n, target):
|
||||
"""Finite resolution timing: direct, real space, or one valid intervening note."""
|
||||
gap = target.start - n.end
|
||||
if gap < 0:
|
||||
return
|
||||
if gap == 0:
|
||||
yield ((n.start, n.end, n.pitch.number()), (target.start, target.end, target.pitch.number())), (n, target)
|
||||
return
|
||||
yield ((n.start, n.end, n.pitch.number()), (n.end, target.start, None), (target.start, target.end, target.pitch.number())), (n, target)
|
||||
windows = {(n.end, target.start)} if gap <= 1 else set()
|
||||
if gap >= 1:
|
||||
windows |= {(n.end, n.end + fractions.Fraction(1, 2)), (target.start - fractions.Fraction(1, 2), target.start)}
|
||||
for a, b in sorted(windows):
|
||||
for number in range(max(self.low, n.pitch.number() - 3), min(self.high, n.pitch.number() + 3) + 1):
|
||||
mid = self.note(a, b, number)
|
||||
if number in (n.pitch.number(), target.pitch.number()) or not self.harmony.ordinary(mid):
|
||||
continue
|
||||
events = [(n.start, n.end, n.pitch.number())]
|
||||
if a > n.end:
|
||||
events.append((n.end, a, None))
|
||||
events.append((a, b, number))
|
||||
if b < target.start:
|
||||
events.append((b, target.start, None))
|
||||
events.append((target.start, target.end, target.pitch.number()))
|
||||
yield tuple(events), (n, mid, target)
|
||||
def resolutions(self, n):
|
||||
"""Joint color/delayed-approach actions; the destination is never optional."""
|
||||
requests = []
|
||||
for rule, boundary in self.color_bases(n):
|
||||
if rule.get('resolve'):
|
||||
for gap in (fractions.Fraction(), fractions.Fraction(1, 2), fractions.Fraction(1)):
|
||||
if gap <= fractions.Fraction(generator.vocabulary.data('harmony')['resolution_gap']):
|
||||
requests.append(('color', rule, boundary, n.end + gap, (-2, -1, 1, 2)))
|
||||
delay = generator.vocabulary.data('lead_ins')['delayed_approach']
|
||||
if n.end - n.start <= fractions.Fraction(delay['max_note']) and n.pitch.number() % 12 not in self.harmony.permitted(n.start, n.end):
|
||||
for arrival in map(fractions.Fraction, delay['arrivals']):
|
||||
if n.end <= n.start + arrival <= n.start + fractions.Fraction(delay['deadline']):
|
||||
requests.append(('delayed', delay, None, n.start + arrival, delay['steps']))
|
||||
for kind, rule, boundary, arrival, steps in requests:
|
||||
for delta in steps:
|
||||
number=n.pitch.number()+delta
|
||||
if not self.low<=number<=self.high:continue
|
||||
for hold in (F(1,2),F(1),F(2)):
|
||||
if arrival+hold>self.end or number%12 not in self.harmony.members(arrival,arrival+hold):continue
|
||||
target=self.note(arrival,arrival+hold,number,'destination')
|
||||
c=self.color_contract(n,target,rule,boundary) if kind=='color' else types.ExpressionContract(n.event_id+':delayed','delayed_approach',rule['id'],(n,),target,self.harmony.at(arrival).chord)
|
||||
number = n.pitch.number() + delta
|
||||
if not self.low <= number <= self.high:
|
||||
continue
|
||||
for hold in (fractions.Fraction(1, 2), fractions.Fraction(1), fractions.Fraction(2)):
|
||||
if arrival + hold > self.end or number % 12 not in self.harmony.members(arrival, arrival + hold):
|
||||
continue
|
||||
target = self.note(arrival, arrival + hold, number, 'destination')
|
||||
c = self.color_contract(n, target, rule, boundary) if kind == 'color' else generator.solo.types.ExpressionContract(n.event_id + ':delayed', 'delayed_approach', rule['id'], (n,), target, self.harmony.at(arrival).chord)
|
||||
# Color and delayed-approach contracts permit this intervening
|
||||
# window. Validate the invariant endpoints once; tails() checks
|
||||
# every inserted note's ordinary full-duration permission itself.
|
||||
if not self.harmony.evaluate((n,target),complete=True,expressions=(c,),infer=False).valid:continue
|
||||
for events,ns in self.tails(n,target):
|
||||
if not groups.valid(tuple((a,b) for a,b,_ in events)):continue
|
||||
yield events,ns,(c,)
|
||||
|
||||
def actions(self,time):
|
||||
if time in self._actions:return self._actions[time]
|
||||
result=[]
|
||||
def add(events,notes,contracts=()):result.append((events,notes,contracts))
|
||||
if not self.harmony.evaluate((n, target), complete=True, expressions=(c,), infer=False).valid:
|
||||
continue
|
||||
for events, ns in self.tails(n, target):
|
||||
if not generator.solo.groups.valid(tuple((a, b) for a, b, _ in events)):
|
||||
continue
|
||||
yield events, ns, (c,)
|
||||
def actions(self, time):
|
||||
if time in self._actions:
|
||||
return self._actions[time]
|
||||
result = []
|
||||
def add(events, notes, contracts=()): result.append((events, notes, contracts))
|
||||
for duration in self.durations:
|
||||
end=time+duration
|
||||
if end>self.end:continue
|
||||
add(((time,end,None),),())
|
||||
for number in range(self.low,self.high+1):
|
||||
n=self.note(time,end,number);support=self.permission(n)
|
||||
if support is not None:add(((time,end,number),),(n,),support)
|
||||
end = time + duration
|
||||
if end > self.end:
|
||||
continue
|
||||
add(((time, end, None),), ())
|
||||
for number in range(self.low, self.high + 1):
|
||||
n = self.note(time, end, number)
|
||||
support = self.permission(n)
|
||||
if support is not None:
|
||||
add(((time, end, number),), (n,), support)
|
||||
if self.expressions_enabled and not self.harmony.ordinary(n):
|
||||
for events,ns,contracts in self.resolutions(n):add(events,ns,contracts)
|
||||
rule=vocabulary.data('lead_ins')['passing']
|
||||
if duration<=F(rule['max_note']):
|
||||
for events, ns, contracts in self.resolutions(n):
|
||||
add(events, ns, contracts)
|
||||
rule = generator.vocabulary.data('lead_ins')['passing']
|
||||
if duration <= fractions.Fraction(rule['max_note']):
|
||||
for step in rule['steps']:
|
||||
p=number+step
|
||||
if not self.low<=p<=self.high:continue
|
||||
for hold in (F(1,2),F(1),F(2)):
|
||||
if end+hold>self.end:continue
|
||||
target=self.note(end,end+hold,p);extra=self.permission(target)
|
||||
if extra is None or any(c.kind not in ('color','anticipated_color') for c in extra):continue
|
||||
c=types.ExpressionContract(n.event_id+':passing','passing',rule['id'],(n,),target,self.harmony.at(end).chord)
|
||||
add(((time,end,number),(end,end+hold,p)),(n,target),(c,*extra))
|
||||
p = number + step
|
||||
if not self.low <= p <= self.high:
|
||||
continue
|
||||
for hold in (fractions.Fraction(1, 2), fractions.Fraction(1), fractions.Fraction(2)):
|
||||
if end + hold > self.end:
|
||||
continue
|
||||
target = self.note(end, end + hold, p)
|
||||
extra = self.permission(target)
|
||||
if extra is None or any(c.kind not in ('color', 'anticipated_color') for c in extra):
|
||||
continue
|
||||
c = generator.solo.types.ExpressionContract(n.event_id + ':passing', 'passing', rule['id'], (n,), target, self.harmony.at(end).chord)
|
||||
add(((time, end, number), (end, end + hold, p)), (n, target), (c, *extra))
|
||||
if self.expressions_enabled:
|
||||
limits=vocabulary.data('roles')['difficulty']
|
||||
for rule in vocabulary.data('lead_ins')['patterns']:
|
||||
difficulty=rule.get('difficulty')
|
||||
if difficulty and self.analysis.tempo>limits[difficulty+'_max_bpm']:continue
|
||||
limits = generator.vocabulary.data('roles')['difficulty']
|
||||
for rule in generator.vocabulary.data('lead_ins')['patterns']:
|
||||
difficulty = rule.get('difficulty')
|
||||
if difficulty and self.analysis.tempo > limits[difficulty + '_max_bpm']:
|
||||
continue
|
||||
# Connections require a concrete preceding note, checked at expansion.
|
||||
durations=tuple(map(F,rule['durations']));gaps=tuple(map(F,rule.get('internal_gaps',['0']*(len(durations)-1))))
|
||||
target_time=time+sum(durations,F())+sum(gaps,F())+F(rule['gap'])
|
||||
span=self.harmony.at(target_time)
|
||||
if not span or span.chord=='N.C.':continue
|
||||
holds=(F(1,3),) if difficulty=='triplet' else (F(1,2),F(1),F(2))
|
||||
durations = tuple(map(fractions.Fraction, rule['durations']))
|
||||
gaps = tuple(map(fractions.Fraction, rule.get('internal_gaps', ['0'] * (len(durations) - 1))))
|
||||
target_time = time + sum(durations, fractions.Fraction()) + sum(gaps, fractions.Fraction()) + fractions.Fraction(rule['gap'])
|
||||
span = self.harmony.at(target_time)
|
||||
if not span or span.chord == 'N.C.':
|
||||
continue
|
||||
holds = (fractions.Fraction(1, 3),) if difficulty == 'triplet' else (fractions.Fraction(1, 2), fractions.Fraction(1), fractions.Fraction(2))
|
||||
for duration in holds:
|
||||
end=target_time+duration
|
||||
if end>self.end:continue
|
||||
for number in range(self.low,self.high+1):
|
||||
if number%12 not in self.harmony.members(target_time,end):continue
|
||||
target=self.note(target_time,end,number,'destination');cursor=time;notes=[];events=[]
|
||||
for i,(delta,d) in enumerate(zip(rule['semitones'],durations)):
|
||||
n=self.note(cursor,cursor+d,expressions.chromatic(target.pitch,delta),rule['kind'])
|
||||
notes.append(n);events.append((n.start,n.end,n.pitch.number()));cursor=n.end
|
||||
gap=gaps[i] if i<len(gaps) else F(rule['gap'])
|
||||
if gap:events.append((cursor,cursor+gap,None));cursor+=gap
|
||||
if any(not self.low<=n.pitch.number()<=self.high for n in notes):continue
|
||||
if not groups.valid(tuple((a,b) for a,b,p in (*events,(target_time,end,number)))):continue
|
||||
contract=types.ExpressionContract(f'{time}:{number}:{rule["id"]}',rule['kind'],rule['id'],tuple(notes),target,span.chord)
|
||||
ns=(*notes,target)
|
||||
if rule['kind']=='connection':
|
||||
end = target_time + duration
|
||||
if end > self.end:
|
||||
continue
|
||||
for number in range(self.low, self.high + 1):
|
||||
if number % 12 not in self.harmony.members(target_time, end):
|
||||
continue
|
||||
target = self.note(target_time, end, number, 'destination')
|
||||
cursor = time
|
||||
notes = []
|
||||
events = []
|
||||
for i, (delta, d) in enumerate(zip(rule['semitones'], durations)):
|
||||
n = self.note(cursor, cursor + d, generator.solo.expressions.chromatic(target.pitch, delta), rule['kind'])
|
||||
notes.append(n)
|
||||
events.append((n.start, n.end, n.pitch.number()))
|
||||
cursor = n.end
|
||||
gap = gaps[i] if i < len(gaps) else fractions.Fraction(rule['gap'])
|
||||
if gap:
|
||||
events.append((cursor, cursor + gap, None))
|
||||
cursor += gap
|
||||
if any(not self.low <= n.pitch.number() <= self.high for n in notes):
|
||||
continue
|
||||
if not generator.solo.groups.valid(tuple((a, b) for a, b, p in (*events, (target_time, end, number)))):
|
||||
continue
|
||||
contract = generator.solo.types.ExpressionContract(f'{time}:{number}:{rule["id"]}', rule['kind'], rule['id'], tuple(notes), target, span.chord)
|
||||
ns = (*notes, target)
|
||||
if rule['kind'] == 'connection':
|
||||
# Validation waits for the actual incoming event.
|
||||
add((*events,(target_time,end,number)),ns,(contract,))
|
||||
elif self.harmony.evaluate(ns,complete=True,expressions=(contract,),infer=False).valid:
|
||||
add((*events,(target_time,end,number)),ns,(contract,))
|
||||
add((*events, (target_time, end, number)), ns, (contract,))
|
||||
elif self.harmony.evaluate(ns, complete=True, expressions=(contract,), infer=False).valid:
|
||||
add((*events, (target_time, end, number)), ns, (contract,))
|
||||
# Complete ordinary triplet groups; do not leave a third-beat residual.
|
||||
if self.allow_triplets and time+1<=self.end:
|
||||
for number in range(self.low,self.high+1):
|
||||
for direction in (-1,0,1):
|
||||
numbers=[number];cursor=time
|
||||
if self.allow_triplets and time + 1 <= self.end:
|
||||
for number in range(self.low, self.high + 1):
|
||||
for direction in (-1, 0, 1):
|
||||
numbers = [number]
|
||||
cursor = time
|
||||
for _ in range(2):
|
||||
nxt=next((p for p in range(numbers[-1]+direction,numbers[-1]+direction*4,direction or 1)
|
||||
if self.low<=p<=self.high and p%12 in self.harmony.permitted(cursor+F(1,3),cursor+F(2,3))),None) if direction else number
|
||||
if nxt is None:break
|
||||
numbers.append(nxt);cursor+=F(1,3)
|
||||
if len(numbers)!=3:continue
|
||||
ns=tuple(self.note(time+i*F(1,3),time+(i+1)*F(1,3),p) for i,p in enumerate(numbers))
|
||||
if self.harmony.evaluate(ns,complete=True,infer=False).valid:add(tuple((n.start,n.end,n.pitch.number()) for n in ns),ns)
|
||||
self._actions[time]=tuple(result);self.counts['valid_structures']+=len(result)
|
||||
nxt = next((p for p in range(numbers[-1] + direction, numbers[-1] + direction * 4, direction or 1)
|
||||
if self.low <= p <= self.high and p % 12 in self.harmony.permitted(cursor + fractions.Fraction(1, 3), cursor + fractions.Fraction(2, 3))), None) if direction else number
|
||||
if nxt is None:
|
||||
break
|
||||
numbers.append(nxt)
|
||||
cursor += fractions.Fraction(1, 3)
|
||||
if len(numbers) != 3:
|
||||
continue
|
||||
ns = tuple(self.note(time + i * fractions.Fraction(1, 3), time + (i + 1) * fractions.Fraction(1, 3), p) for i, p in enumerate(numbers))
|
||||
if self.harmony.evaluate(ns, complete=True, infer=False).valid:
|
||||
add(tuple((n.start, n.end, n.pitch.number()) for n in ns), ns)
|
||||
self._actions[time] = tuple(result)
|
||||
self.counts['valid_structures'] += len(result)
|
||||
return self._actions[time]
|
||||
|
||||
def extend(self,state,action):
|
||||
events,notes,contracts=action
|
||||
def extend(self, state, action):
|
||||
events, notes, contracts = action
|
||||
# Training tokenization merges contiguous silence. Never manufacture a
|
||||
# rest-to-rest transition absent from that event representation.
|
||||
if state.events and state.events[-1][2] is None and events[0][2] is None:return None
|
||||
if notes and state.previous is not None and abs(notes[0].pitch.number()-state.previous)>12:return None
|
||||
if any(c.kind in ('connection','passing') and c.preparation is None for c in contracts):
|
||||
if not state.notes:return None
|
||||
contracts=tuple(dataclasses.replace(c,preparation=state.notes[-1]) if c.kind in ('connection','passing') and c.preparation is None else c for c in contracts)
|
||||
if state.events and state.events[-1][2] is None and events[0][2] is None:
|
||||
return None
|
||||
if notes and state.previous is not None and abs(notes[0].pitch.number() - state.previous) > 12:
|
||||
return None
|
||||
if any(c.kind in ('connection', 'passing') and c.preparation is None for c in contracts):
|
||||
if not state.notes:
|
||||
return None
|
||||
contracts = tuple(dataclasses.replace(c, preparation=state.notes[-1]) if c.kind in ('connection', 'passing') and c.preparation is None else c for c in contracts)
|
||||
if self.harmony.ordinary(state.notes[-1]):
|
||||
_,error=expressions.validate((state.notes[-1],*notes),contracts,self.harmony)
|
||||
_, error = generator.solo.expressions.validate((state.notes[-1], *notes), contracts, self.harmony)
|
||||
else:
|
||||
_,error=expressions.validate((*state.notes,*notes),(*state.contracts,*contracts),self.harmony)
|
||||
if error:return None
|
||||
history=state.history;previous=state.previous;cost=state.cost
|
||||
for a,b,p in events:
|
||||
harmonic,root,phase,source,_=self.context(a)
|
||||
action=learned.token(b-a,p,previous,root)
|
||||
context=learned.contexts(harmonic,phase,source,history)
|
||||
cost+=self.model.cost(action,context)
|
||||
_, error = generator.solo.expressions.validate((*state.notes, *notes), (*state.contracts, *contracts), self.harmony)
|
||||
if error:
|
||||
return None
|
||||
history = state.history
|
||||
previous = state.previous
|
||||
cost = state.cost
|
||||
for a, b, p in events:
|
||||
harmonic, root, phase, source, _ = self.context(a)
|
||||
action = generator.solo.learned.token(b - a, p, previous, root)
|
||||
context = generator.solo.learned.contexts(harmonic, phase, source, history)
|
||||
cost += self.model.cost(action, context)
|
||||
if p is not None:
|
||||
if previous is None:cost+=self.model.register_cost(p)
|
||||
previous=p
|
||||
history=(*history,action)[-4:]
|
||||
return State((*state.events,*events),(*state.notes,*notes),(*state.contracts,*contracts),history,previous,cost)
|
||||
|
||||
if previous is None:
|
||||
cost += self.model.register_cost(p)
|
||||
previous = p
|
||||
history = (*history, action)[-4:]
|
||||
return State((*state.events, *events), (*state.notes, *notes), (*state.contracts, *contracts), history, previous, cost)
|
||||
def compose(self):
|
||||
for attempt in range(self.attempts):
|
||||
rng=random.Random(self.seed+':'+str(attempt));state=State();time=self.start
|
||||
trace=[]
|
||||
while time<self.end:
|
||||
actions=self.actions(time)
|
||||
options=[s for a in actions if (s:=self.extend(state,a)) is not None]
|
||||
rng = random.Random(self.seed + ':' + str(attempt))
|
||||
state = State()
|
||||
time = self.start
|
||||
trace = []
|
||||
while time < self.end:
|
||||
actions = self.actions(time)
|
||||
options = [s for a in actions if (s := self.extend(state, a)) is not None]
|
||||
# Different operator names cannot multiply the probability of identical
|
||||
# sounded music. Prefer plain permission when no exception is needed.
|
||||
unique={}
|
||||
unique = {}
|
||||
for s in options:
|
||||
signature=event_key(s.events[len(state.events):])
|
||||
if signature not in unique or len(s.contracts)<len(unique[signature].contracts):unique[signature]=s
|
||||
options=sorted(unique.values(),key=lambda s:event_key(s.events[len(state.events):]))
|
||||
self.counts['scored_continuations']+=len(options)
|
||||
if not options:break
|
||||
low=min(s.cost-state.cost for s in options)
|
||||
weights=[math.exp(-(s.cost-state.cost-low)) for s in options]
|
||||
draw=rng.random()*sum(weights);selected=options[-1]
|
||||
for s,w in zip(options,weights):
|
||||
draw-=w
|
||||
if draw<=0:selected=s;break
|
||||
trace.append({'time':str(time),'valid_joint_actions':len(options),
|
||||
'selected_events':[(str(a),str(b),p) for a,b,p in selected.events[len(state.events):]],
|
||||
'negative_log_likelihood':selected.cost-state.cost})
|
||||
state=selected;time=state.events[-1][1]
|
||||
if time==self.end and state.notes:
|
||||
self.trace=trace;self.completed=(state,);self.counts['attempts']=attempt+1
|
||||
signature = event_key(s.events[len(state.events):])
|
||||
if signature not in unique or len(s.contracts) < len(unique[signature].contracts):
|
||||
unique[signature] = s
|
||||
options = sorted(unique.values(), key=lambda s: event_key(s.events[len(state.events):]))
|
||||
self.counts['scored_continuations'] += len(options)
|
||||
if not options:
|
||||
break
|
||||
low = min(s.cost - state.cost for s in options)
|
||||
weights = [math.exp(-(s.cost - state.cost - low)) for s in options]
|
||||
draw = rng.random() * sum(weights)
|
||||
selected = options[-1]
|
||||
for s, w in zip(options, weights):
|
||||
draw -= w
|
||||
if draw <= 0:
|
||||
selected = s
|
||||
break
|
||||
trace.append({'time': str(time), 'valid_joint_actions': len(options),
|
||||
'selected_events': [(str(a), str(b), p) for a, b, p in selected.events[len(state.events):]],
|
||||
'negative_log_likelihood': selected.cost - state.cost})
|
||||
state = selected
|
||||
time = state.events[-1][1]
|
||||
if time == self.end and state.notes:
|
||||
self.trace = trace
|
||||
self.completed = (state,)
|
||||
self.counts['attempts'] = attempt + 1
|
||||
return state
|
||||
raise SearchExhausted(f'{self.role}: no complete non-silent sample within {self.attempts} deterministic attempts; end={self.end}')
|
||||
|
||||
+21
-22
@@ -3,11 +3,12 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fractions import Fraction as F
|
||||
|
||||
from generator import model, musicxml_writer, score
|
||||
import pathlib
|
||||
import fractions
|
||||
|
||||
import generator.model
|
||||
import generator.musicxml_writer
|
||||
import generator.score
|
||||
|
||||
def region(document, part):
|
||||
measures = document['staves'][0]['measures']
|
||||
@@ -15,7 +16,6 @@ def region(document, part):
|
||||
end = next((i for i in range(first + 1, len(measures)) if measures[i].get('section_label')), len(measures))
|
||||
return first, end - first
|
||||
|
||||
|
||||
def excerpt(document, first, count):
|
||||
result = copy.deepcopy(document)
|
||||
staff = result['staves'][0]
|
||||
@@ -23,7 +23,7 @@ def excerpt(document, first, count):
|
||||
staff['measures'] = original[first:first + count]
|
||||
if not staff['measures']:
|
||||
raise ValueError('Empty solo export region')
|
||||
notes = [n for m in staff['measures'] for n in score.measure_notes(m)]
|
||||
notes = [n for m in staff['measures'] for n in generator.score.measure_notes(m)]
|
||||
identities = {p['id'] for n in notes for p in n['pitches']}
|
||||
events = {n['id'] for n in notes}
|
||||
for note in notes:
|
||||
@@ -36,16 +36,15 @@ def excerpt(document, first, count):
|
||||
for note in notes:
|
||||
if 'tuplet_ids' in note:
|
||||
note['tuplet_ids'] = [i for i in note['tuplet_ids'] if i in tuplets]
|
||||
offset = sum((model.fraction(m['duration']) for m in original[:first]), F())
|
||||
end = offset + sum((model.fraction(m['duration']) for m in staff['measures']), F())
|
||||
offset = sum((generator.model.fraction(m['duration']) for m in original[:first]), fractions.Fraction())
|
||||
end = offset + sum((generator.model.fraction(m['duration']) for m in staff['measures']), fractions.Fraction())
|
||||
for key in ('scale_spans', 'octave_shifts'):
|
||||
staff[key] = [{**s, 'start': model.rational(max(offset, model.fraction(s['start'])) - offset),
|
||||
'end': model.rational(min(end, model.fraction(s['end'])) - offset)} for s in staff.get(key, [])
|
||||
if model.fraction(s['start']) < end and model.fraction(s['end']) > offset]
|
||||
model.validate_document(result)
|
||||
staff[key] = [{**s, 'start': generator.model.rational(max(offset, generator.model.fraction(s['start'])) - offset),
|
||||
'end': generator.model.rational(min(end, generator.model.fraction(s['end'])) - offset)} for s in staff.get(key, [])
|
||||
if generator.model.fraction(s['start']) < end and generator.model.fraction(s['end']) > offset]
|
||||
generator.model.validate_document(result)
|
||||
return result
|
||||
|
||||
|
||||
def write_role(result, path):
|
||||
role = result['role']
|
||||
if role not in ('acoustic_solo', 'electric_solo'):
|
||||
@@ -53,25 +52,25 @@ def write_role(result, path):
|
||||
part = role.split('_')[0]
|
||||
document = result['document']
|
||||
first, count = region(document, part)
|
||||
leadin=int(result.get('details',{}).get('allocation',{}).get('lead_in_measures',0))
|
||||
first-=leadin;count+=leadin
|
||||
leadin = int(result.get('details', {}).get('allocation', {}).get('lead_in_measures', 0))
|
||||
first -= leadin
|
||||
count += leadin
|
||||
selected = excerpt(document, first, count)
|
||||
numbers = [m['number'] for m in selected['staves'][0]['measures']]
|
||||
for i, measure in enumerate(selected['staves'][0]['measures'], 1):
|
||||
measure['number'] = str(i)
|
||||
path = Path(path)
|
||||
path = pathlib.Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
musicxml_writer.write_musicxml(path, selected, tablature=True)
|
||||
return {'role': role, 'file': path.name, 'form_start_measure': 1+leadin,
|
||||
generator.musicxml_writer.write_musicxml(path, selected, tablature=True)
|
||||
return {'role': role, 'file': path.name, 'form_start_measure': 1 + leadin,
|
||||
'measure_count': count, 'arrangement_measures': numbers,
|
||||
'arrangement_start_beat': str(sum((model.fraction(m['duration']) for m in document['staves'][0]['measures'][:first]), F())),
|
||||
'arrangement_start_beat': str(sum((generator.model.fraction(m['duration']) for m in document['staves'][0]['measures'][:first]), fractions.Fraction())),
|
||||
'pitch_convention': 'guitar-written; explicit octave transposition -1',
|
||||
'trace': result['trace'], 'phrase_report': result.get('details', {})}
|
||||
|
||||
|
||||
def export_tree(assets, output=None):
|
||||
assets = Path(assets)
|
||||
output = Path(output) if output else assets / 'solo_xml'
|
||||
assets = pathlib.Path(assets)
|
||||
output = pathlib.Path(output) if output else assets / 'solo_xml'
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
entries = {}
|
||||
for path in sorted((assets / 'analysis').glob('*.roles')):
|
||||
|
||||
+247
-181
@@ -2,205 +2,267 @@
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
from generator import harmony as chord_model, model, pitch as spelling, vocabulary
|
||||
from generator.solo import types
|
||||
import fractions
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
import generator.vocabulary
|
||||
import generator.solo.types
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def parsed_chord(symbol):
|
||||
"""Private read-only chord analysis shared by pure expression operations."""
|
||||
return chord_model.parse_symbol(symbol)
|
||||
|
||||
return generator.harmony.parse_symbol(symbol)
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def degree_spelling(letter,accidental,degree,alter):
|
||||
root={'letter':letter,'alter':model.rational(accidental),'octave':4}
|
||||
p=spelling.shift_pitch(root,degree-1,chord_model.degree_semitones(degree,alter))
|
||||
return types.Pitch(p['letter'],model.alteration(p['alter']),p['octave'])
|
||||
|
||||
def degree_spelling(letter, accidental, degree, alter):
|
||||
root = {'letter': letter, 'alter': generator.model.rational(accidental), 'octave': 4}
|
||||
p = generator.pitch.shift_pitch(root, degree - 1, generator.harmony.degree_semitones(degree, alter))
|
||||
return generator.solo.types.Pitch(p['letter'], generator.model.alteration(p['alter']), p['octave'])
|
||||
|
||||
def quality(chord):
|
||||
degrees=chord_model.degrees(chord)
|
||||
if degrees.get(3)==0 and degrees.get(7)==-1:return 'dominant'
|
||||
if degrees.get(3)==-1:
|
||||
if degrees.get(5)==-1:return 'half_diminished' if degrees.get(7)==-1 else 'diminished'
|
||||
degrees = generator.harmony.degrees(chord)
|
||||
if degrees.get(3) == 0 and degrees.get(7) == -1:
|
||||
return 'dominant'
|
||||
if degrees.get(3) == -1:
|
||||
if degrees.get(5) == -1:
|
||||
return 'half_diminished' if degrees.get(7) == -1 else 'diminished'
|
||||
return 'minor'
|
||||
if chord['kind'] in ('suspended-second','suspended-fourth'):return chord['kind']
|
||||
if degrees.get(3)==0 and degrees.get(5)==0:return 'major'
|
||||
if chord['kind'] in ('suspended-second', 'suspended-fourth'):
|
||||
return chord['kind']
|
||||
if degrees.get(3) == 0 and degrees.get(5) == 0:
|
||||
return 'major'
|
||||
return 'other'
|
||||
|
||||
|
||||
def degree_pitch(chord,degree,alter,near):
|
||||
value=degree_spelling(chord['root']['letter'],model.alteration(chord['root']['alter']),degree,alter)
|
||||
return dataclasses.replace(value,octave=value.octave+round((near-value.number())/12))
|
||||
|
||||
def degree_pitch(chord, degree, alter, near):
|
||||
value = degree_spelling(chord['root']['letter'], generator.model.alteration(chord['root']['alter']), degree, alter)
|
||||
return dataclasses.replace(value, octave=value.octave + round((near - value.number()) / 12))
|
||||
|
||||
@functools.lru_cache(maxsize=1024)
|
||||
def chromatic(target,delta):
|
||||
def chromatic(target, delta):
|
||||
# Chromatic neighbors are spelled relative to their exact destination.
|
||||
if delta in (-3,-2,-1,1,2,3):
|
||||
step=(1 if delta>0 else -1)*(2 if abs(delta)==3 else 1)
|
||||
root={'letter':target.letter,'alter':model.rational(target.alter),'octave':target.octave}
|
||||
p=spelling.shift_pitch(root,step,delta)
|
||||
return types.Pitch(p['letter'],model.alteration(p['alter']),p['octave'])
|
||||
if delta in (-3, -2, -1, 1, 2, 3):
|
||||
step = (1 if delta > 0 else -1) * (2 if abs(delta) == 3 else 1)
|
||||
root = {'letter': target.letter, 'alter': generator.model.rational(target.alter), 'octave': target.octave}
|
||||
p = generator.pitch.shift_pitch(root, step, delta)
|
||||
return generator.solo.types.Pitch(p['letter'], generator.model.alteration(p['alter']), p['octave'])
|
||||
raise ValueError('unsupported approach interval')
|
||||
|
||||
|
||||
def eligible_color(rule,span,harmony):
|
||||
def eligible_color(rule, span, harmony):
|
||||
# An explicit no-chord span (or missing harmony) has no optional colors.
|
||||
# In particular, looking ahead for an anticipation must not try to parse
|
||||
# the display label N.C. as a written chord.
|
||||
if not span or span.chord=='N.C.' or not span.chord:return False
|
||||
key=(tuple(rule['qualities']),rule['degree'],rule['alter'],rule.get('function'),span.start,span.end,span.chord)
|
||||
cache=harmony.color_eligibility
|
||||
if key not in cache:cache[key]=_eligible_color(rule,span,harmony)
|
||||
if not span or span.chord == 'N.C.' or not span.chord:
|
||||
return False
|
||||
key = (tuple(rule['qualities']), rule['degree'], rule['alter'], rule.get('function'), span.start, span.end, span.chord)
|
||||
cache = harmony.color_eligibility
|
||||
if key not in cache:
|
||||
cache[key] = _eligible_color(rule, span, harmony)
|
||||
return cache[key]
|
||||
|
||||
|
||||
def _eligible_color(rule,span,harmony):
|
||||
chord=parsed_chord(span.chord)
|
||||
degrees=chord_model.degrees(chord)
|
||||
if quality(chord) not in rule['qualities']:return False
|
||||
degree=rule['degree']; simple=(degree-1)%7+1
|
||||
def _eligible_color(rule, span, harmony):
|
||||
chord = parsed_chord(span.chord)
|
||||
degrees = generator.harmony.degrees(chord)
|
||||
if quality(chord) not in rule['qualities']:
|
||||
return False
|
||||
degree = rule['degree']
|
||||
simple = (degree - 1) % 7 + 1
|
||||
# Explicit alterations and omissions take precedence over optional colors.
|
||||
if any((d['value']-1)%7+1==simple and (d['type']=='subtract' or model.alteration(d['alter'])!=rule['alter']) for d in chord.get('degrees',[])):return False
|
||||
if degree in degrees and degrees[degree]!=rule['alter']:return False
|
||||
following=next((s for s in harmony.spans if s.start>=span.end and s.chord!=span.chord and s.chord!='N.C.'),None)
|
||||
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))%12
|
||||
next_chord=parsed_chord(following.chord) if following else None
|
||||
next_root=int(model.NATURAL[next_chord['root']['letter']]+model.alteration(next_chord['root']['alter']))%12 if next_chord else None
|
||||
function=rule.get('function')
|
||||
if function=='predominant_to_dominant' and not (next_chord and quality(next_chord)=='dominant' and (next_root-root)%12==5):return False
|
||||
if function=='resolving_dominant' and (next_root is None or (root-next_root)%12!=7):return False
|
||||
if function=='tonic_or_predominant':
|
||||
tonic=span.key.get('tonic')
|
||||
if not tonic:return False
|
||||
tonic_pc=int(model.NATURAL[tonic['letter']]+model.alteration(tonic['alter']))%12
|
||||
if root!=tonic_pc and not (next_chord and quality(next_chord)=='dominant' and (next_root-root)%12==5):return False
|
||||
if any((d['value'] - 1) % 7 + 1 == simple and (d['type'] == 'subtract' or generator.model.alteration(d['alter']) != rule['alter']) for d in chord.get('degrees', [])):
|
||||
return False
|
||||
if degree in degrees and degrees[degree] != rule['alter']:
|
||||
return False
|
||||
following = next((s for s in harmony.spans if s.start >= span.end and s.chord != span.chord and s.chord != 'N.C.'), None)
|
||||
root = int(generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter'])) % 12
|
||||
next_chord = parsed_chord(following.chord) if following else None
|
||||
next_root = int(generator.model.NATURAL[next_chord['root']['letter']] + generator.model.alteration(next_chord['root']['alter'])) % 12 if next_chord else None
|
||||
function = rule.get('function')
|
||||
if function == 'predominant_to_dominant' and not (next_chord and quality(next_chord) == 'dominant' and (next_root - root) % 12 == 5):
|
||||
return False
|
||||
if function == 'resolving_dominant' and (next_root is None or (root - next_root) % 12 != 7):
|
||||
return False
|
||||
if function == 'tonic_or_predominant':
|
||||
tonic = span.key.get('tonic')
|
||||
if not tonic:
|
||||
return False
|
||||
tonic_pc = int(generator.model.NATURAL[tonic['letter']] + generator.model.alteration(tonic['alter'])) % 12
|
||||
if root != tonic_pc and not (next_chord and quality(next_chord) == 'dominant' and (next_root - root) % 12 == 5):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate(notes,contracts,harmony):
|
||||
def validate(notes, contracts, harmony):
|
||||
"""Return event permissions only after checking complete actual gestures."""
|
||||
actual={n.event_id:n for n in notes};approved={}
|
||||
actual = {n.event_id: n for n in notes}
|
||||
approved = {}
|
||||
|
||||
def same(expected):
|
||||
n=actual.get(expected.event_id)
|
||||
return n and (n.start,n.end,n.pitch)==(expected.start,expected.end,expected.pitch)
|
||||
n = actual.get(expected.event_id)
|
||||
return n and (n.start, n.end, n.pitch) == (expected.start, expected.end, expected.pitch)
|
||||
|
||||
for c in contracts:
|
||||
if not c.events or not all(same(n) for n in (*c.events,c.target)):
|
||||
return {},c.id+': missing or changed expression event/target'
|
||||
if c.preparation and not same(c.preparation):return {},c.id+': changed preparation'
|
||||
if any(n.event_id in approved for n in c.events):return {},c.id+': overlapping expression permissions'
|
||||
target=c.target; span=harmony.at(c.boundary if c.boundary is not None else c.events[0].start if c.kind=='color' else target.start)
|
||||
if not span or span.chord=='N.C.' or span.chord!=c.chord:return {},c.id+': wrong target harmony'
|
||||
if c.kind in ('anticipation','anticipated_color'):
|
||||
previous=next((s for s in harmony.spans if s.end==c.boundary),None)
|
||||
if not previous or previous.chord.strip('()')==span.chord.strip('()'):return {},c.id+': anticipation requires a harmonic change'
|
||||
if c.kind in ('approach','enclosure','connection'):
|
||||
rule=next((r for r in vocabulary.data('lead_ins')['patterns'] if r['id']==c.rule and r['kind']==c.kind),None)
|
||||
if not rule or len(c.events)!=len(rule['semitones']):return {},c.id+': unknown approach formula'
|
||||
arrival=c.boundary if c.boundary is not None else target.start
|
||||
if c.boundary is not None and not any(a.kind=='anticipation' and a.target==target and a.boundary==c.boundary for a in contracts):return {},c.id+': future target lacks anticipation contract'
|
||||
planned_color=any(a.kind=='color' and a.target==target and a.events==(target,) for a in contracts)
|
||||
if not planned_color and (target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in harmony.permitted(arrival,target.end)):return {},c.id+': invalid destination hold'
|
||||
gaps=tuple(map(F,rule.get('internal_gaps',['0']*(len(c.events)-1))))
|
||||
if len(gaps)!=len(c.events)-1 or any(g<0 for g in gaps):return {},c.id+': invalid internal approach gaps'
|
||||
gap=F(rule['gap']);cursor=target.start-gap-sum((F(d) for d in rule['durations']),F())-sum(gaps,F())
|
||||
if target.start-cursor>F(vocabulary.data('lead_ins')['max_approach']):return {},c.id+': approach too long'
|
||||
for i,(n,step,duration) in enumerate(zip(c.events,rule['semitones'],rule['durations'])):
|
||||
if n.start!=cursor or n.end!=cursor+F(duration) or n.pitch.number()!=target.pitch.number()+step:return {},c.id+': wrong register, interval, or timing'
|
||||
cursor=n.end+(gaps[i] if i<len(gaps) else 0)
|
||||
for index in rule.get('diatonic_indices',[]):
|
||||
n=c.events[index]
|
||||
if n.pitch.number()%12 not in harmony.permitted(n.start,n.end):return {},c.id+': declared diatonic neighbor is outside source-key/chord motion'
|
||||
if any(n.start<c.target.start and n.end>c.events[0].start and n.event_id not in {e.event_id for e in c.events} for n in notes):return {},c.id+': interrupted approach'
|
||||
if c.kind=='connection' and (not c.preparation or c.preparation.end!=c.events[0].start or abs(c.preparation.pitch.number()-c.events[0].pitch.number())>2):return {},c.id+': chromatic connection lacks its incoming motion'
|
||||
elif c.kind=='passing':
|
||||
rule=vocabulary.data('lead_ins')['passing']
|
||||
if c.rule!=rule['id'] or len(c.events)!=1 or not c.preparation:return {},c.id+': unsupported passing figure'
|
||||
n,=c.events;before=c.preparation
|
||||
left=n.pitch.number()-before.pitch.number();right=target.pitch.number()-n.pitch.number()
|
||||
if before.end!=n.start or n.end!=target.start or n.end-n.start>F(rule['max_note']) or left!=right or left not in rule['steps']:
|
||||
return {},c.id+': interrupted/nonstepwise passing figure'
|
||||
if not c.events or not all(same(n) for n in (*c.events, c.target)):
|
||||
return {}, c.id + ': missing or changed expression event/target'
|
||||
if c.preparation and not same(c.preparation):
|
||||
return {}, c.id + ': changed preparation'
|
||||
if any(n.event_id in approved for n in c.events):
|
||||
return {}, c.id + ': overlapping expression permissions'
|
||||
target = c.target
|
||||
span = harmony.at(c.boundary if c.boundary is not None else c.events[0].start if c.kind == 'color' else target.start)
|
||||
if not span or span.chord == 'N.C.' or span.chord != c.chord:
|
||||
return {}, c.id + ': wrong target harmony'
|
||||
if c.kind in ('anticipation', 'anticipated_color'):
|
||||
previous = next((s for s in harmony.spans if s.end == c.boundary), None)
|
||||
if not previous or previous.chord.strip('()') == span.chord.strip('()'):
|
||||
return {}, c.id + ': anticipation requires a harmonic change'
|
||||
if c.kind in ('approach', 'enclosure', 'connection'):
|
||||
rule = next((r for r in generator.vocabulary.data('lead_ins')['patterns'] if r['id'] == c.rule and r['kind'] == c.kind), None)
|
||||
if not rule or len(c.events) != len(rule['semitones']):
|
||||
return {}, c.id + ': unknown approach formula'
|
||||
arrival = c.boundary if c.boundary is not None else target.start
|
||||
if c.boundary is not None and not any(a.kind == 'anticipation' and a.target == target and a.boundary == c.boundary for a in contracts):
|
||||
return {}, c.id + ': future target lacks anticipation contract'
|
||||
planned_color = any(a.kind == 'color' and a.target == target and a.events == (target,) for a in contracts)
|
||||
if not planned_color and (target.pitch.number() % 12 not in harmony.members(arrival, target.end) or target.pitch.number() % 12 not in harmony.permitted(arrival, target.end)):
|
||||
return {}, c.id + ': invalid destination hold'
|
||||
gaps = tuple(map(fractions.Fraction, rule.get('internal_gaps', ['0'] * (len(c.events) - 1))))
|
||||
if len(gaps) != len(c.events) - 1 or any(g < 0 for g in gaps):
|
||||
return {}, c.id + ': invalid internal approach gaps'
|
||||
gap = fractions.Fraction(rule['gap'])
|
||||
cursor = target.start - gap - sum((fractions.Fraction(d) for d in rule['durations']), fractions.Fraction()) - sum(gaps, fractions.Fraction())
|
||||
if target.start - cursor > fractions.Fraction(generator.vocabulary.data('lead_ins')['max_approach']):
|
||||
return {}, c.id + ': approach too long'
|
||||
for i, (n, step, duration) in enumerate(zip(c.events, rule['semitones'], rule['durations'])):
|
||||
if n.start != cursor or n.end != cursor + fractions.Fraction(duration) or n.pitch.number() != target.pitch.number() + step:
|
||||
return {}, c.id + ': wrong register, interval, or timing'
|
||||
cursor = n.end + (gaps[i] if i < len(gaps) else 0)
|
||||
for index in rule.get('diatonic_indices', []):
|
||||
n = c.events[index]
|
||||
if n.pitch.number() % 12 not in harmony.permitted(n.start, n.end):
|
||||
return {}, c.id + ': declared diatonic neighbor is outside source-key/chord motion'
|
||||
if any(n.start < c.target.start and n.end > c.events[0].start and n.event_id not in {e.event_id for e in c.events} for n in notes):
|
||||
return {}, c.id + ': interrupted approach'
|
||||
if c.kind == 'connection' and (not c.preparation or c.preparation.end != c.events[0].start or abs(c.preparation.pitch.number() - c.events[0].pitch.number()) > 2):
|
||||
return {}, c.id + ': chromatic connection lacks its incoming motion'
|
||||
elif c.kind == 'passing':
|
||||
rule = generator.vocabulary.data('lead_ins')['passing']
|
||||
if c.rule != rule['id'] or len(c.events) != 1 or not c.preparation:
|
||||
return {}, c.id + ': unsupported passing figure'
|
||||
n, = c.events
|
||||
before = c.preparation
|
||||
left = n.pitch.number() - before.pitch.number()
|
||||
right = target.pitch.number() - n.pitch.number()
|
||||
if before.end != n.start or n.end != target.start or n.end - n.start > fractions.Fraction(rule['max_note']) or left != right or left not in rule['steps']:
|
||||
return {}, c.id + ': interrupted/nonstepwise passing figure'
|
||||
|
||||
def supported(endpoint):
|
||||
return harmony.ordinary(endpoint) or any(a.kind in ('color','anticipated_color') and endpoint in a.events for a in contracts)
|
||||
if not supported(before) or not supported(target):return {},c.id+': unsupported passing endpoints'
|
||||
elif c.kind=='delayed_approach':
|
||||
rule=vocabulary.data('lead_ins').get('delayed_approach',{})
|
||||
if c.rule!=rule.get('id') or len(c.events)!=1:return {},c.id+': unknown delayed approach'
|
||||
n,=c.events
|
||||
if n.end-n.start>F(rule['max_note']) or not n.end<=target.start<=n.start+F(rule['deadline']):return {},c.id+': expired delayed approach'
|
||||
if target.pitch.number()-n.pitch.number() not in rule['steps']:return {},c.id+': wrong delayed target register'
|
||||
if target.pitch.number()%12 not in harmony.members(target.start,target.end) or target.pitch.number()%12 not in harmony.permitted(target.start,target.end):return {},c.id+': invalid delayed destination'
|
||||
elif c.kind=='color':
|
||||
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
|
||||
if not rule:return {},c.id+': unknown color policy'
|
||||
colored=False
|
||||
return harmony.ordinary(endpoint) or any(a.kind in ('color', 'anticipated_color') and endpoint in a.events for a in contracts)
|
||||
|
||||
if not supported(before) or not supported(target):
|
||||
return {}, c.id + ': unsupported passing endpoints'
|
||||
elif c.kind == 'delayed_approach':
|
||||
rule = generator.vocabulary.data('lead_ins').get('delayed_approach', {})
|
||||
if c.rule != rule.get('id') or len(c.events) != 1:
|
||||
return {}, c.id + ': unknown delayed approach'
|
||||
n, = c.events
|
||||
if n.end - n.start > fractions.Fraction(rule['max_note']) or not n.end <= target.start <= n.start + fractions.Fraction(rule['deadline']):
|
||||
return {}, c.id + ': expired delayed approach'
|
||||
if target.pitch.number() - n.pitch.number() not in rule['steps']:
|
||||
return {}, c.id + ': wrong delayed target register'
|
||||
if target.pitch.number() % 12 not in harmony.members(target.start, target.end) or target.pitch.number() % 12 not in harmony.permitted(target.start, target.end):
|
||||
return {}, c.id + ': invalid delayed destination'
|
||||
elif c.kind == 'color':
|
||||
rule = next((r for r in generator.vocabulary.data('harmony')['colors'] if r['id'] == c.rule), None)
|
||||
if not rule:
|
||||
return {}, c.id + ': unknown color policy'
|
||||
colored = False
|
||||
for n in c.events:
|
||||
if n.end-n.start>F(rule['max_beats']):return {},c.id+': excessive color duration'
|
||||
for region,a,b in harmony.segments(n):
|
||||
if n.pitch.number()%12 in region.roles.basic|region.roles.specified:continue
|
||||
if region.chord=='N.C.' or not eligible_color(rule,region,harmony):return {},c.id+': incompatible color harmony/function'
|
||||
p=degree_pitch(parsed_chord(region.chord),rule['degree'],rule['alter'],n.pitch.number())
|
||||
if n.end - n.start > fractions.Fraction(rule['max_beats']):
|
||||
return {}, c.id + ': excessive color duration'
|
||||
for region, a, b in harmony.segments(n):
|
||||
if n.pitch.number() % 12 in region.roles.basic | region.roles.specified:
|
||||
continue
|
||||
if region.chord == 'N.C.' or not eligible_color(rule, region, harmony):
|
||||
return {}, c.id + ': incompatible color harmony/function'
|
||||
p = degree_pitch(parsed_chord(region.chord), rule['degree'], rule['alter'], n.pitch.number())
|
||||
# The rule retains the functional degree/spelling. Encoded
|
||||
# enharmonic notation must not change sounding harmonic validity.
|
||||
if p.number()!=n.pitch.number():return {},c.id+': color degree/register mismatch'
|
||||
colored=True
|
||||
if not colored:return {},c.id+': no interval expresses the declared color'
|
||||
last=c.events[-1]
|
||||
if p.number() != n.pitch.number():
|
||||
return {}, c.id + ': color degree/register mismatch'
|
||||
colored = True
|
||||
if not colored:
|
||||
return {}, c.id + ': no interval expresses the declared color'
|
||||
last = c.events[-1]
|
||||
if rule.get('resolve'):
|
||||
if target.event_id==last.event_id or not 0<=target.start-last.end<=F(vocabulary.data('harmony')['resolution_gap']) or not 0<abs(target.pitch.number()-last.pitch.number())<=2 or target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': unresolved color'
|
||||
elif c.kind=='suspension':
|
||||
rule=next((r for r in vocabulary.data('harmony').get('suspensions',[]) if r['id']==c.rule),None)
|
||||
if not rule or len(c.events)!=1 or c.boundary is None:return {},c.id+': unsupported suspension'
|
||||
held,=c.events
|
||||
if not held.start<=c.boundary-F(rule['preparation'])<c.boundary<held.end:
|
||||
return {},c.id+': insufficient sounding preparation'
|
||||
if held.pitch.number()%12 not in harmony.members(held.start,c.boundary):return {},c.id+': nonchordal preparation'
|
||||
if held.end-c.boundary>F(rule['max_delay']) or target.start!=held.end or target.pitch.number()-held.pitch.number() not in rule['steps']:
|
||||
return {},c.id+': missed suspension resolution'
|
||||
if target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': invalid suspension target'
|
||||
if held.pitch.number()%12 not in harmony.permitted(held.start,held.end):return {},c.id+': suspension outside source-key/chord motion'
|
||||
elif c.kind=='anticipated_color':
|
||||
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
|
||||
if not rule or len(c.events)!=1 or c.boundary!=span.start:return {},c.id+': unsupported anticipated color'
|
||||
n,=c.events
|
||||
if not n.start<c.boundary<n.end or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipated color interval'
|
||||
arrived=dataclasses.replace(n,start=c.boundary)
|
||||
target=arrived if c.target==n else c.target
|
||||
inner=types.ExpressionContract(c.id+':arrived','color',rule['id'],(arrived,),target,span.chord)
|
||||
_,error=validate((arrived,*((target,) if target!=arrived else ())), (inner,),harmony)
|
||||
if error:return {},error
|
||||
elif c.kind=='anticipation':
|
||||
if c.rule not in ('early_chord_tone','early_reattack') or len(c.events)!=1 or span.start!=c.boundary:
|
||||
return {},c.id+': unknown anticipation or non-boundary target'
|
||||
n,=c.events
|
||||
if c.boundary is None or not n.start<c.boundary or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipation interval'
|
||||
if c.rule=='early_chord_tone' and (n!=target or not c.boundary<n.end):return {},c.id+': invalid held anticipation'
|
||||
if c.rule=='early_reattack' and (n.end!=c.boundary or target.start!=c.boundary or target.event_id==n.event_id or target.pitch.number()!=n.pitch.number()):return {},c.id+': invalid anticipated reattack'
|
||||
if target.pitch.number()%12 not in harmony.members(c.boundary,target.end) or target.pitch.number()%12 not in harmony.permitted(c.boundary,target.end):return {},c.id+': invalid future destination'
|
||||
elif c.kind=='blues':
|
||||
if len(c.events)!=1:return {},c.id+': blues treatment requires one inflected event'
|
||||
rule=next((r for r in vocabulary.data('lead_ins')['blues'] if r['id']==c.rule),None)
|
||||
n,=c.events
|
||||
chord=parsed_chord(span.chord)
|
||||
if not rule or n.end-n.start>F(1,2) or n.end!=target.start or target.pitch.number()%12 not in span.roles.basic:return {},c.id+': unresolved blues inflection'
|
||||
expected=degree_pitch(chord,rule['degree'],rule['alter'],n.pitch.number())
|
||||
resolved=degree_pitch(chord,rule['target_degree'],0,target.pitch.number())
|
||||
if n.pitch!=expected or target.pitch!=resolved or target.pitch.number()-n.pitch.number()!=1:return {},c.id+': unsupported blues destination'
|
||||
if not c.preparation or c.preparation.end!=n.start or abs(c.preparation.pitch.number()-n.pitch.number())>2:return {},c.id+': unprepared blues gesture'
|
||||
else:return {},c.id+': unknown expression kind'
|
||||
if target.event_id == last.event_id or not 0 <= target.start - last.end <= fractions.Fraction(generator.vocabulary.data('harmony')['resolution_gap']) or not 0 < abs(target.pitch.number() - last.pitch.number()) <= 2 or target.pitch.number() % 12 not in harmony.members(target.start, target.end):
|
||||
return {}, c.id + ': unresolved color'
|
||||
elif c.kind == 'suspension':
|
||||
rule = next((r for r in generator.vocabulary.data('harmony').get('suspensions', []) if r['id'] == c.rule), None)
|
||||
if not rule or len(c.events) != 1 or c.boundary is None:
|
||||
return {}, c.id + ': unsupported suspension'
|
||||
held, = c.events
|
||||
if not held.start <= c.boundary - fractions.Fraction(rule['preparation']) < c.boundary < held.end:
|
||||
return {}, c.id + ': insufficient sounding preparation'
|
||||
if held.pitch.number() % 12 not in harmony.members(held.start, c.boundary):
|
||||
return {}, c.id + ': nonchordal preparation'
|
||||
if held.end - c.boundary > fractions.Fraction(rule['max_delay']) or target.start != held.end or target.pitch.number() - held.pitch.number() not in rule['steps']:
|
||||
return {}, c.id + ': missed suspension resolution'
|
||||
if target.pitch.number() % 12 not in harmony.members(target.start, target.end):
|
||||
return {}, c.id + ': invalid suspension target'
|
||||
if held.pitch.number() % 12 not in harmony.permitted(held.start, held.end):
|
||||
return {}, c.id + ': suspension outside source-key/chord motion'
|
||||
elif c.kind == 'anticipated_color':
|
||||
rule = next((r for r in generator.vocabulary.data('harmony')['colors'] if r['id'] == c.rule), None)
|
||||
if not rule or len(c.events) != 1 or c.boundary != span.start:
|
||||
return {}, c.id + ': unsupported anticipated color'
|
||||
n, = c.events
|
||||
if not n.start < c.boundary < n.end or c.boundary - n.start > fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']):
|
||||
return {}, c.id + ': invalid anticipated color interval'
|
||||
arrived = dataclasses.replace(n, start=c.boundary)
|
||||
target = arrived if c.target == n else c.target
|
||||
inner = generator.solo.types.ExpressionContract(c.id + ':arrived', 'color', rule['id'], (arrived,), target, span.chord)
|
||||
_, error = validate((arrived, *((target,) if target != arrived else ())), (inner,), harmony)
|
||||
if error:
|
||||
return {}, error
|
||||
elif c.kind == 'anticipation':
|
||||
if c.rule not in ('early_chord_tone', 'early_reattack') or len(c.events) != 1 or span.start != c.boundary:
|
||||
return {}, c.id + ': unknown anticipation or non-boundary target'
|
||||
n, = c.events
|
||||
if c.boundary is None or not n.start < c.boundary or c.boundary - n.start > fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']):
|
||||
return {}, c.id + ': invalid anticipation interval'
|
||||
if c.rule == 'early_chord_tone' and (n != target or not c.boundary < n.end):
|
||||
return {}, c.id + ': invalid held anticipation'
|
||||
if c.rule == 'early_reattack' and (n.end != c.boundary or target.start != c.boundary or target.event_id == n.event_id or target.pitch.number() != n.pitch.number()):
|
||||
return {}, c.id + ': invalid anticipated reattack'
|
||||
if target.pitch.number() % 12 not in harmony.members(c.boundary, target.end) or target.pitch.number() % 12 not in harmony.permitted(c.boundary, target.end):
|
||||
return {}, c.id + ': invalid future destination'
|
||||
elif c.kind == 'blues':
|
||||
if len(c.events) != 1:
|
||||
return {}, c.id + ': blues treatment requires one inflected event'
|
||||
rule = next((r for r in generator.vocabulary.data('lead_ins')['blues'] if r['id'] == c.rule), None)
|
||||
n, = c.events
|
||||
chord = parsed_chord(span.chord)
|
||||
if not rule or n.end - n.start > fractions.Fraction(1, 2) or n.end != target.start or target.pitch.number() % 12 not in span.roles.basic:
|
||||
return {}, c.id + ': unresolved blues inflection'
|
||||
expected = degree_pitch(chord, rule['degree'], rule['alter'], n.pitch.number())
|
||||
resolved = degree_pitch(chord, rule['target_degree'], 0, target.pitch.number())
|
||||
if n.pitch != expected or target.pitch != resolved or target.pitch.number() - n.pitch.number() != 1:
|
||||
return {}, c.id + ': unsupported blues destination'
|
||||
if not c.preparation or c.preparation.end != n.start or abs(c.preparation.pitch.number() - n.pitch.number()) > 2:
|
||||
return {}, c.id + ': unprepared blues gesture'
|
||||
else:
|
||||
return {}, c.id + ': unknown expression kind'
|
||||
for n in c.events:
|
||||
covered=n.start
|
||||
for region,a,b in harmony.segments(n):
|
||||
if a!=covered or region.chord=='N.C.':return {},c.id+': missing harmony'
|
||||
covered=b
|
||||
if covered!=n.end:return {},c.id+': incomplete harmonic coverage'
|
||||
approved[n.event_id]=c
|
||||
return approved,''
|
||||
covered = n.start
|
||||
for region, a, b in harmony.segments(n):
|
||||
if a != covered or region.chord == 'N.C.':
|
||||
return {}, c.id + ': missing harmony'
|
||||
covered = b
|
||||
if covered != n.end:
|
||||
return {}, c.id + ': incomplete harmonic coverage'
|
||||
approved[n.event_id] = c
|
||||
return approved, ''
|
||||
|
||||
|
||||
def validate_frontier(notes,contracts,harmony,until,complete):
|
||||
def validate_frontier(notes, contracts, harmony, until, complete):
|
||||
"""Validate heard prefixes and bounded future obligations separately.
|
||||
|
||||
Future contract events are used ONLY to establish rule feasibility. They do
|
||||
@@ -208,21 +270,25 @@ def validate_frontier(notes,contracts,harmony,until,complete):
|
||||
target hold is present. Missing past events and expired deadlines fail.
|
||||
"""
|
||||
if complete:
|
||||
approved,error=validate(notes,contracts,harmony)
|
||||
return approved,(),error
|
||||
actual={n.event_id:n for n in notes};future=dict(actual);pending=[]
|
||||
approved, error = validate(notes, contracts, harmony)
|
||||
return approved, (), error
|
||||
actual = {n.event_id: n for n in notes}
|
||||
future = dict(actual)
|
||||
pending = []
|
||||
for c in contracts:
|
||||
required=tuple(dict.fromkeys((*c.events,c.target,*((c.preparation,) if c.preparation else ()))))
|
||||
incomplete=False
|
||||
required = tuple(dict.fromkeys((*c.events, c.target, *((c.preparation,) if c.preparation else ()))))
|
||||
incomplete = False
|
||||
for expected in required:
|
||||
heard=actual.get(expected.event_id)
|
||||
if expected.start>=until:
|
||||
future[expected.event_id]=expected;incomplete=True
|
||||
elif heard and heard.pitch==expected.pitch and heard.start==expected.start and heard.end==min(until,expected.end):
|
||||
future[expected.event_id]=expected
|
||||
incomplete |= heard.end<expected.end
|
||||
elif not heard or (heard.start,heard.end,heard.pitch)!=(expected.start,expected.end,expected.pitch):
|
||||
return {},(),c.id+': missing/changed event before resolution deadline'
|
||||
if incomplete:pending.append(c)
|
||||
approved,error=validate(tuple(sorted(future.values(),key=lambda n:(n.start,n.event_id))),contracts,harmony)
|
||||
return {key:c for key,c in approved.items() if key in actual},tuple(pending),error
|
||||
heard = actual.get(expected.event_id)
|
||||
if expected.start >= until:
|
||||
future[expected.event_id] = expected
|
||||
incomplete = True
|
||||
elif heard and heard.pitch == expected.pitch and heard.start == expected.start and heard.end == min(until, expected.end):
|
||||
future[expected.event_id] = expected
|
||||
incomplete |= heard.end < expected.end
|
||||
elif not heard or (heard.start, heard.end, heard.pitch) != (expected.start, expected.end, expected.pitch):
|
||||
return {}, (), c.id + ': missing/changed event before resolution deadline'
|
||||
if incomplete:
|
||||
pending.append(c)
|
||||
approved, error = validate(tuple(sorted(future.values(), key=lambda n: (n.start, n.event_id))), contracts, harmony)
|
||||
return {key: c for key, c in approved.items() if key in actual}, tuple(pending), error
|
||||
|
||||
+47
-39
@@ -7,59 +7,66 @@ not a rhythmic boundary and must not be used to clip a group.
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
|
||||
import fractions
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class RhythmGroup:
|
||||
start: F
|
||||
end: F
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
family: str
|
||||
events: tuple[tuple[F, F], ...]
|
||||
displacement: F = F()
|
||||
events: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
|
||||
displacement: fractions.Fraction = fractions.Fraction()
|
||||
source: str = ''
|
||||
|
||||
|
||||
def describe(pattern, *, origin=F(), source='', imported=False):
|
||||
if not pattern:return ()
|
||||
def describe(pattern, *, origin=fractions.Fraction(), source='', imported=False):
|
||||
if not pattern:
|
||||
return ()
|
||||
if imported:
|
||||
return (RhythmGroup(origin, origin+pattern[-1][1], 'source', pattern, source=source),)
|
||||
result=[]; i=0
|
||||
while i<len(pattern):
|
||||
a,b=pattern[i]
|
||||
if b<=a or i and a<pattern[i-1][1]:raise ValueError('overlapping rhythm group')
|
||||
return (RhythmGroup(origin, origin + pattern[-1][1], 'source', pattern, source=source),)
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(pattern):
|
||||
a, b = pattern[i]
|
||||
if b <= a or i and a < pattern[i - 1][1]:
|
||||
raise ValueError('overlapping rhythm group')
|
||||
# A complete ternary figure can be intentionally displaced on an eighth.
|
||||
if (b-a).denominator%3==0:
|
||||
if (a*2).denominator!=1:raise ValueError('unexplained tuplet entry phase')
|
||||
start=i; cursor=a
|
||||
while i<len(pattern):
|
||||
x,y=pattern[i]
|
||||
if x!=cursor or (y-x).denominator not in (3,6):break
|
||||
cursor=y;i+=1
|
||||
if (cursor-a).denominator==1:break
|
||||
if i==start or (cursor-a).denominator!=1:
|
||||
if (b - a).denominator % 3 == 0:
|
||||
if (a * 2).denominator != 1:
|
||||
raise ValueError('unexplained tuplet entry phase')
|
||||
start = i
|
||||
cursor = a
|
||||
while i < len(pattern):
|
||||
x, y = pattern[i]
|
||||
if x != cursor or (y - x).denominator not in (3, 6):
|
||||
break
|
||||
cursor = y
|
||||
i += 1
|
||||
if (cursor - a).denominator == 1:
|
||||
break
|
||||
if i == start or (cursor - a).denominator != 1:
|
||||
raise ValueError('incomplete tuplet group')
|
||||
group=pattern[start:i]
|
||||
result.append(RhythmGroup(origin+a,origin+cursor,'triplet',group,a%1,source))
|
||||
group = pattern[start:i]
|
||||
result.append(RhythmGroup(origin + a, origin + cursor, 'triplet', group, a % 1, source))
|
||||
else:
|
||||
if (a*4).denominator!=1 or (b*4).denominator!=1:
|
||||
if (a * 4).denominator != 1 or (b * 4).denominator != 1:
|
||||
raise ValueError('unexplained binary subdivision phase')
|
||||
family='sixteenth' if (a*2).denominator!=1 or (b*2).denominator!=1 else 'binary'
|
||||
result.append(RhythmGroup(origin+a,origin+b,family,((a,b),),a%1,source));i+=1
|
||||
family = 'sixteenth' if (a * 2).denominator != 1 or (b * 2).denominator != 1 else 'binary'
|
||||
result.append(RhythmGroup(origin + a, origin + b, family, ((a, b),), a % 1, source))
|
||||
i += 1
|
||||
return tuple(result)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def valid(pattern):
|
||||
try:describe(pattern);return True
|
||||
except ValueError:return False
|
||||
|
||||
try:
|
||||
describe(pattern)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def signature(notes):
|
||||
"""Translation-invariant duration, inter-onset and rest vocabulary."""
|
||||
return tuple((n.end-n.start, notes[i+1].start-n.end if i+1<len(notes) else F())
|
||||
for i,n in enumerate(notes))
|
||||
|
||||
return tuple((n.end - n.start, notes[i + 1].start - n.end if i + 1 < len(notes) else fractions.Fraction())
|
||||
for i, n in enumerate(notes))
|
||||
|
||||
def descriptor(pattern, duration):
|
||||
"""Retain ordered attacks, durations and space, including a trailing breath.
|
||||
@@ -69,11 +76,12 @@ def descriptor(pattern, duration):
|
||||
"""
|
||||
return (duration, tuple(pattern))
|
||||
|
||||
|
||||
def broaden(pattern, arrival, release):
|
||||
"""Reopen an editable binary tail; never slice a tuplet or inherit its phase."""
|
||||
if (arrival*2).denominator!=1 or (release*2).denominator!=1:return ()
|
||||
if (arrival * 2).denominator != 1 or (release * 2).denominator != 1:
|
||||
return ()
|
||||
for group in describe(pattern):
|
||||
if group.family=='triplet' and group.start<arrival<group.end:return ()
|
||||
result=tuple((a,min(b,arrival)) for a,b in pattern if a<arrival)+((arrival,release),)
|
||||
if group.family == 'triplet' and group.start < arrival < group.end:
|
||||
return ()
|
||||
result = tuple((a, min(b, arrival)) for a, b in pattern if a < arrival) + ((arrival, release),)
|
||||
return result if valid(result) else ()
|
||||
|
||||
+124
-98
@@ -6,127 +6,153 @@ permission follows the actual sounded interval and validated resolution.
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
from generator import harmony as chords, model
|
||||
from generator.solo import types
|
||||
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
|
||||
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+chords.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 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=model.NATURAL[root['letter']]+model.alteration(root['alter'])
|
||||
return degree_roles(chord['kind'],pc,tuple(sorted(chords.degrees(chord).items())))
|
||||
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:F
|
||||
end:F
|
||||
roles:Roles
|
||||
chord:str
|
||||
key:dict
|
||||
pitches:frozenset
|
||||
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=()
|
||||
valid: bool
|
||||
exposure: float
|
||||
pending: tuple = ()
|
||||
error: str = ''
|
||||
contracts: tuple = ()
|
||||
|
||||
class Harmony:
|
||||
def __init__(self,spans,profile=None):
|
||||
from generator.solo.profiles import UNRESTRICTED
|
||||
self.profile=profile or UNRESTRICTED
|
||||
self.source_spans=spans;self.spans=[];self.color_eligibility={}
|
||||
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((model.NATURAL[l]+int(model.fraction(a)))%12 for l,a in zip(model.LETTERS,s.key['alterations']))
|
||||
self.spans.append(Span(s.start,s.end,r,chords.symbol(s.chord) if s.chord else 'N.C.',s.key,key|r.basic|r.specified))
|
||||
self.spans=tuple(self.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)
|
||||
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()
|
||||
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
|
||||
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 ''
|
||||
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
|
||||
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())
|
||||
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):
|
||||
from generator.solo.expressions import validate_frontier
|
||||
notes=tuple(self.identified(n) for n in 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=F())
|
||||
permissions,pending,error=validate_frontier(notes,expressions,self,frontier,complete or until is None)
|
||||
if error:return Result(False,0,error=error)
|
||||
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):
|
||||
from generator.solo.recognition import recognize
|
||||
expressions,error=recognize(notes,self,expressions)
|
||||
if error:return Result(False,0,error=error)
|
||||
permissions,pending,error=validate_frontier(notes,expressions,self,frontier,True)
|
||||
if error:return Result(False,0,error=error)
|
||||
exposed=sounded=F()
|
||||
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(F(1,1000),sounded)),pending,contracts=tuple(expressions))
|
||||
def describe(self,notes,expressions=()):
|
||||
notes=tuple(self.identified(n) for n in 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)]
|
||||
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)]
|
||||
|
||||
+97
-92
@@ -9,122 +9,127 @@ import collections
|
||||
import json
|
||||
import math
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
from pathlib import Path
|
||||
from generator import model
|
||||
from generator.solo import expressions
|
||||
import fractions
|
||||
import pathlib
|
||||
import generator.model
|
||||
import generator.solo.expressions
|
||||
|
||||
VERSION = 'pooled-events-1'
|
||||
PRIOR = 8
|
||||
DURATIONS = ('1/4','1/3','1/2','2/3','3/4','1','3/2','2','5/2','3','4')
|
||||
|
||||
DURATIONS = ('1/4', '1/3', '1/2', '2/3', '3/4', '1', '3/2', '2', '5/2', '3', '4')
|
||||
|
||||
def key(value):
|
||||
return json.dumps(value,separators=(',',':'))
|
||||
return json.dumps(value, separators=(',', ':'))
|
||||
|
||||
def chord_context(harmony, time):
|
||||
span = harmony.at(time)
|
||||
current = generator.solo.expressions.parsed_chord(span.chord) if span and span.chord != 'N.C.' else None
|
||||
future = next((s for s in harmony.spans if s.start > time and s.chord != 'N.C.' and (not span or s.chord != span.chord)), None)
|
||||
following = generator.solo.expressions.parsed_chord(future.chord) if future else None
|
||||
|
||||
def chord_context(harmony,time):
|
||||
span=harmony.at(time)
|
||||
current=expressions.parsed_chord(span.chord) if span and span.chord!='N.C.' else None
|
||||
future=next((s for s in harmony.spans if s.start>time and s.chord!='N.C.' and (not span or s.chord!=span.chord)),None)
|
||||
following=expressions.parsed_chord(future.chord) if future else None
|
||||
def root(c):return int(model.NATURAL[c['root']['letter']]+model.alteration(c['root']['alter']))%12 if c else 0
|
||||
return (expressions.quality(current) if current else 'unknown',
|
||||
expressions.quality(following) if following else 'end',
|
||||
(root(following)-root(current))%12 if current and following else -1),root(current),future
|
||||
def root(c): return int(generator.model.NATURAL[c['root']['letter']] + generator.model.alteration(c['root']['alter'])) % 12 if c else 0
|
||||
|
||||
return (generator.solo.expressions.quality(current) if current else 'unknown',
|
||||
generator.solo.expressions.quality(following) if following else 'end',
|
||||
(root(following) - root(current)) % 12 if current and following else -1), root(current), future
|
||||
|
||||
def token(duration,pitch,previous,root):
|
||||
return ('rest',str(duration),0,0) if pitch is None else ('note',str(duration),
|
||||
pitch-previous if previous is not None else 'start',(pitch-root)%12)
|
||||
def token(duration, pitch, previous, root):
|
||||
return ('rest', str(duration), 0, 0) if pitch is None else ('note', str(duration), pitch - previous if previous is not None else 'start', (pitch - root) % 12)
|
||||
|
||||
def source_feature(analysis, time, root=0):
|
||||
time += analysis.body_offset
|
||||
before = [n for n in analysis.melody if n.start <= time][-2:]
|
||||
following = next((n for n in analysis.melody if n.start > time), None)
|
||||
direction = 0 if len(before) < 2 else (before[-1].pitch.number() > before[-2].pitch.number()) - (before[-1].pitch.number() < before[-2].pitch.number())
|
||||
held = bool(before and before[-1].end > time)
|
||||
upcoming = 0 if not before or not following else (following.pitch.number() > before[-1].pitch.number()) - (following.pitch.number() < before[-1].pitch.number())
|
||||
return (direction, held, upcoming, str(before[-1].end - before[-1].start) if before else 'none',
|
||||
(before[-1].pitch.number() - root) % 12 if before else -1,
|
||||
before[-1].pitch.number() - before[-2].pitch.number() if len(before) > 1 else 0)
|
||||
|
||||
def source_feature(analysis,time,root=0):
|
||||
time+=analysis.body_offset
|
||||
before=[n for n in analysis.melody if n.start<=time][-2:]
|
||||
following=next((n for n in analysis.melody if n.start>time),None)
|
||||
direction=0 if len(before)<2 else (before[-1].pitch.number()>before[-2].pitch.number())-(before[-1].pitch.number()<before[-2].pitch.number())
|
||||
held=bool(before and before[-1].end>time)
|
||||
upcoming=0 if not before or not following else (following.pitch.number()>before[-1].pitch.number())-(following.pitch.number()<before[-1].pitch.number())
|
||||
return (direction,held,upcoming,str(before[-1].end-before[-1].start) if before else 'none',
|
||||
(before[-1].pitch.number()-root)%12 if before else -1,
|
||||
before[-1].pitch.number()-before[-2].pitch.number() if len(before)>1 else 0)
|
||||
|
||||
|
||||
def phase_at(analysis,time):
|
||||
absolute=time+analysis.body_offset
|
||||
start=next((a for a,b in zip(analysis.boundaries,analysis.boundaries[1:]) if a<=absolute<b),analysis.boundaries[-2])
|
||||
return absolute-start
|
||||
|
||||
def phase_at(analysis, time):
|
||||
absolute = time + analysis.body_offset
|
||||
start = next((a for a, b in zip(analysis.boundaries, analysis.boundaries[1:]) if a <= absolute < b), analysis.boundaries[-2])
|
||||
return absolute - start
|
||||
|
||||
@functools.lru_cache(maxsize=65536)
|
||||
def contexts(harmonic,phase,source,history):
|
||||
def contexts(harmonic, phase, source, history):
|
||||
# No title, path, absolute bar number, progression ID, or target-derived ID.
|
||||
base=(*harmonic,str(phase))
|
||||
result=[(),base,(*base,source)]
|
||||
base = (*harmonic, str(phase))
|
||||
result = [(), base, (*base, source)]
|
||||
# A complete four-event rhythmic/melodic relationship can influence the next
|
||||
# event, while unseen histories smoothly return to broader musical evidence.
|
||||
for count in range(1,min(4,len(history))+1):
|
||||
result.append((*base,source,tuple(history[-count:])))
|
||||
for count in range(1, min(4, len(history)) + 1):
|
||||
result.append((*base, source, tuple(history[-count:])))
|
||||
return tuple(key(c) for c in result)
|
||||
|
||||
|
||||
class Model:
|
||||
def __init__(self,value):
|
||||
if value.get('version')!=VERSION:raise ValueError('Missing/incompatible solo model; run python -m generator.train')
|
||||
self.value=value
|
||||
self.tables={k:(v['total'],v['counts']) for k,v in value['contexts'].items()}
|
||||
self.durations=tuple(sorted(map(F,value['durations'])))
|
||||
self.rhythms=value['rhythms'];self.steps=value['steps'];self.degrees=value['degrees']
|
||||
self._prob={}
|
||||
self._totals={id(t):sum(t.values()) for t in (self.rhythms,self.steps,self.degrees)}
|
||||
|
||||
def __init__(self, value):
|
||||
if value.get('version') != VERSION:
|
||||
raise ValueError('Missing/incompatible solo model; run python -m generator.train')
|
||||
self.value = value
|
||||
self.tables = {k: (v['total'], v['counts']) for k, v in value['contexts'].items()}
|
||||
self.durations = tuple(sorted(map(fractions.Fraction, value['durations'])))
|
||||
self.rhythms = value['rhythms']
|
||||
self.steps = value['steps']
|
||||
self.degrees = value['degrees']
|
||||
self._prob = {}
|
||||
self._totals = {id(t): sum(t.values()) for t in (self.rhythms, self.steps, self.degrees)}
|
||||
@classmethod
|
||||
def load(cls,path):
|
||||
try:return cls(json.loads(Path(path).read_text()))
|
||||
except FileNotFoundError as e:raise ValueError('Solo model not found; run python -m generator.train') from e
|
||||
|
||||
def probability(self,action,context):
|
||||
cache=(action,context)
|
||||
if cache in self._prob:return self._prob[cache]
|
||||
kind,duration,step,degree=action
|
||||
def p(table,item,size):return (table.get(str(item),0)+1)/(self._totals[id(table)]+size)
|
||||
probability=p(self.rhythms,kind+':'+duration,2*len(self.durations))
|
||||
if kind=='note':probability*=p(self.steps,step,50)*p(self.degrees,degree,12)
|
||||
encoded=key(action)
|
||||
def load(cls, path):
|
||||
try:
|
||||
return cls(json.loads(pathlib.Path(path).read_text()))
|
||||
except FileNotFoundError as e:
|
||||
raise ValueError('Solo model not found; run python -m generator.train') from e
|
||||
def probability(self, action, context):
|
||||
cache = (action, context)
|
||||
if cache in self._prob:
|
||||
return self._prob[cache]
|
||||
kind, duration, step, degree = action
|
||||
def p(table, item, size): return (table.get(str(item), 0) + 1) / (self._totals[id(table)] + size)
|
||||
probability = p(self.rhythms, kind + ':' + duration, 2 * len(self.durations))
|
||||
if kind == 'note':
|
||||
probability *= p(self.steps, step, 50) * p(self.degrees, degree, 12)
|
||||
encoded = key(action)
|
||||
for c in context:
|
||||
if c in self.tables:
|
||||
total,counts=self.tables[c]
|
||||
probability=(counts.get(encoded,0)+PRIOR*probability)/(total+PRIOR)
|
||||
if len(self._prob)>100000:self._prob.clear()
|
||||
self._prob[cache]=probability
|
||||
total, counts = self.tables[c]
|
||||
probability = (counts.get(encoded, 0) + PRIOR * probability) / (total + PRIOR)
|
||||
if len(self._prob) > 100000:
|
||||
self._prob.clear()
|
||||
self._prob[cache] = probability
|
||||
return probability
|
||||
def cost(self, action, context): return -math.log(self.probability(action, context))
|
||||
def register_cost(self, number):
|
||||
counts = self.value['registers']
|
||||
return -math.log((counts.get(str(number // 12), 0) + PRIOR) / (sum(counts.values()) + PRIOR * 4))
|
||||
|
||||
def cost(self,action,context):return -math.log(self.probability(action,context))
|
||||
|
||||
def register_cost(self,number):
|
||||
counts=self.value['registers']
|
||||
return -math.log((counts.get(str(number//12),0)+PRIOR)/(sum(counts.values())+PRIOR*4))
|
||||
|
||||
|
||||
def fit(examples,provenance):
|
||||
tables=collections.defaultdict(collections.Counter)
|
||||
rhythms=collections.Counter();steps=collections.Counter();degrees=collections.Counter()
|
||||
registers=collections.Counter();durations=set(DURATIONS)
|
||||
def fit(examples, provenance):
|
||||
tables = collections.defaultdict(collections.Counter)
|
||||
rhythms = collections.Counter()
|
||||
steps = collections.Counter()
|
||||
degrees = collections.Counter()
|
||||
registers = collections.Counter()
|
||||
durations = set(DURATIONS)
|
||||
for example in examples:
|
||||
history=[];previous=None
|
||||
for start,end,pitch in example['events']:
|
||||
harmonic,root,_=chord_context(example['harmony'],start)
|
||||
action=token(end-start,pitch,previous,root)
|
||||
context=contexts(harmonic,phase_at(example['analysis'],start-example['analysis'].body_offset),
|
||||
source_feature(example['analysis'],start-example['analysis'].body_offset,root),tuple(history[-4:]))
|
||||
for c in context:tables[c][key(action)]+=1
|
||||
durations.add(str(end-start));rhythms[action[0]+':'+action[1]]+=1
|
||||
history = []
|
||||
previous = None
|
||||
for start, end, pitch in example['events']:
|
||||
harmonic, root, _ = chord_context(example['harmony'], start)
|
||||
action = token(end - start, pitch, previous, root)
|
||||
context = contexts(harmonic, phase_at(example['analysis'], start - example['analysis'].body_offset),
|
||||
source_feature(example['analysis'], start - example['analysis'].body_offset, root), tuple(history[-4:]))
|
||||
for c in context:
|
||||
tables[c][key(action)] += 1
|
||||
durations.add(str(end - start))
|
||||
rhythms[action[0] + ':' + action[1]] += 1
|
||||
if pitch is not None:
|
||||
steps[str(action[2])]+=1;degrees[str(action[3])]+=1
|
||||
registers[str(pitch//12)]+=1;previous=pitch
|
||||
steps[str(action[2])] += 1
|
||||
degrees[str(action[3])] += 1
|
||||
registers[str(pitch // 12)] += 1
|
||||
previous = pitch
|
||||
history.append(action)
|
||||
return {'version':VERSION,'provenance':provenance,'prior':PRIOR,
|
||||
'durations':sorted(durations,key=F),'rhythms':dict(rhythms),'steps':dict(steps),'degrees':dict(degrees),
|
||||
'registers':dict(registers),
|
||||
'contexts':{k:{'total':sum(v.values()),'counts':dict(sorted(v.items()))} for k,v in sorted(tables.items())}}
|
||||
return {'version': VERSION, 'provenance': provenance, 'prior': PRIOR,
|
||||
'durations': sorted(durations, key=fractions.Fraction), 'rhythms': dict(rhythms), 'steps': dict(steps), 'degrees': dict(degrees),
|
||||
'registers': dict(registers),
|
||||
'contexts': {k: {'total': sum(v.values()), 'counts': dict(sorted(v.items()))} for k, v in sorted(tables.items())}}
|
||||
|
||||
+22
-16
@@ -61,27 +61,33 @@ def measures(sources: list[dict], notes: tuple[generator.solo.types.Note, ...],
|
||||
|
||||
def tuplets(measures):
|
||||
# Group by sounding duration, including notation fragments across barlines.
|
||||
spans=[]
|
||||
events=[n for m in measures for n in generator.score.measure_notes(m)]
|
||||
for ratio,values in itertools.groupby(events,key=lambda n:generator.model.fraction(n['written'].get('ratio',generator.model.rational(1)))):
|
||||
if ratio==1:continue
|
||||
members=[];elapsed=fractions.Fraction();length=None
|
||||
spans = []
|
||||
events = [n for m in measures for n in generator.score.measure_notes(m)]
|
||||
for ratio, values in itertools.groupby(events, key=lambda n: generator.model.fraction(n['written'].get('ratio', generator.model.rational(1)))):
|
||||
if ratio == 1:
|
||||
continue
|
||||
members = []
|
||||
elapsed = fractions.Fraction()
|
||||
length = None
|
||||
for note in values:
|
||||
if not members:
|
||||
length=generator.model.TYPE_LENGTHS[note['written']['type']]*ratio.numerator
|
||||
members.append(note);elapsed+=generator.model.fraction(note['duration'])
|
||||
if elapsed>=length:
|
||||
identifier=members[0]['id']+'_tuplet'
|
||||
spans.append({'id':identifier,'voice':'voice_arranged','start':members[0]['id'],'end':members[-1]['id'],'note_count':ratio.denominator,'space_count':ratio.numerator,'depth':0,'bracket':'auto','show_number':'actual'})
|
||||
for n in members:n['tuplet_ids']=[identifier]
|
||||
members=[];elapsed=fractions.Fraction()
|
||||
length = generator.model.TYPE_LENGTHS[note['written']['type']] * ratio.numerator
|
||||
members.append(note)
|
||||
elapsed += generator.model.fraction(note['duration'])
|
||||
if elapsed >= length:
|
||||
identifier = members[0]['id'] + '_tuplet'
|
||||
spans.append({'id': identifier, 'voice': 'voice_arranged', 'start': members[0]['id'], 'end': members[-1]['id'], 'note_count': ratio.denominator, 'space_count': ratio.numerator, 'depth': 0, 'bracket': 'auto', 'show_number': 'actual'})
|
||||
for n in members:
|
||||
n['tuplet_ids'] = [identifier]
|
||||
members = []
|
||||
elapsed = fractions.Fraction()
|
||||
if members:
|
||||
identifier=members[0]['id']+'_tuplet'
|
||||
spans.append({'id':identifier,'voice':'voice_arranged','start':members[0]['id'],'end':members[-1]['id'],'note_count':ratio.denominator,'space_count':ratio.numerator,'depth':0,'bracket':'auto','show_number':'actual'})
|
||||
for n in members:n['tuplet_ids']=[identifier]
|
||||
identifier = members[0]['id'] + '_tuplet'
|
||||
spans.append({'id': identifier, 'voice': 'voice_arranged', 'start': members[0]['id'], 'end': members[-1]['id'], 'note_count': ratio.denominator, 'space_count': ratio.numerator, 'depth': 0, 'bracket': 'auto', 'show_number': 'actual'})
|
||||
for n in members:
|
||||
n['tuplet_ids'] = [identifier]
|
||||
return spans
|
||||
|
||||
|
||||
def replace_region(document: dict, first: int, replacement: list[dict]) -> None:
|
||||
staff = document['staves'][0]
|
||||
replacement = copy.deepcopy(replacement)
|
||||
|
||||
+17
-19
@@ -6,7 +6,8 @@ import functools
|
||||
|
||||
import generator.fretboard
|
||||
import generator.solo.types
|
||||
from generator.solo.profiles import UNRESTRICTED
|
||||
import generator.solo.profiles
|
||||
import generator.solo.policy
|
||||
|
||||
def travel(previous: generator.solo.types.Note | None, current: generator.solo.types.Note, tempo: fractions.Fraction) -> int:
|
||||
if previous is None:
|
||||
@@ -23,18 +24,15 @@ def transition(previous, current, duration, tempo):
|
||||
def hand_positions(position):
|
||||
return tuple(h for h in range(1, 10) if h <= position.fret <= h + 3)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def position_rank(position):
|
||||
from generator.solo.policy import stable_rank
|
||||
return stable_rank('guitar-position', position)
|
||||
return generator.solo.policy.stable_rank('guitar-position', position)
|
||||
|
||||
def stable_path(path):
|
||||
return tuple(position_rank(n.position) for n in path)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def alternatives(notes, tempo, previous=None, limit=2, profile=UNRESTRICTED):
|
||||
def alternatives(notes, tempo, previous=None, limit=2, profile=generator.solo.profiles.UNRESTRICTED):
|
||||
"""Phrase paths with a four-fret hand region and distinct endpoint choices."""
|
||||
states = [(0, (), h) for h in hand_positions(previous.position)] if previous and previous.position else [(0, (), None)]
|
||||
for note in notes:
|
||||
@@ -49,36 +47,36 @@ def alternatives(notes, tempo, previous=None, limit=2, profile=UNRESTRICTED):
|
||||
continue
|
||||
movement = 0
|
||||
if prior and not note.continuation:
|
||||
seconds = max(.05, float((note.start-prior.start)*60/tempo))
|
||||
distance = abs(hand-old_hand) if old_hand is not None else 0
|
||||
distance += .25*abs(position.fret-prior.position.fret) + .5*abs(position.string_number-prior.position.string_number)
|
||||
movement = round(100*distance/seconds)
|
||||
candidate = (cost+movement, (*path,current), hand)
|
||||
seconds = max(.05, float((note.start - prior.start) * 60 / tempo))
|
||||
distance = abs(hand - old_hand) if old_hand is not None else 0
|
||||
distance += .25 * abs(position.fret - prior.position.fret) + .5 * abs(position.string_number - prior.position.string_number)
|
||||
movement = round(100 * distance / seconds)
|
||||
candidate = (cost + movement, (*path, current), hand)
|
||||
if best is None or candidate[0] < best[0] or candidate[0] == best[0] and stable_path(candidate[1]) < stable_path(best[1]):
|
||||
best = candidate
|
||||
if best is not None:
|
||||
following[position,hand] = best
|
||||
following[position, hand] = best
|
||||
if not following:
|
||||
return ()
|
||||
states = list(following.values())
|
||||
# Endpoints survive so the following phrase can choose a better transition.
|
||||
selected, seen = [], set()
|
||||
for cost,path,hand in sorted(states,key=lambda v:(v[0],stable_path(v[1]))):
|
||||
for cost, path, hand in sorted(states, key=lambda v: (v[0], stable_path(v[1]))):
|
||||
endpoint = path[-1].position if path else None
|
||||
if endpoint in seen:
|
||||
continue
|
||||
seen.add(endpoint); selected.append((cost,path))
|
||||
seen.add(endpoint)
|
||||
selected.append((cost, path))
|
||||
if len(selected) == limit:
|
||||
break
|
||||
return tuple(selected)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def place(notes, tempo, previous=None, profile=UNRESTRICTED):
|
||||
result = alternatives(notes,tempo,previous,1,profile)
|
||||
def place(notes, tempo, previous=None, profile=generator.solo.profiles.UNRESTRICTED):
|
||||
result = alternatives(notes, tempo, previous, 1, profile)
|
||||
return result[0] if result else None
|
||||
|
||||
@functools.lru_cache(maxsize=2048)
|
||||
def positions(number: int, profile=UNRESTRICTED):
|
||||
profile = profile or UNRESTRICTED
|
||||
def positions(number: int, profile=generator.solo.profiles.UNRESTRICTED):
|
||||
profile = profile or generator.solo.profiles.UNRESTRICTED
|
||||
return tuple(p for p in generator.fretboard.single_positions_for_pitch(fractions.Fraction(number)) if p.string_number in profile.strings)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Stable deterministic tie breaking shared with guitar and accompaniment."""
|
||||
import hashlib
|
||||
|
||||
def stable_rank(seed,value):
|
||||
return hashlib.sha256((seed+repr(value)).encode()).hexdigest()
|
||||
def stable_rank(seed, value):
|
||||
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
|
||||
|
||||
@@ -3,35 +3,31 @@ from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
import fractions
|
||||
|
||||
import generator.fretboard
|
||||
import generator.vocabulary
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Instrument:
|
||||
part: str = ''
|
||||
strings: tuple[int, ...] = (1, 2, 3, 4, 5, 6)
|
||||
reference: int | None = None
|
||||
melody_range: tuple[int, int] | None = None
|
||||
|
||||
@property
|
||||
def register(self):
|
||||
tuning = [p for s, p in generator.fretboard.STANDARD_TUNING if s in self.strings]
|
||||
return min(tuning) + 1, max(tuning) + 12
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def permits(self, number):
|
||||
return any(p.string_number in self.strings for p in generator.fretboard.single_positions_for_pitch(F(number)))
|
||||
return any(p.string_number in self.strings for p in generator.fretboard.single_positions_for_pitch(fractions.Fraction(number)))
|
||||
|
||||
UNRESTRICTED = Instrument()
|
||||
|
||||
|
||||
def for_part(part, analysis=None):
|
||||
if part not in ('acoustic', 'electric'):
|
||||
raise ValueError('unknown solo instrument: ' + part)
|
||||
strings = (1, 2, 3, 4, 5, 6)
|
||||
if analysis is None or not analysis.melody:
|
||||
return Instrument(part, strings)
|
||||
return Instrument(part,strings)
|
||||
return Instrument(part, strings)
|
||||
|
||||
+133
-107
@@ -4,138 +4,164 @@ No filenames, annotations, learned examples or role exemptions enter here.
|
||||
Existing expression validators prove each candidate; deterministic set-cover
|
||||
search chooses a mutually consistent explanation, never edits the notes.
|
||||
"""
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary
|
||||
from generator.solo import expressions, types
|
||||
|
||||
import fractions
|
||||
import generator.vocabulary
|
||||
import generator.solo.expressions
|
||||
import generator.solo.types
|
||||
|
||||
def signature(c):
|
||||
return (c.kind,c.rule,tuple(n.event_id for n in c.events),c.target.event_id,
|
||||
str(c.boundary),c.preparation.event_id if c.preparation else '')
|
||||
|
||||
return (c.kind, c.rule, tuple(n.event_id for n in c.events), c.target.event_id,
|
||||
str(c.boundary), c.preparation.event_id if c.preparation else '')
|
||||
|
||||
def merge(*groups):
|
||||
result={}
|
||||
result = {}
|
||||
for group in groups:
|
||||
for c in group:result.setdefault(signature(c),c)
|
||||
for c in group:
|
||||
result.setdefault(signature(c), c)
|
||||
return tuple(result.values())
|
||||
|
||||
def recognize(notes, harmony, provided=(), *, budget=4096):
|
||||
required = {n.event_id for n in notes if not harmony.ordinary(n)}
|
||||
fixed = {n.event_id for c in provided for n in c.events}
|
||||
if required <= fixed:
|
||||
return tuple(provided), ''
|
||||
candidates = {}
|
||||
single = {}
|
||||
by_id = {n.event_id: n for n in notes}
|
||||
if len(by_id) != len(notes):
|
||||
return (), 'Harmonic analysis requires unique sounding-event IDs'
|
||||
|
||||
def recognize(notes,harmony,provided=(),*,budget=4096):
|
||||
required={n.event_id for n in notes if not harmony.ordinary(n)}
|
||||
fixed={n.event_id for c in provided for n in c.events}
|
||||
if required<=fixed:return tuple(provided),''
|
||||
candidates={};single={};by_id={n.event_id:n for n in notes}
|
||||
if len(by_id)!=len(notes):return (), 'Harmonic analysis requires unique sounding-event IDs'
|
||||
def contract(kind, rule, events, target, boundary=None, preparation=None):
|
||||
span = harmony.at(boundary if boundary is not None else target.start)
|
||||
if not span or span.chord == 'N.C.':
|
||||
return None
|
||||
identifier = f'inferred:{kind}:{rule}:{events[0].event_id}:{target.event_id}:{boundary}'
|
||||
return generator.solo.types.ExpressionContract(identifier, kind, rule, tuple(events), target, span.chord, boundary, preparation)
|
||||
|
||||
def contract(kind,rule,events,target,boundary=None,preparation=None):
|
||||
span=harmony.at(boundary if boundary is not None else target.start)
|
||||
if not span or span.chord=='N.C.':return None
|
||||
identifier=f'inferred:{kind}:{rule}:{events[0].event_id}:{target.event_id}:{boundary}'
|
||||
return types.ExpressionContract(identifier,kind,rule,tuple(events),target,span.chord,boundary,preparation)
|
||||
|
||||
def add(c,extras=()):
|
||||
if c is None:return False
|
||||
bundle=merge(extras,(c,));covered=frozenset(n.event_id for a in bundle for n in a.events)
|
||||
if not (covered & required):return False
|
||||
combined=merge(provided,bundle)
|
||||
_,error=expressions.validate(notes,combined,harmony)
|
||||
if error:return False
|
||||
candidates[tuple(signature(a) for a in bundle)]=(bundle,covered)
|
||||
def add(c, extras=()):
|
||||
if c is None:
|
||||
return False
|
||||
bundle = merge(extras, (c,))
|
||||
covered = frozenset(n.event_id for a in bundle for n in a.events)
|
||||
if not (covered & required):
|
||||
return False
|
||||
combined = merge(provided, bundle)
|
||||
_, error = generator.solo.expressions.validate(notes, combined, harmony)
|
||||
if error:
|
||||
return False
|
||||
candidates[tuple(signature(a) for a in bundle)] = (bundle, covered)
|
||||
return True
|
||||
|
||||
# First discover independently supported colors and harmonic anticipations.
|
||||
# These can also support a later approach's destination or passing endpoint.
|
||||
for i,n in enumerate(notes):
|
||||
if n.event_id not in required:continue
|
||||
future=[t for t in notes[i+1:] if n.end<=t.start<=n.end+F(vocabulary.data('harmony')['resolution_gap'])
|
||||
and 0<abs(t.pitch.number()-n.pitch.number())<=2]
|
||||
regions=[(None,harmony.at(n.start))]
|
||||
regions.extend((s.start,s) for s in harmony.spans if n.start<s.start<n.end
|
||||
and s.start-n.start<=F(vocabulary.data('harmony')['anticipation_max']))
|
||||
for boundary,span in regions:
|
||||
if not span or span.chord=='N.C.':continue
|
||||
for i, n in enumerate(notes):
|
||||
if n.event_id not in required:
|
||||
continue
|
||||
future = [t for t in notes[i + 1:] if n.end <= t.start <= n.end + fractions.Fraction(generator.vocabulary.data('harmony')['resolution_gap'])
|
||||
and 0 < abs(t.pitch.number() - n.pitch.number()) <= 2]
|
||||
regions = [(None, harmony.at(n.start))]
|
||||
regions.extend((s.start, s) for s in harmony.spans if n.start < s.start < n.end
|
||||
and s.start - n.start <= fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']))
|
||||
for boundary, span in regions:
|
||||
if not span or span.chord == 'N.C.':
|
||||
continue
|
||||
if boundary is not None:
|
||||
c=contract('anticipation','early_chord_tone',(n,),n,boundary)
|
||||
if add(c):single.setdefault(n.event_id,[]).append((c,))
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
if not expressions.eligible_color(rule,span,harmony) and boundary is not None:continue
|
||||
kind='anticipated_color' if boundary is not None else 'color'
|
||||
for target in future if rule.get('resolve') else (n,):
|
||||
c=contract(kind,rule['id'],(n,),target,boundary)
|
||||
c = contract('anticipation', 'early_chord_tone', (n,), n, boundary)
|
||||
if add(c):
|
||||
single.setdefault(n.event_id, []).append((c,))
|
||||
for rule in generator.vocabulary.data('harmony')['colors']:
|
||||
if not generator.solo.expressions.eligible_color(rule, span, harmony) and boundary is not None:
|
||||
continue
|
||||
kind = 'anticipated_color' if boundary is not None else 'color'
|
||||
for target in future if rule.get('resolve') else [n]:
|
||||
c = contract(kind, rule['id'], (n,), target, boundary)
|
||||
# Non-anticipated color permission is scoped to the note's initial
|
||||
# harmony, even when its resolution occurs after a chord boundary.
|
||||
if boundary is None and c:c=types.ExpressionContract(c.id,c.kind,c.rule,c.events,c.target,span.chord)
|
||||
if add(c):single.setdefault(n.event_id,[]).append((c,))
|
||||
if i+1<len(notes):
|
||||
target=notes[i+1]
|
||||
if boundary is None and c:
|
||||
c = generator.solo.types.ExpressionContract(c.id, c.kind, c.rule, c.events, c.target, span.chord)
|
||||
if add(c):
|
||||
single.setdefault(n.event_id, []).append((c,))
|
||||
if i + 1 < len(notes):
|
||||
target = notes[i + 1]
|
||||
for s in harmony.spans:
|
||||
if n.start<s.start<n.end:
|
||||
for rule in vocabulary.data('harmony').get('suspensions',[]):
|
||||
add(contract('suspension',rule['id'],(n,),target,s.start))
|
||||
elif s.start==n.end==target.start and n.pitch.number()==target.pitch.number():
|
||||
add(contract('anticipation','early_reattack',(n,),target,s.start))
|
||||
|
||||
for i,target in enumerate(notes):
|
||||
targets=[(None,())]
|
||||
for support in single.get(target.event_id,()):
|
||||
c=support[0]
|
||||
if n.start < s.start < n.end:
|
||||
for rule in generator.vocabulary.data('harmony').get('suspensions', []):
|
||||
add(contract('suspension', rule['id'], (n,), target, s.start))
|
||||
elif s.start == n.end == target.start and n.pitch.number() == target.pitch.number():
|
||||
add(contract('anticipation', 'early_reattack', (n,), target, s.start))
|
||||
for i, target in enumerate(notes):
|
||||
targets = [(None, ())]
|
||||
for support in single.get(target.event_id, ()):
|
||||
c = support[0]
|
||||
# An approach into an anticipated color needs both permissions; that
|
||||
# combination is not silently interpreted as a chord-tone anticipation.
|
||||
if c.kind=='color':targets.append((None,support))
|
||||
elif c.kind=='anticipation':targets.append((c.boundary,support))
|
||||
for rule in vocabulary.data('lead_ins')['patterns']:
|
||||
size=len(rule['semitones'])
|
||||
if i<size:continue
|
||||
ns=notes[i-size:i]
|
||||
if not any(n.event_id in required for n in ns):continue
|
||||
if tuple(n.pitch.number()-target.pitch.number() for n in ns)!=tuple(rule['semitones']):continue
|
||||
preparation=notes[i-size-1] if i>size and rule['kind']=='connection' else None
|
||||
for boundary,extras in targets:
|
||||
add(contract(rule['kind'],rule['id'],ns,target,boundary,preparation),extras)
|
||||
if c.kind == 'color':
|
||||
targets.append((None, support))
|
||||
elif c.kind == 'anticipation':
|
||||
targets.append((c.boundary, support))
|
||||
for rule in generator.vocabulary.data('lead_ins')['patterns']:
|
||||
size = len(rule['semitones'])
|
||||
if i < size:
|
||||
continue
|
||||
ns = notes[i - size:i]
|
||||
if not any(n.event_id in required for n in ns):
|
||||
continue
|
||||
if tuple(n.pitch.number() - target.pitch.number() for n in ns) != tuple(rule['semitones']):
|
||||
continue
|
||||
preparation = notes[i - size - 1] if i > size and rule['kind'] == 'connection' else None
|
||||
for boundary, extras in targets:
|
||||
add(contract(rule['kind'], rule['id'], ns, target, boundary, preparation), extras)
|
||||
# Delayed approaches may include real space or intervening valid music.
|
||||
# They still have an exact semitone target and a finite deadline.
|
||||
rule=vocabulary.data('lead_ins')['delayed_approach']
|
||||
rule = generator.vocabulary.data('lead_ins')['delayed_approach']
|
||||
for n in reversed(notes[:i]):
|
||||
if target.start-n.start>F(rule['deadline']):break
|
||||
if n.event_id in required and target.pitch.number()-n.pitch.number() in rule['steps']:
|
||||
add(contract('delayed_approach',rule['id'],(n,),target))
|
||||
if i>=2:
|
||||
before,n=notes[i-2:i]
|
||||
if target.start - n.start > fractions.Fraction(rule['deadline']):
|
||||
break
|
||||
if n.event_id in required and target.pitch.number() - n.pitch.number() in rule['steps']:
|
||||
add(contract('delayed_approach', rule['id'], (n,), target))
|
||||
if i >= 2:
|
||||
before, n = notes[i - 2:i]
|
||||
if n.event_id in required:
|
||||
for left in [(),*single.get(before.event_id,())]:
|
||||
for right in [(),*single.get(target.event_id,())]:
|
||||
add(contract('passing',vocabulary.data('lead_ins')['passing']['id'],(n,),target,preparation=before),merge(left,right))
|
||||
for rule in vocabulary.data('lead_ins')['blues']:
|
||||
add(contract('blues',rule['id'],(n,),target,preparation=before))
|
||||
|
||||
options={identifier:[] for identifier in sorted(required-fixed,key=lambda identifier:(by_id[identifier].start,identifier))}
|
||||
for bundle,covered in candidates.values():
|
||||
for identifier in covered & options.keys():options[identifier].append((bundle,covered))
|
||||
for identifier,choices in options.items():
|
||||
for left in [(), *single.get(before.event_id, ())]:
|
||||
for right in [(), *single.get(target.event_id, ())]:
|
||||
add(contract('passing', generator.vocabulary.data('lead_ins')['passing']['id'], (n,), target, preparation=before), merge(left, right))
|
||||
for rule in generator.vocabulary.data('lead_ins')['blues']:
|
||||
add(contract('blues', rule['id'], (n,), target, preparation=before))
|
||||
options = {identifier: [] for identifier in sorted(required - fixed, key=lambda identifier: (by_id[identifier].start, identifier))}
|
||||
for bundle, covered in candidates.values():
|
||||
for identifier in covered & options.keys():
|
||||
options[identifier].append((bundle, covered))
|
||||
for identifier, choices in options.items():
|
||||
if not choices:
|
||||
n=by_id[identifier]
|
||||
return (),f'{identifier} at {n.start}: unsupported harmonic relationship for {n.pitch.label()}'
|
||||
choices.sort(key=lambda x:(sum((n.end-n.start for c in x[0] for n in c.events),F()),
|
||||
sum((max(F(),c.target.start-c.events[-1].end) for c in x[0]),F()),tuple(signature(c) for c in x[0])))
|
||||
n = by_id[identifier]
|
||||
return (), f'{identifier} at {n.start}: unsupported harmonic relationship for {n.pitch.label()}'
|
||||
choices.sort(key=lambda x: (sum((n.end - n.start for c in x[0] for n in c.events), fractions.Fraction()),
|
||||
sum((max(fractions.Fraction(), c.target.start - c.events[-1].end) for c in x[0]), fractions.Fraction()), tuple(signature(c) for c in x[0])))
|
||||
visits = 0
|
||||
failed = set()
|
||||
|
||||
visits=0;failed=set()
|
||||
def solve(chosen,covered):
|
||||
def solve(chosen, covered):
|
||||
nonlocal visits
|
||||
remaining=required-covered
|
||||
if not remaining:return chosen
|
||||
key=tuple(sorted(signature(c) for c in chosen))
|
||||
if key in failed or visits>=budget:return None
|
||||
visits+=1
|
||||
identifier=min(remaining,key=lambda x:(len(options[x]),by_id[x].start,x))
|
||||
for bundle,added in options[identifier]:
|
||||
combined=merge(chosen,bundle)
|
||||
_,error=expressions.validate(notes,combined,harmony)
|
||||
if error:continue
|
||||
result=solve(combined,covered|added)
|
||||
if result is not None:return result
|
||||
remaining = required - covered
|
||||
if not remaining:
|
||||
return chosen
|
||||
key = tuple(sorted(signature(c) for c in chosen))
|
||||
if key in failed or visits >= budget:
|
||||
return None
|
||||
visits += 1
|
||||
identifier = min(remaining, key=lambda x: (len(options[x]), by_id[x].start, x))
|
||||
for bundle, added in options[identifier]:
|
||||
combined = merge(chosen, bundle)
|
||||
_, error = generator.solo.expressions.validate(notes, combined, harmony)
|
||||
if error:
|
||||
continue
|
||||
result = solve(combined, covered | added)
|
||||
if result is not None:
|
||||
return result
|
||||
failed.add(key)
|
||||
return None
|
||||
result=solve(tuple(provided),fixed)
|
||||
if result is None:return (), 'Harmonic treatment search budget exhausted' if visits>=budget else 'No mutually consistent harmonic treatment coverage'
|
||||
return result,''
|
||||
|
||||
result = solve(tuple(provided), fixed)
|
||||
if result is None:
|
||||
return (), 'Harmonic treatment search budget exhausted' if visits >= budget else 'No mutually consistent harmonic treatment coverage'
|
||||
return result, ''
|
||||
|
||||
+55
-44
@@ -1,60 +1,71 @@
|
||||
"""Compact reports of actual selected phrases, not duplicated draft ancestry."""
|
||||
import dataclasses, fractions, json
|
||||
import generator.solo.placement
|
||||
from generator.solo import harmonic
|
||||
import generator.solo.harmonic
|
||||
import collections
|
||||
import generator.fretboard
|
||||
|
||||
def plain(value):
|
||||
if dataclasses.is_dataclass(value):return plain(dataclasses.asdict(value))
|
||||
if isinstance(value,dict):return {k:plain(v) for k,v in value.items()}
|
||||
if isinstance(value,(tuple,list,set,frozenset)):return [plain(v) for v in value]
|
||||
if isinstance(value,fractions.Fraction):return str(value)
|
||||
if dataclasses.is_dataclass(value):
|
||||
return plain(dataclasses.asdict(value))
|
||||
if isinstance(value, dict):
|
||||
return {k: plain(v) for k, v in value.items()}
|
||||
if isinstance(value, (tuple, list, set, frozenset)):
|
||||
return [plain(v) for v in value]
|
||||
if isinstance(value, fractions.Fraction):
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
def structure(solo):
|
||||
harmony=harmonic.Harmony(solo.harmony)
|
||||
contracts=tuple(c for p in solo.phrases for c in p.expressions)
|
||||
checked=harmony.evaluate(solo.notes(),until=solo.end,complete=True,expressions=contracts)
|
||||
phrases=[]
|
||||
harmony = generator.solo.harmonic.Harmony(solo.harmony)
|
||||
contracts = tuple(c for p in solo.phrases for c in p.expressions)
|
||||
checked = harmony.evaluate(solo.notes(), until=solo.end, complete=True, expressions=contracts)
|
||||
phrases = []
|
||||
for p in solo.phrases:
|
||||
entry={'id':p.id,'start':str(p.start),'end':str(p.end),'purpose':p.purpose,'direction':p.direction,'ending':p.ending,
|
||||
'theme':p.theme,'notes':plain(p.notes),'rhythm_groups':plain(p.groups),'sources':p.sources,
|
||||
'identifying_events':plain(p.identity),
|
||||
'evaluation':plain(p.evaluation),'score':p.score,'expressions':plain(p.expressions)}
|
||||
entry = {'id': p.id, 'start': str(p.start), 'end': str(p.end), 'purpose': p.purpose, 'direction': p.direction, 'ending': p.ending,
|
||||
'theme': p.theme, 'notes': plain(p.notes), 'rhythm_groups': plain(p.groups), 'sources': p.sources,
|
||||
'identifying_events': plain(p.identity),
|
||||
'evaluation': plain(p.evaluation), 'score': p.score, 'expressions': plain(p.expressions)}
|
||||
phrases.append(entry)
|
||||
ns=solo.notes()
|
||||
duration=solo.end-solo.start
|
||||
return {'schema':'composer-report-3','selection_trace':plain({k:v for k,v in solo.diagnostics.items() if not k.endswith('_seconds')}),
|
||||
'instrument_profile':plain(solo.profile),'phrases':phrases,'expressions':plain(contracts),'fretboard':fretboard_report(solo),
|
||||
'phrasing':{'attacks':len(ns),'allocation_beats':str(duration),
|
||||
'attacks_per_four_beats':4*len(ns)/float(duration) if duration else 0,
|
||||
'occupancy':sum(float(n.end-n.start) for n in ns)/float(duration) if duration else 0},
|
||||
'harmonic_roles':harmony.describe(ns,expressions=contracts),'harmonic_policy':'contextual (shared by authored and predicted music)', 'harmonic_valid':checked.valid,'harmonic_error':checked.error,
|
||||
'harmonic_exposure':checked.exposure,'unresolved_treatments':plain(checked.pending)}
|
||||
ns = solo.notes()
|
||||
duration = solo.end - solo.start
|
||||
return {'schema': 'composer-report-3', 'selection_trace': plain({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}),
|
||||
'instrument_profile': plain(solo.profile), 'phrases': phrases, 'expressions': plain(contracts), 'fretboard': fretboard_report(solo),
|
||||
'phrasing': {'attacks': len(ns), 'allocation_beats': str(duration),
|
||||
'attacks_per_four_beats': 4 * len(ns) / float(duration) if duration else 0,
|
||||
'occupancy': sum(float(n.end - n.start) for n in ns) / float(duration) if duration else 0},
|
||||
'harmonic_roles': harmony.describe(ns, expressions=contracts), 'harmonic_policy': 'contextual (shared by authored and predicted music)', 'harmonic_valid': checked.valid, 'harmonic_error': checked.error,
|
||||
'harmonic_exposure': checked.exposure, 'unresolved_treatments': plain(checked.pending)}
|
||||
|
||||
def describe(solo,measure_offset=0,time_offset=0,*,detail=None):
|
||||
detail=detail or structure(solo)
|
||||
lines=[f'{solo.diagnostics.get("construction","solo")}: {solo.part}', 'Times are exact quarter beats from the chorus start; negative times are allocated lead-ins. Pitches sound one octave below guitar notation.',
|
||||
'Approximate fingering costs are not a biomechanical guarantee.',json.dumps(detail['phrasing'])]
|
||||
def describe(solo, measure_offset=0, time_offset=0, *, detail=None):
|
||||
detail = detail or structure(solo)
|
||||
lines = [f'{solo.diagnostics.get("construction", "solo")}: {solo.part}', 'Times are exact quarter beats from the chorus start; negative times are allocated lead-ins. Pitches sound one octave below guitar notation.',
|
||||
'Approximate fingering costs are not a biomechanical guarantee.', json.dumps(detail['phrasing'])]
|
||||
for p in solo.phrases:
|
||||
lines.append(f'{p.id}: {p.start}–{p.end}; {p.purpose}, {p.direction}, {p.ending}; theme {p.theme}; score {p.score:.4f}')
|
||||
for n in p.notes:lines.append(f' {n.start}–{n.end}: {n.pitch.label()}, {n.role}; string {n.position.string_number}, fret {n.position.fret}')
|
||||
for c in p.expressions:lines.append(f' {c.kind}/{c.rule} → {c.target.pitch.label()} at {c.target.start}, held to {c.target.end}')
|
||||
return '\n'.join(lines)+'\n'
|
||||
for n in p.notes:
|
||||
lines.append(f' {n.start}–{n.end}: {n.pitch.label()}, {n.role}; string {n.position.string_number}, fret {n.position.fret}')
|
||||
for c in p.expressions:
|
||||
lines.append(f' {c.kind}/{c.rule} → {c.target.pitch.label()} at {c.target.start}, held to {c.target.end}')
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
def fretboard_report(solo):
|
||||
import collections
|
||||
import generator.fretboard
|
||||
notes=solo.notes()
|
||||
attacks=collections.Counter(); durations=collections.Counter(); forced=optional=0
|
||||
notes = solo.notes()
|
||||
attacks = collections.Counter()
|
||||
durations = collections.Counter()
|
||||
forced = optional = 0
|
||||
for note in notes:
|
||||
string=note.position.string_number
|
||||
attacks[string]+=1;durations[string]+=float(note.end-note.start)
|
||||
if string==1:
|
||||
if len(generator.solo.placement.positions(note.pitch.number(),solo.profile))==1:forced+=1
|
||||
else:optional+=1
|
||||
total=sum(durations.values())
|
||||
return {'attacks_by_string':dict(sorted(attacks.items())),
|
||||
'duration_fraction_by_string':{s:d/max(.001,total) for s,d in sorted(durations.items())},
|
||||
'first_string_forced':forced,'first_string_optional':optional,
|
||||
'range_midi': [min(n.pitch.number() for n in notes),max(n.pitch.number() for n in notes)] if notes else [],
|
||||
'cross_bar_sounding_events':sum(any(n.start < t < n.end for t in {c.start for c in solo.harmony if c.beat == 1}) for n in notes)}
|
||||
string = note.position.string_number
|
||||
attacks[string] += 1
|
||||
durations[string] += float(note.end - note.start)
|
||||
if string == 1:
|
||||
if len(generator.solo.placement.positions(note.pitch.number(), solo.profile)) == 1:
|
||||
forced += 1
|
||||
else:
|
||||
optional += 1
|
||||
total = sum(durations.values())
|
||||
return {'attacks_by_string': dict(sorted(attacks.items())),
|
||||
'duration_fraction_by_string': {s: d / max(.001, total) for s, d in sorted(durations.items())},
|
||||
'first_string_forced': forced, 'first_string_optional': optional,
|
||||
'range_midi': [min(n.pitch.number() for n in notes), max(n.pitch.number() for n in notes)] if notes else [],
|
||||
'cross_bar_sounding_events': sum(any(n.start < t < n.end for t in {c.start for c in solo.harmony if c.beat == 1}) for n in notes)}
|
||||
|
||||
@@ -5,7 +5,6 @@ import generator.model
|
||||
import generator.fretboard
|
||||
import generator.solo.profiles
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, order=True)
|
||||
class Pitch:
|
||||
letter: str
|
||||
|
||||
Reference in New Issue
Block a user