"""Compact reports of actual selected phrases, not duplicated draft ancestry.""" import dataclasses, fractions, json import generator.solo.placement from generator.solo import harmonic 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=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)} 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)} 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' 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.start for c in solo.harmony if c.beat == 1}) for n in notes)}