Files
product-vamp/generator/solo/report.py
T
khodowany f234dd6d51 Save
2026-10-04 15:51:18 -05:00

201 lines
16 KiB
Python

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
import generator.solo.placement
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 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
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}
def fretboard_report(solo):
import collections
import generator.fretboard
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.span.start for c in solo.choices if c.span.beat == 1}) for n in notes)}