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.references def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0) -> 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)) detail = structure(solo) 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 preference: 4–6 attacks per four beats; source phrasing takes precedence. 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 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.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' 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}') 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']: 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)) checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True) 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(), '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)) if draft} entry['complete_routes'] = rational([dataclasses.asdict(r) for r in phrase.routes]) if phrase.initial and phrase.refined: entry['head_comparison_plain']=generator.solo.similarity.compare(phrase.refined.notes,phrase.initial.notes) entry['head_comparison_elaborated']=generator.solo.similarity.compare(tuple(n for d in phrase.decisions for n in d.notes),phrase.initial.notes) if phrase.state and phrase.state.home: entry['theme_relationship']=generator.solo.references.relationship(phrase.refined.notes,phrase.state.home) entry['score'] = phrase.score phrases.append(entry) return {'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, 'harmonic_roles': harmony.describe(solo.notes(), contracts), '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 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.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())))==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)}