Cleanup
This commit is contained in:
+37
-176
@@ -1,185 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import fractions
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.scales.report
|
||||
import generator.scales.selection
|
||||
import generator.scales.timeline
|
||||
import generator.solo.types
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.drafts
|
||||
import generator.solo.similarity
|
||||
import generator.solo.features
|
||||
"""Compact reports of actual selected phrases, not duplicated draft ancestry."""
|
||||
import dataclasses, fractions, json
|
||||
import generator.solo.placement
|
||||
from generator.solo import harmonic
|
||||
|
||||
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0, *, detail=None) -> str:
|
||||
region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo')
|
||||
positioned = tuple(dataclasses.replace(c, span=dataclasses.replace(c.span, start=c.span.start + time_offset, end=c.span.end + time_offset, measure=c.span.measure + measure_offset)) for c in solo.choices)
|
||||
lines = [generator.scales.report.passage(solo.part, region, positioned, scales), f'Phrase composition: {solo.part}', f'Placement reference tempo: {solo.tempo} quarter notes/minute. Runtime tempo changes are not a comfort guarantee.', 'Times below are quarter-note offsets from the start of this solo; pitches are sounding.']
|
||||
if solo.diagnostics:
|
||||
lines.append('Composition diagnostics: ' + json.dumps({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}, sort_keys=True,default=str))
|
||||
detail = structure(solo) if detail is None else detail
|
||||
lines.append("Sustained phrasing: " + json.dumps(detail["phrasing"], sort_keys=True))
|
||||
for passage in detail['passages']:
|
||||
lines.append(f"Melodic passage {passage['id']}: {passage['start']}-{passage['end']}; phrases {', '.join(passage['phrases'])}")
|
||||
lines.append(f"Harmonic exposure: {detail['harmonic_exposure']:.4f}; unresolved treatments: {len(detail['unresolved_treatments'])}")
|
||||
for phrase in solo.phrases:
|
||||
plan = phrase.plan
|
||||
lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources)))
|
||||
lines.append(f' Contract: {plan.treatment}; closure: {plan.closure}; ending: {plan.ending}; score: {phrase.score:.4f}; identity: {plan.seed}')
|
||||
if plan.treatment in ('source_variation', 'thematic_recomposition'):
|
||||
lines.append(' Activity follows the selected gesture and tempo-aware vocabulary; occupancy is diagnostic, not a target.')
|
||||
if phrase.draft:
|
||||
draft = phrase.draft
|
||||
lines.append(f' Complete draft {draft.id}: {draft.relationship}; home {draft.home.id if draft.home else "none"}; {len(draft.notes)} attacks; rests {draft.rests()}')
|
||||
lines.append(f' Lead reference: {draft.reference}; selected features: ' + ', '.join(f.id for f in draft.features))
|
||||
if draft.source_phrase:
|
||||
lines.append(f' Source phrase {draft.source_phrase.id}; octave transfer {draft.octave_transfer:+}; source rests {draft.source_phrase.rests}; source-event links {draft.source_links}')
|
||||
if draft.source_reference:
|
||||
ref=draft.source_reference
|
||||
lines.append(f' Source influence {ref.id}: {ref.treatment}; source {ref.source_id}, beats {ref.start}-{ref.end}. Later developments refer to solo theme {phrase.state.home.id}.')
|
||||
lines.append(' Retained contracts: '+', '.join(f'{f.id}: {f.preserves}' for f in draft.features))
|
||||
for enclosure in draft.enclosures:
|
||||
lines.append(f' Enclosure {enclosure.id}: {enclosure.upper.pitch.label()} → {enclosure.lower.pitch.label()} → {enclosure.target.pitch.label()}; {enclosure.target_role} over {enclosure.chord}; arrival {enclosure.target.start}; protected through {enclosure.target.end}')
|
||||
for route in phrase.routes:
|
||||
lines.append(f' Complete route {route.id}: {route.notes[0].start}-{route.destination.start}; destination {route.destination.pitch.label()}; {route.relationship}; sources {route.sources}')
|
||||
for transform in draft.transformations:
|
||||
lines.append(f' Development {transform.id}: {transform.operation}; {transform.start}-{transform.end}; parent {transform.parent}; home {transform.home}; preserves {transform.preserves}')
|
||||
for edit in phrase.edits:
|
||||
lines.append(f' Revision {edit.kind}: {edit.start}-{edit.end}; {edit.source}; replaced {edit.removed}; realized {edit.added}')
|
||||
for passage in plan.passages:
|
||||
lines.append(f' Activity {passage.start}-{passage.end}: {passage.intent}; attacks/beat {float(passage.activity):.3f}; occupancy {float(passage.occupancy):.2f}')
|
||||
for landmark in plan.landmarks:
|
||||
lines.append(f' Landmark {landmark.name}: {landmark.earliest}-{landmark.latest}; sounding MIDI {landmark.low}-{landmark.high}; direction {landmark.direction}')
|
||||
if plan.target:
|
||||
lines.append(f' Destination: {plan.target.chord}; {plan.target.reason}; arrival {plan.target.earliest}-{plan.target.latest}')
|
||||
for anchor in phrase.anchors:
|
||||
actual = generator.solo.harmonic.anchor_note(anchor, phrase.state.notes if phrase.state else solo.notes())
|
||||
lines.append(f' Anchor {anchor.id}: {anchor.role}/{anchor.effect}; {anchor.chord}; {anchor.pitch.label()}; window {anchor.earliest}-{anchor.latest}; hold {anchor.duration}; hold until {anchor.hold_until}; completed {actual is not None}')
|
||||
if phrase.state:
|
||||
valid, reason = generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=phrase.plan.end == phrase.plan.passage_end, closure=phrase.plan.closure)
|
||||
lines.append(f' Ending contract fulfilled: {valid}; {reason}')
|
||||
for motif in phrase.state.motifs:
|
||||
if phrase.plan.start <= motif.source_start < phrase.plan.end:
|
||||
lines.append(f' Realized motif {motif.id}: parent {motif.parent}; {motif.relationship}; {motif.treatment}; pitches ' + ' '.join(n.pitch.label() for n in motif.notes) + f'; rhythm ' + ' '.join(f'{n.start}:{n.end}' for n in motif.notes))
|
||||
for decision in phrase.decisions:
|
||||
lines.append(f' {decision.start}-{decision.end}: {decision.source}; {decision.treatment}')
|
||||
if not decision.notes:
|
||||
lines.append(' Silence')
|
||||
for note in decision.notes:
|
||||
choice = next(c for c in solo.choices if c.span.start <= note.start < c.span.end)
|
||||
root = generator.scales.selection.root_number(choice.span.chord)
|
||||
degrees = [str(d) for d, a in generator.harmony.degrees(choice.span.chord).items() if (root + generator.harmony.degree_semitones(d, a)) % 12 == note.pitch.number() % 12]
|
||||
function = 'chord degree ' + '/'.join(degrees) if degrees else 'in-collection connection'
|
||||
if note.pitch.number()%12 not in generator.solo.types.permitted(solo.choices,note.start,note.end):
|
||||
function='validated expression exception'
|
||||
lines.append(f' {note.start}-{note.end}: {note.pitch.label()}; {note.role}; {generator.harmony.symbol(choice.span.chord)}, {function}; {choice.scale.label()}; string {note.position.string_number}, fret {note.position.fret}')
|
||||
for expression in detail['expressions']:
|
||||
lines.append('Expression: ' + expression['kind'] + ' / ' + expression['rule'] + ' -> ' + str(expression['target']['pitch']))
|
||||
lines.append('\nSounding harmonic spans (ties merged before classification):')
|
||||
for event in detail['harmonic_roles']:
|
||||
if float(fractions.Fraction(event['sounding_duration'])) >= 1 or event['protected'] or event['enclosure']:
|
||||
lines.append(' ' + json.dumps(event, sort_keys=True))
|
||||
return '\n'.join(lines) + '\n'
|
||||
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)
|
||||
return value
|
||||
|
||||
def structure(solo):
|
||||
harmony = generator.solo.harmonic.Harmony(solo.choices)
|
||||
contracts = tuple(dict.fromkeys(t for p in solo.phrases if p.state for t in p.state.treatments))
|
||||
enclosures = tuple(e for p in solo.phrases if p.draft for e in p.draft.enclosures)
|
||||
expressions = tuple(e for p in solo.phrases if p.draft for e in p.draft.expressions)
|
||||
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures,expressions=expressions)
|
||||
|
||||
def rational(value):
|
||||
if isinstance(value, dict):
|
||||
return {key: rational(item) for key, item in value.items()}
|
||||
if isinstance(value, (tuple, list, set, frozenset)):
|
||||
return [rational(item) for item in value]
|
||||
if isinstance(value, fractions.Fraction):
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
phrases = []
|
||||
for phrase in solo.phrases:
|
||||
notes = phrase.state.notes if phrase.state else solo.notes()
|
||||
complete = phrase.plan.end == (phrase.plan.passage_end or phrase.plan.end)
|
||||
valid, reason = generator.solo.harmonic.anchors_valid(phrase.anchors, notes, phrase.plan.end, complete=complete, closure=phrase.plan.closure)
|
||||
anchors = []
|
||||
for anchor in phrase.anchors:
|
||||
event = generator.solo.harmonic.anchor_note(anchor, notes)
|
||||
anchors.append({**rational(dataclasses.asdict(anchor)), 'pitch': anchor.pitch.label(), 'degrees': next((list(degrees) for pc,degrees in harmony.at(anchor.earliest).roles.degrees if pc==anchor.pitch.number()%12),[]), 'completed': event is not None, 'actual_start': str(event.start) if event else None, 'actual_end': str(event.end) if event else None})
|
||||
motifs = tuple(m for m in phrase.state.motifs if phrase.plan.start <= m.source_start < phrase.plan.end) if phrase.state else ()
|
||||
entry = {'id': phrase.plan.id, 'passage': phrase.plan.passage_id, 'start': str(phrase.plan.start), 'end': str(phrase.plan.end), 'ending': phrase.plan.ending, 'closure': phrase.plan.closure, 'ending_completed': valid, 'ending_error': reason, 'anchors': anchors, 'motif_lineage': rational([dataclasses.asdict(m) for m in motifs])}
|
||||
if phrase.draft:
|
||||
entry.update({'draft': rational(dataclasses.asdict(phrase.draft)), 'draft_rests': rational(phrase.draft.rests()), 'edits': rational([dataclasses.asdict(e) for e in phrase.edits]), 'relationships': generator.solo.drafts.relationship_features(phrase.draft, tuple(n for d in phrase.decisions for n in d.notes)), 'home': phrase.state.home.id if phrase.state and phrase.state.home else None, 'current_development': phrase.state.development.id if phrase.state and phrase.state.development else None})
|
||||
entry['lead_stages'] = {name: {'id': draft.id, 'attacks': len(draft.notes), 'notes': rational([dataclasses.asdict(n) for n in draft.notes])} for name, draft in (('initial', phrase.initial), ('refined', phrase.refined), ('before_enclosure',phrase.before_enclosure)) if draft}
|
||||
entry['complete_routes'] = rational([dataclasses.asdict(r) for r in phrase.routes])
|
||||
if phrase.initial and phrase.refined:
|
||||
# A source hold may already be sounding at this generated phrase boundary.
|
||||
# Detach its clipped entrance for the local comparison only; the full
|
||||
# source comparison below still merges its original tied continuations.
|
||||
local_source=generator.solo.drafts.local_sounding(phrase.initial.notes)
|
||||
entry['head_comparison_plain']=generator.solo.similarity.compare(phrase.refined.notes,local_source)
|
||||
entry['head_comparison_elaborated']=generator.solo.similarity.compare(tuple(n for d in phrase.decisions for n in d.notes),local_source)
|
||||
if phrase.state and phrase.state.home:
|
||||
entry['theme_relationship']=generator.solo.features.relationship(phrase.refined.notes,phrase.state.home)
|
||||
entry['evaluation'] = rational(dataclasses.asdict(phrase.evaluation)) if phrase.evaluation else None
|
||||
from generator.solo import groups
|
||||
try:
|
||||
entry['rhythm_groups']=rational([dataclasses.asdict(g) for g in groups.describe(tuple((n.start-phrase.plan.start,n.end-phrase.plan.start) for n in phrase.draft.notes),origin=phrase.plan.start)]) if phrase.draft else []
|
||||
except ValueError as error:
|
||||
entry['rhythm_groups']=[{'family':'source','reason':str(error)}]
|
||||
entry['score'] = phrase.score
|
||||
harmony=harmonic.Harmony(solo.choices)
|
||||
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=[];endings=[]
|
||||
for p in solo.phrases:
|
||||
anchor=p.destination
|
||||
completed=anchor is None or harmonic.anchor_note(anchor,p.notes) is not None
|
||||
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,
|
||||
'anchors':[{**plain(anchor),'pitch':anchor.pitch.label(),'completed':completed}] if anchor else [],
|
||||
'ending_completed':completed,'evaluation':plain(p.evaluation),'score':p.score,'expressions':plain(p.expressions)}
|
||||
phrases.append(entry)
|
||||
return {'schema':'composer-report-2','selection_trace':rational({k:v for k,v in solo.diagnostics.items() if not k.endswith('_seconds')}), 'instrument_profile': rational(dataclasses.asdict(solo.profile)) if solo.profile else None, 'phrasing': phrasing_report(solo), 'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), 'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'expressions': rational([dataclasses.asdict(e) for e in expressions]), 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures,expressions), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])}
|
||||
|
||||
|
||||
def phrasing_report(solo):
|
||||
import collections
|
||||
from generator.solo import phrasing
|
||||
notes=solo.notes()
|
||||
figures=[phrasing.heard(notes,'chorus')] if notes else []
|
||||
exact=collections.Counter();relative=collections.Counter()
|
||||
for figure in figures:
|
||||
# Count occurrences, not just unique vocabulary within each phrase.
|
||||
for i in range(len(figure.events)-3):
|
||||
group=figure.events[i:i+4];start,_,pitch=group[0]
|
||||
exact[tuple((a-start,d,p) for a,d,p in group)]+=1
|
||||
relative[tuple((a-start,d,p-pitch) for a,d,p in group)]+=1
|
||||
endings=[];regions=[];appearances=[]
|
||||
for phrase in solo.phrases:
|
||||
local=tuple(n for d in phrase.decisions for n in d.notes)
|
||||
for anchor in phrase.anchors:
|
||||
if anchor.role!='terminal' or phrase.plan.closure!='closed':continue
|
||||
note=generator.solo.harmonic.anchor_note(anchor,phrase.state.notes)
|
||||
endings.append({'phrase':phrase.plan.id,'arrival':str(note.start) if note else None,'pitch':anchor.pitch.label(),
|
||||
'effect':anchor.effect,'required_beats':str(anchor.duration),'sounding_beats':str(note.end-note.start) if note else None,
|
||||
'release':str(phrase.plan.release),'completed':note is not None})
|
||||
if local:
|
||||
regions.append({'start':str(phrase.plan.start),'end':str(phrase.plan.end),
|
||||
'low':min(n.pitch.number() for n in local),'high':max(n.pitch.number() for n in local),
|
||||
'duration_weighted_mean':sum(n.pitch.number()*float(n.end-n.start) for n in local)/sum(float(n.end-n.start) for n in local)})
|
||||
if phrase.draft:
|
||||
for transform,feature in zip(phrase.draft.transformations,phrase.draft.features):
|
||||
appearances.append({'start':str(transform.start),'operation':transform.operation,'home':transform.home,
|
||||
'preserves':list(feature.preserves),'feature_source':feature.source})
|
||||
activity={}
|
||||
for intent in ('active','sustained_arrival','space'):
|
||||
spans=[s for p in solo.phrases for s in p.plan.passages if s.intent==intent]
|
||||
duration=sum(float(s.end-s.start) for s in spans)
|
||||
attacks=sum(any(s.start<=n.start<s.end for s in spans) for n in notes)
|
||||
occupancy=sum(float(max(0,min(n.end,s.end)-max(n.start,s.start))) for n in notes for s in spans)
|
||||
activity[intent]={'beats':duration,'attacks':attacks,'attacks_per_four_beats':4*attacks/duration if duration else 0,
|
||||
'occupancy':occupancy/duration if duration else 0}
|
||||
return {'completed_sustained_endings':sum(e['completed'] and fractions.Fraction(e['sounding_beats'])>=2 for e in endings),
|
||||
'endings':endings,'appearances':appearances,'clear_returns':sum(a['operation']=='return' for a in appearances),
|
||||
'exact_four_note_recurrences':sum(max(0,n-1) for n in exact.values()),
|
||||
'interval_rhythm_four_note_recurrences':sum(max(0,n-1) for n in relative.values()),
|
||||
'activity':activity,'register_regions':regions}
|
||||
if anchor:endings.append({'phrase':p.id,'effect':p.ending,'arrival':str(anchor.earliest),'pitch':anchor.pitch.label(),'required_beats':str(anchor.duration),'completed':completed})
|
||||
ns=solo.notes()
|
||||
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':{'endings':endings,'attacks':len(ns),'attacks_per_four_beats':4*len(ns)/float(solo.end) if solo.end else 0,
|
||||
'occupancy':sum(float(n.end-n.start) for n in ns)/float(solo.end) if solo.end else 0},
|
||||
'harmonic_roles':harmony.describe(ns,expressions=contracts),'harmonic_valid':checked.valid,'harmonic_error':checked.error,
|
||||
'harmonic_exposure':checked.exposure,'unresolved_treatments':plain(checked.pending)}
|
||||
|
||||
def describe(solo,scales,measure_offset=0,time_offset=0,*,detail=None):
|
||||
detail=detail or structure(solo)
|
||||
lines=[f'Independent complete-phrase solo: {solo.part}', 'Times are exact quarter beats from the solo start; 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'
|
||||
|
||||
def fretboard_report(solo):
|
||||
import collections
|
||||
|
||||
Reference in New Issue
Block a user