64 lines
4.1 KiB
Python
64 lines
4.1 KiB
Python
"""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.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)
|
||
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()
|
||
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':{'endings':endings,'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_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 joint-action 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.span.start for c in solo.choices if c.span.beat == 1}) for n in notes)}
|