Playback
ci / checks (push) Failing after 23m37s

This commit is contained in:
khodowany
2026-10-05 23:41:11 -05:00
parent ffdbe48258
commit 82349cf897
104 changed files with 4095 additions and 4354 deletions
+6 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+2 -2
View File
@@ -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 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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)}
-1
View File
@@ -5,7 +5,6 @@ import generator.model
import generator.fretboard
import generator.solo.profiles
@dataclasses.dataclass(frozen=True, order=True)
class Pitch:
letter: str