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=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)}