diff --git a/README.md b/README.md index dbf75ee..513accd 100644 --- a/README.md +++ b/README.md @@ -95,23 +95,25 @@ Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or MuseScore live `data/songs` supplies the authored music. `data/rhythms///` supplies the accompaniment rhythm library for 2/4, 3/4, 4/4, and 5/4. All three series are active: the original quarter/eighth patterns plus series `3` with half, dotted-half, and whole notes/rests. Rhythm snippets are build inputs, not separate catalog entries or runtime files. Generation loads the library once and shares it across parts. -`data/scales` supplies the 21 C-centered modes of major, melodic minor, and harmonic minor. The Python build uses these collections, the song melody, the accepted paired pitch/rhythm references in `data/lead_phrases`, `data/sequences`, and `data/rhythms` to compose acoustic and electric solos with transformed motifs, connected melodic lines, varying activity, sustained arrivals, and planned space. A passage plan groups roughly four to eight bars, connects opening, intermediate and terminal chord-tone anchors, and carries realized motif identity across smaller search cells and phrase boundaries. A bounded search considers the next phrase before committing. Exact quotation is available but is no longer the default. The other guitar provides solo-aware accompaniment. Scale changes appear above the solo notation and TAB; detailed build-only phrase reports live in `generated_assets/analysis/`. No solo assignments are stored in song XML. Invalid patterns or a harmony without an exact permitted scale fail before replacing the previous output. +`data/scales` supplies the 21 C-centered modes of major, melodic minor, and harmonic minor. Build-time Python uses each song's actual melody, its mapped harmony, `data/sequences`, and `data/rhythms` to compose recognizable acoustic and electric variations. The standalone `data/lead_phrases` material is an audition control, not a production seed. No solo assignments are stored in song XML, and the runtime format is unchanged. -Solo harmonic function is distinct from scale permission. Basic members respect the chord's suspensions, alterations and omissions; symbol-specified extensions require a planned color when sustained, and a non-chord slash bass is not automatically a melodic destination. Ordinary notes lasting two quarter-note beats or longer must remain chord members across every overlapping chord, even when the scale stays unchanged. Short scale motion remains available. A prepared suspension must resolve by the planned deadline to an adjacent chord member. These checks operate on complete sounding events, so notation ties do not change harmonic treatment or attack counts. Protected quotations keep their musical content and report exposed tensions. +Source analysis retains complete sounding notes, rests, pickups and cross-bar holds. Production uses **thematic recomposition**: a short rhythm, contour, opening, or characteristic interval from the head initializes a new solo theme. Later phrases develop that realization through harmonic adaptation, extension, answers, or optional brief callbacks. Each instrument establishes a distinct related theme instead of restarting from the corresponding head phrase at every source boundary. Close paraphrase remains available through `composer.compose(..., mode='paraphrase')` for comparison. -Ordinary solos now start from complete paired lead ideas. Up to twelve rhythm/interval identities and two harmonic realizations per identity produce complete four-to-eight-bar candidates. The candidate contains a statement, a recognizable return with the same timing, development, and a real ending. Four-bar passages remain common; a longer thought uses the existing extended-phrase representation. Each small pitch search compares complete timed figures from a fixed pitch origin, so correcting one note does not shift the rest of the idea. The return is checked against the statement actually heard, including every identifying interval and duration. Later passages can recall that realized identity or deliberately contrast. Head-based treatments and exact quotation remain available through their explicit contracts. +Treatment-specific contracts preserve the selected features only. Whole-source deviation applies to close paraphrase, including sounding durations; it does not penalize recomposition for changing unrelated notes. Before optional runs, realized pitch/time overlap and interval/rhythm fragments reject substantial near-copies (initial settings: 80% agreement across a two-bar window with at least four source attacks). Octave shifts, reattacks and extra attacks cannot dilute the duration comparison. Additional distance earns no bonus. The original source reference and the solo's realized theme remain separate in state and reports. -Ordinary intermediate anchors are selected from the resulting melody rather than inserted over its rhythm. All sustained sounds still receive chord-by-chord harmonic validation. Complete melody alternatives compete before elaboration, and their unornamented score controls selection: a run cannot rescue a weak theme. Both guitars use a broad activity range of 4–6 attacks per four beats across the passage, with quiet bars and busier runs allowed. Active occupancy between 85% and 100% has no penalty; deliberate breaths and missing harmony remain space. Activity never automatically diminishes or broadens an identifying rhythm. These are audition settings, not a musical-quality claim. +Scale permission and chord function remain distinct. Every generated sound must belong to all mapped collections it overlaps. Added chromatic substitutions and their validation exception have been removed; mapped accidentals such as C-sharp in D harmonic minor remain valid. Basic chord members respect suspensions, alterations and omissions; slash-bass requirements do not automatically become melodic targets. Ordinary sounds lasting at least two quarter-note beats must remain chord members across every overlapping chord, unless an explicit valid color or prepared suspension applies. Tied notation is merged before evaluation. Terminal anchors must actually complete, and protected quotations retain their contracts. -Selected figures, their recognizable returns, quotations, and committed arrivals survive elaboration. `data/sequences` supplies complete optional approaches to the tune's destinations using exact octave-aware scale indices. A route retains its timing, pitches, and endpoint across collection changes; an invalid route is rejected as a whole. The existing bounded elaborator and guitar placement remain in use. The plain lead, diatonic elaboration, and final chromatic version share the same underlying melody. The runtime representation, constant playback treatment, frets 1–12, returning vocal pickup, and arrangement-wide measure numbering are unchanged. +Rhythm snippets and complete interval routes realize one- or two-bar edits jointly with chord-tone destinations. Routes retain their entry, complete timing, pitch path and arrival across barlines and collection changes. Later candidates receive the incoming note, position and realized theme before pitch generation. Run entries may extend an existing note without reattack. Intermediate arrivals continue the thought; the final destination has a closed-ending contract. Missing harmony creates explicit silence while preserving thematic memory. Short fragments around missing harmony are explicitly reduced and do not claim full motif fidelity. -Light chromaticism is an explicit exception to scale permission, not a new collection. An unprotected offbeat eighth note may approach the next attacked chord member by one semitone, without a gap. It cannot span a harmonic boundary itself, but its resolution may enter the next chord. The actual pitch, event identity, duration, resolution, and deadline must all validate. No chromatic hold, unresolved approach, missing-harmony exception, or change to a quotation is permitted. The budget is at most four per eight bars, spaced at least two bars apart and at most one per route; unsuitable sites are left diatonic. Approach spelling prefers ordinary accidentals over avoidable double accidentals. The existing suspension/color contracts remain separate. +The plain recomposed theme is exported independently of optional runs. Activity retains a soft 4–6 attacks-per-four-beats preference; occupancy remains diagnostic. No accents, dynamics, swing or other performance-expression changes are applied. -For reproducible listening comparisons, `python -m generator.solo.audition --output build/solo_passage_auditions --capture before` captures the previous implementation; run the corresponding `--capture after` after changing the engine, then `--render`. Use the repository's pinned Python runner. The output includes full solos, opening eight-bar and closing four-bar excerpts, MusicXML, passage reports and structured diagnostics. Fixed-backing comparisons keep the original accompaniment; responsive comparisons use the newly arranged accompaniment. These build-only tools do not add runtime data or playback expression. +Search retains eight phrase alternatives and an eight-state chorus frontier, with two intermediate continuations per established theme. Up to 64 acoustic/electric pairs compete; equivalent themes after transposition are excluded while individual musical costs and pair similarity remain separate. Concrete caches include incoming state, harmony, timing and vocabulary. One bounded replan broadens eligible start and destination pitches. Exhaustion identifies the failed window without invoking a generic composer. Stable ordering and final tie-breaking preserve determinism. -The earlier phrase-draft experiments remain available for investigating the legacy motif/quotation path. Use the lead-melody comparison below for the current production constructor. +Phrase register alternatives cover the available F2–E5 sounding range. Placement uses all six strings, frets 1–12 without open notes, with a four-fret hand-position model and alternative endpoint fingerings. Movement costs use available time; they do not claim a maximum playable speed. Equal-cost tie-breaking has no first-string preference. Chords elsewhere retain their existing 1–12 anchor policy. The other guitarist is recomposed around the selected foreground, and the returning vocal pickup remains protected. -For lead-melody comparisons, preserve the old generator with `python -m generator.solo.audition --output build/solo_lead_melody_auditions --capture baseline`, then use `python -m generator.solo.lead_audition --capture` and `--render`. Open `build/solo_lead_melody_auditions/index.html` to compare baseline, plain lead, diatonic runs, and chromatic approaches with fixed backing or alone. The final version also has responsive accompaniment. Intermediate exports follow the selected production branches, not independent searches. Literal reference controls verify exact sounding notes through validation, guitar placement, notation, and playback. Reports identify the selected idea, return correspondence, complete routes, chromatic resolutions, harmonic exposure, activity, and occupancy. Listen for a tune whose identity survives its ornamentation; metrics alone do not establish musical improvement. The original temporary archive lives in ignored `build/lead_melody_reference.zip`; builds use the tracked structured reference and never execute the archive's script. +Build reports in `generated_assets/analysis/` include selected source features, recurring solo themes, development lineage, anchors, complete routes, plain-versus-head and elaborated-versus-head comparisons, and candidate diagnostics. Audition reports additionally show string use weighted by sounding duration and distinguish forced first-string notes from optional placements. These measurements establish technical behavior, not musical quality. + +For comparisons, preserve the previous `elaborated/snapshot.json` under `build/solo_melody_auditions/paraphrase_baseline/`, then run `./modulus.py python -m generator.solo.lead_audition --capture` and `--render`. Open `build/solo_melody_auditions/index.html` for the original source, previous close paraphrase, new theme and theme with runs, alone and with fixed backing. Full final mixes also use responsive accompaniment. Source controls preserve the imported melody and may expose original harmonic tensions; production variations must pass strict validation. The temporary reference archive remains ignored at `build/lead_melody_reference.zip`. To inspect generated assets without building the full package, run: diff --git a/generator/__main__.py b/generator/__main__.py index 9ebebe8..fac7553 100644 --- a/generator/__main__.py +++ b/generator/__main__.py @@ -22,7 +22,6 @@ import generator.scales.library import generator.score import generator.summary import generator.solo.sequences -import generator.solo.references RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt") @@ -76,14 +75,13 @@ def generate_assets(args: argparse.Namespace) -> None: scales = generator.scales.library.load_library(data_dir / 'scales') report('loading solo interval sequences') sequences = generator.solo.sequences.load_library(data_dir / 'sequences') - references = generator.solo.references.load_library(data_dir / 'lead_phrases') output_dir.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix='.vamp_assets_', dir=output_dir.parent) as temporary: staged = pathlib.Path(temporary) / 'assets' staged.mkdir(parents=True, exist_ok=True) copy_runtime_fonts(static_assets_dir, staged) report('generating performance parts') - performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences, references=references) + performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences) generator.score.validate_generated_tree(staged) if args.check: generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs') diff --git a/generator/solo/analysis.py b/generator/solo/analysis.py index 0ae5684..61a3a74 100644 --- a/generator/solo/analysis.py +++ b/generator/solo/analysis.py @@ -16,6 +16,8 @@ class Analysis: boundaries: tuple[fractions.Fraction, ...] tempo: fractions.Fraction melody: tuple[generator.solo.types.Note, ...] + body_offset: fractions.Fraction = fractions.Fraction() + source_phrases: tuple[generator.solo.types.SourcePhrase, ...] = () def identity(analysis, measures, part): anchor = analysis.melody[0].pitch.number() if analysis.melody else 0 @@ -63,16 +65,14 @@ def analyze(document: dict) -> Analysis: candidates = [] for index, measure in enumerate(measures): start = boundaries[index] - # One- and two-bar ideas retain leading silence and the complete held ending. - for count in (1, 2): + # Legacy motif consumers also receive complete longer source material. + for count in (1, 2, 4, 6, 8): if index + count >= len(boundaries): continue end = boundaries[index + count] notes = tuple(n for n in melody if start <= n.start < end) if not notes or any(n.end > end for n in notes) or any(a.end > b.start for a, b in zip(notes, notes[1:])): continue - if len(notes) > 8: - continue normalized = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in notes) identity = repr(tuple((n.start, n.end, n.pitch) for n in normalized)).encode('ascii') identifier = 'head_' + hashlib.sha256(identity).hexdigest()[:12] @@ -82,4 +82,56 @@ def analyze(document: dict) -> Analysis: # Preserve the opening idea, then favor a contrasting rhythm/contour with space. ordered = list(unique.values()) ordered.sort(key=lambda m: (next(i for i, n in enumerate(melody) if n.sources[0] == m.notes[0].sources[0]), m.duration)) - return Analysis(tuple(ordered), tuple(boundaries), tempo, melody) + offset = boundaries[generator.score.body_start_measure_index(measures)] + phrases = source_phrases(melody, offset, boundaries[-1] - offset, + generator.model.fraction(measures[-1]['duration'])) + return Analysis(tuple(ordered), tuple(boundaries), tempo, melody, offset, phrases) + +def source_phrases(melody, offset, end, bar): + """Partition at musical releases, without slicing a sounding event at bars.""" + notes = tuple(dataclasses.replace(n, start=n.start-offset, end=n.end-offset, + event_id=n.sources[0]) for n in melody if n.end > offset) + if any(a.end > b.start for a, b in zip(notes, notes[1:])): + raise ValueError('solo source requires a monophonic melody') + candidates = sorted({fractions.Fraction(), end, *(n.end for n in notes if 0 < n.end <= end), + *(n.start for n in notes if 0 < n.start < end)}) + candidates = [t for t in candidates if not any(n.start < t < n.end for n in notes)] + if not candidates or candidates[0] != 0: + candidates.insert(0, fractions.Fraction()) + best = {end: (0., ())} + for start in reversed(candidates[:-1]): + options = [] + for finish in candidates: + duration = finish-start + if finish not in best or duration <= 0 or duration > 8*bar or duration < 2*bar and finish != end: + continue + prior = next((n for n in reversed(notes) if n.end <= finish), None) + after = next((n for n in notes if n.start >= finish), None) + gap = float(max(0, after.start-prior.end)) if prior and after else 0. + held = float(prior.end-prior.start) if prior else 0. + cost = abs(float(duration/bar)-4)/4 + .7*(held < 2) - .3*min(2,gap) + options.append((cost+best[finish][0], (finish,*best[finish][1]))) + if options: + best[start] = min(options) + if 0 not in best: + # A single extended held event is one musical unit, regardless of length. + stops = (end,) + else: + stops = best[0][1] + result, start = [], fractions.Fraction() + for finish in stops: + actual = tuple(n for n in notes if n.start < finish and n.end > start) + cursor, rests = start, [] + for n in actual: + if cursor < n.start: + rests.append((cursor,n.start)) + cursor = n.end + if cursor < finish: + rests.append((cursor,finish)) + origin = actual[0].pitch.number() if actual else 0 + shape = tuple((n.start-start,n.end-start,n.pitch.number()-origin) for n in actual) + family = hashlib.sha256(repr(shape).encode()).hexdigest()[:12] + pickup = tuple(dataclasses.replace(n,start=n.start-offset,end=n.end-offset,event_id=n.sources[0]) for n in melody if n.end<=offset) if start==0 else () + result.append(generator.solo.types.SourcePhrase(f'source_{start}_{finish}',start,finish,actual,tuple(rests),family,pickup)) + start = finish + return tuple(result) diff --git a/generator/solo/arrangement.py b/generator/solo/arrangement.py index e112154..7681a3e 100644 --- a/generator/solo/arrangement.py +++ b/generator/solo/arrangement.py @@ -93,7 +93,8 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, head_first, head_count, _, _ = sections[2] head = parts['electric']['staves'][0]['measures'][head_first:head_first + head_count] reports = [] - for section_index, part, other in ((3, 'acoustic', 'electric'), (4, 'electric', 'acoustic')): + requests = {} + for section_index, part in ((3, 'acoustic'), (4, 'electric')): first, count, offset, length = sections[section_index] initial = generator.accompaniment.carried_chords(parts[part]['staves'][0]['measures'])[first] template = copy.deepcopy(parts[part]['staves'][0]['measures'][first:first + count]) @@ -103,7 +104,12 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(pickup) if n['pitches']] if sounding: cutoff -= generator.model.fraction(pickup['duration']) - min(sounding) - solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial, revise=revise, references=references, stage=stage, context=(contexts or {}).get(part)) + requests[part] = (template, cutoff, initial) + selected = generator.solo.composer.compose_pair(analysis, requests, scales=scales, sequences=sequences, rhythms=rhythms, contexts=contexts, stage=stage) + for solo, section_index, other in zip(selected, (3, 4), ('electric', 'acoustic')): + part = solo.part + first, count, offset, length = sections[section_index] + template, cutoff, initial = requests[part] if solos is not None: solos.append(solo) foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part) diff --git a/generator/solo/audition.py b/generator/solo/audition.py index 1ef69e4..5f545c2 100644 --- a/generator/solo/audition.py +++ b/generator/solo/audition.py @@ -121,10 +121,10 @@ def capture(root, stage, libraries=None, contexts=None): for phrase in solo.phrases: if not phrase.draft: continue - begin = boundaries.index(phrase.plan.start) + begin = max(i for i,boundary in enumerate(boundaries) if boundary <= phrase.plan.start) stop = next(i for i, boundary in enumerate(boundaries) if boundary >= phrase.plan.end) view = excerpt(parts[solo.part], first + begin, stop - begin) - local = tuple(dataclasses.replace(n, start=n.start - phrase.plan.start, end=n.end - phrase.plan.start, continuation=False if i == 0 else n.continuation) for i, n in enumerate(phrase.draft.notes)) + local = tuple(dataclasses.replace(n, start=n.start - boundaries[begin], end=n.end - boundaries[begin], continuation=False if i == 0 else n.continuation) for i, n in enumerate(phrase.draft.notes)) placed = generator.solo.placement.place(local, solo.tempo) if placed is None: raise ValueError(f'{phrase.plan.id}: draft cannot be placed for independent audition') @@ -143,16 +143,16 @@ def capture(root, stage, libraries=None, contexts=None): if placed is None: raise ValueError(stage_name + ': selected theme cannot be placed') sounded = generator.solo.types.sounding_notes(placed[1]) - contracts = tuple(dict.fromkeys(t for p in solo.phrases for t in p.state.treatments if t.kind != 'chromatic_approach')) + contracts = tuple(dict.fromkeys(t for p in solo.phrases for t in p.state.treatments)) checked = generator.solo.harmonic.Harmony(solo.choices).evaluate(sounded, contracts, until=solo.end, complete=True) - if not checked.valid: + if not checked.valid and stage_name != 'initial': raise ValueError(stage_name + ': ' + checked.error) view = excerpt(parts[solo.part], first, count) foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], stage_name + '_' + solo.part) generator.solo.notation.replace_region(view, 0, foreground) generator.model.validate_document(view) generator.fretboard.validate_document(view) - stages[stage_name] = {'document': view, 'attacks': len(sounded), 'occupancy': float(sum(n.end - n.start for n in sounded) / solo.end), 'harmonic_exposure': checked.exposure} + stages[stage_name] = {'document': view, 'attacks': len(sounded), 'occupancy': float(sum(n.end - n.start for n in sounded) / solo.end), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error} song['solos'][solo.part]['lead_stages'] = stages print(path.stem, solo.part, json.dumps({k: v for k, v in song['solos'][solo.part]['metrics'].items() if k != 'search'}), flush=True) result['songs'][path.stem] = song diff --git a/generator/solo/composer.py b/generator/solo/composer.py index 5d43db3..cd9e658 100644 --- a/generator/solo/composer.py +++ b/generator/solo/composer.py @@ -15,45 +15,49 @@ import generator.solo.policy import generator.solo.realization import generator.solo.types -def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated'): +def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated', mode='thematic'): timeline = generator.scales.timeline.read({'measures': measures}, initial=initial) region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo') choices = generator.scales.selection.select(timeline, region, scales) for choice in choices: if choice.span.chord and choice.scale is None: raise ValueError(f'{part} solo, source measure {choice.span.source_measure}, beat {choice.span.beat}, {generator.harmony.symbol(choice.span.chord)}: No exact match within the permitted catalog') - plans = generator.solo.planning.plans(analysis, measures, choices, end, part, sequences, lead=bool(references)) - retained = tuple((plan.start + n.start, plan.start + n.end, {n.pitch.number() % 12}) for plan in plans if plan.treatment in ('quote', 'exact_quote') for n in plan.motif.notes) - if retained and not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained): - choices = generator.scales.selection.select(timeline, region, scales, retained=retained) - if not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained): - raise ValueError(f'{part} solo: protected quotation has no exact compatible scale collection') - choices = tuple(dataclasses.replace(c, reason=c.reason + '; retained quotation') if any(a < c.span.end and b > c.span.start for a, b, _ in retained) else c for c in choices) + from generator.solo import leads context = context if context is not None else generator.solo.realization.SearchContext() - context.revise = revise - context.references = references - context.stage = stage - - def realize(plan, previous): - for attempt in range(generator.solo.policy.DEFAULT.replan_attempts + 1): - context.batch = attempt - try: - options = generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context) - return options - except generator.solo.realization.SearchExhausted as error: - if attempt == generator.solo.policy.DEFAULT.replan_attempts or plan.treatment in ('quote', 'exact_quote', 'lead'): - raise - context.diagnostics.setdefault('replanning', []).append({'phrase': plan.id, 'attempt': attempt + 1, 'reason': str(error), 'adjustment': 'next diverse complete rhythm and anchor constructions; contracts unchanged'}) - - phrases = choose_phrases(plans, realize, lambda first, second, incoming: generator.solo.realization.revise_pair(first, second, incoming, choices, sequences, analysis.tempo, context)) - context.diagnostics['policy'] = generator.solo.policy.describe()['fingerprint'] - context.diagnostics['musical_identity'] = generator.solo.analysis.identity(analysis, measures, part) - passage_ids = tuple(dict.fromkeys(p.passage_id for p in plans if p.passage_id)) - passages = tuple(generator.solo.types.MelodicPassagePlan(identifier, min(p.start for p in plans if p.passage_id == identifier), max(p.end for p in plans if p.passage_id == identifier), tuple(p.id for p in plans if p.passage_id == identifier), next(p.seed for p in plans if p.passage_id == identifier)) for identifier in passage_ids) - result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end, context.diagnostics, passages) + options = leads.solo_options(analysis, measures, part, end, choices, sequences, rhythms, context, stage, mode) + result = options[0][1] validate(result) return result + +def compose_pair(analysis, requests, *, scales, sequences, rhythms, contexts=None, stage='elaborated', mode='thematic'): + """Select two recognizable interpretations together, before arranging backing.""" + from generator.solo import leads + pools = {} + for part, (measures,end,initial) in requests.items(): + timeline = generator.scales.timeline.read({'measures': measures},initial=initial) + choices = generator.scales.selection.select(timeline,generator.scales.timeline.Solo(0,end,part),scales) + for c in choices: + if c.span.chord and c.scale is None: + raise ValueError(f'{part} solo, source measure {c.span.source_measure}: No exact match within the permitted catalog') + context = (contexts or {}).get(part) or generator.solo.realization.SearchContext() + pools[part] = leads.solo_options(analysis,measures,part,end,choices,sequences,rhythms,context,stage,mode) + def theme_key(solo): + home=next((p.state.home for p in solo.phrases if p.state and p.state.home),None) + if not home or not home.notes:return () + return tuple((n.start-home.notes[0].start,n.end-n.start,n.pitch.number()-home.notes[0].pitch.number()) for n in home.notes) + pairs = [(a_cost+e_cost+2*leads.similarity(a,e),a,e) for a_cost,a in pools['acoustic'] for e_cost,e in pools['electric'] + if mode!='thematic' or not theme_key(a) or theme_key(a)!=theme_key(e)] + if not pairs: + raise generator.solo.realization.SearchExhausted('Distinct acoustic/electric themes not found within the bounded pair search') + _,acoustic,electric = min(pairs,key=lambda v:(v[0],generator.solo.policy.stable_rank('solo-pair',(v[1].notes(),v[2].notes())))) + overlap = leads.similarity(acoustic,electric) + for solo in (acoustic,electric): + solo.diagnostics.update(pair_similarity=overlap,pairs_compared=len(pairs),solo_alternatives=len(pools[solo.part])) + validate(solo) + return acoustic,electric + + def phrase_notes(phrase): notes = tuple(n for d in phrase.decisions for n in d.notes) if notes and notes[0].continuation: @@ -116,8 +120,8 @@ def validate(solo): for note in solo.notes(): if note.start < 0 or note.end > solo.end or note.end <= note.start or previous and previous.end > note.start: raise ValueError(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}') - # Harmony.evaluate below owns scale permission and the narrow, verified - # chromatic exception; no separate permissive final-validation path. + if note.pitch.number() % 12 not in generator.solo.types.permitted(solo.choices, note.start, note.end): + raise ValueError(f'{solo.part} solo: note outside mapped collection at {note.start}') if note.position not in generator.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())): raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}') previous = note diff --git a/generator/solo/drafts.py b/generator/solo/drafts.py index 34c6e5b..79a424b 100644 --- a/generator/solo/drafts.py +++ b/generator/solo/drafts.py @@ -385,7 +385,7 @@ def relationship_features(draft, notes): actual = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(group, group[1:])) direction.extend(a != b for a, b in zip(actual, transform.directions)) reference = draft.reference_motif if transform.operation in ('statement', 'contrast') else draft.home - if reference and not draft.lead_identity and transform.operation not in ('close', 'fragment', 'broaden_ending'): + if reference and not draft.lead_identity and not draft.source_phrase and transform.operation not in ('close', 'fragment', 'broaden_ending'): development.append(generator.solo.references.relationship(group, reference)) joins = [] for time in draft.joins: diff --git a/generator/solo/harmonic.py b/generator/solo/harmonic.py index 976d62c..17b5e5a 100644 --- a/generator/solo/harmonic.py +++ b/generator/solo/harmonic.py @@ -99,32 +99,6 @@ class Harmony: for treatment in treatments: if treatment.start >= until: continue - if treatment.kind == 'chromatic_approach': - approach = next((n for n in notes if n.event_id == treatment.event), None) - if (approach is None or approach.role == 'quote' or approach.pitch.number() != treatment.pitch - or approach.start != treatment.start or approach.end != treatment.end - or approach.end - approach.start != fractions.Fraction(1, 2) - or approach.start % 1 != fractions.Fraction(1, 2) - or treatment.resolution is None or abs(treatment.pitch - treatment.resolution) != 1 - or treatment.deadline != approach.end or len(self.segments(approach)) != 1): - return Result(False, 0, error=f'{treatment.id}: invalid chromatic approach event or timing') - span = self.at(approach.start) - if not span or not span.scale or treatment.pitch % 12 in span.scale.pitches: - return Result(False, 0, error=f'{treatment.id}: approach requires mapped harmony and an out-of-collection pitch') - resolution = next((n for n in notes if n.start == approach.end), None) - if resolution: - destination = self.at(resolution.start) - if (resolution.event_id != treatment.resolution_event or resolution.continuation - or resolution.pitch.number() != treatment.resolution or not destination or not destination.scale - or treatment.resolution % 12 not in destination.roles.basic - or treatment.resolution % 12 not in generator.solo.types.permitted(self.choices, resolution.start, resolution.end)): - return Result(False, 0, error=f'{treatment.id}: chromatic approach has no immediate chord-tone resolution') - elif complete or until > approach.end: - return Result(False, 0, error=f'{treatment.id}: unresolved chromatic approach') - else: - pending.append(treatment) - valid_contracts.append(treatment) - continue if treatment.kind == 'color': spans = [s for s in self.spans if s.start < treatment.end and s.end > treatment.start] if not spans or any(treatment.pitch % 12 not in s.roles.basic | s.roles.specified for s in spans): @@ -155,8 +129,8 @@ class Harmony: sounding += float(duration) covered = note.start for span, start, end in self.segments(note): - contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end and (t.kind != 'chromatic_approach' or t.event == note.event_id)), None) - if start > covered or not span.scale or number % 12 not in span.scale.pitches and not (contract and contract.kind == 'chromatic_approach'): + contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end), None) + if start > covered or not span.scale or number % 12 not in span.scale.pitches: return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony') covered = end function = self.function(number, span) @@ -176,7 +150,7 @@ class Harmony: for index, note in enumerate(notes): for span, start, end in self.segments(note): contracts = [t for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end] - result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': 'chromatic_approach' if any(t.kind == 'chromatic_approach' for t in contracts) else self.connected_motion(notes, index), 'protected': note.role == 'quote', 'treatments': [t.id for t in contracts]}) + result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': self.connected_motion(notes, index), 'protected': note.role == 'quote', 'treatments': [t.id for t in contracts]}) return result def anchor_note(anchor, notes, *, until=None): diff --git a/generator/solo/lead_audition.py b/generator/solo/lead_audition.py index 371ccfe..e230c04 100644 --- a/generator/solo/lead_audition.py +++ b/generator/solo/lead_audition.py @@ -79,12 +79,12 @@ def controls(song, libraries): return result def render(root): - baseline = json.loads((root / 'baseline/snapshot.json').read_text()) + baseline = json.loads((root / 'paraphrase_baseline/snapshot.json').read_text()) current = json.loads((root / 'elaborated/snapshot.json').read_text()) cache, clips = {}, [] gain = 0.4 # Shared audition gain; every mixed clip is checked for clipping. - manifest = {'implementation': current['implementation'], 'baseline_implementation': baseline['implementation'], 'policy': current['policy'], 'shared_gain': gain, 'clips': clips, 'stage_semantics': 'Initial is the selected plain tune; refined adds complete diatonic runs; elaborated adds validated chromatic approaches. All stages share the same underlying selected tune.'} - body = ['Lead melody comparisons

Lead melody comparisons

Compare the plain tune, its diatonic runs, and its chromatic approaches. All fixed mixes use the original backing, identical tempo and one shared gain. Solo-only clips remove the backing. Initial/refined exports follow the selected production branches; they are not independent searches.

These metrics verify behavior, not musical quality. Listen for a recognizable tune, prepared destinations, and runs that belong to the tune. Diagnostics manifest.

'] + manifest = {'implementation': current['implementation'], 'baseline_implementation': baseline['implementation'], 'policy': current['policy'], 'shared_gain': gain, 'clips': clips, 'stage_semantics': 'Source is the original head, close paraphrase is the preserved previous plain theme, refined is the newly recomposed theme, and elaborated includes optional in-scale runs. The selected production branch supplies both new stages.'} + body = ['Lead melody comparisons

Lead melody comparisons

Compare the original head, the previous close paraphrase, the new solo theme, and its optional in-scale runs. Source controls may contain original harmonic tensions that production explicitly adapts. All fixed mixes use the original backing, identical tempo and one shared gain. Solo-only clips remove the backing. Initial/refined exports follow the selected production branches; they are not independent searches.

These metrics verify behavior, not musical quality. Listen for a recognizable tune, prepared destinations, and runs that belong to the tune. Diagnostics manifest.

'] def clip(prefix, foreground, tempo, backing=()): path = root / (prefix + '.wav') @@ -103,13 +103,13 @@ def render(root): raise ValueError(name + ': source changed during comparison') for part, solo in song['solos'].items(): other = 'electric' if part == 'acoustic' else 'acoustic' - body.append('

' + html.escape(name.replace('_', ' ').title() + ' — ' + part) + '

') + body.append('

' + html.escape(name.replace('_', ' ').title() + ' — ' + part) + '

ExcerptBaselinePlain leadDiatonic runsChromatic approaches
') for window, offset, count in (('opening8', 0, 8), ('closing4', solo['count'] - 4, 4), ('full', 0, solo['count'])): first = solo['first'] + offset backing = tuple(audition.excerpt(old['parts'][p], first, count) for p in (other, 'vocal')) body.append('') - for stage in ('baseline', 'initial', 'refined', 'elaborated'): - foreground = audition.excerpt((old if stage == 'baseline' else song)['parts'][part], first, count) if stage in ('baseline', 'elaborated') else audition.excerpt(solo['lead_stages'][stage]['document'], offset, count) + for stage in ('initial', 'close_paraphrase', 'refined', 'elaborated'): + foreground = audition.excerpt(song['parts'][part], first, count) if stage=='elaborated' else audition.excerpt(old['solos'][part]['lead_stages']['refined']['document'] if stage=='close_paraphrase' else solo['lead_stages'][stage]['document'], offset, count) prefix = f'{stage}/{name}_{part}_{window}' path = clip(prefix + '_fixed', foreground, F(solo['tempo']), backing) solo_path = clip(prefix + '_solo', foreground, F(solo['tempo'])) @@ -119,6 +119,7 @@ def render(root): clip(prefix + '_responsive', foreground, F(solo['tempo']), tuple(audition.excerpt(song['parts'][p], first, count) for p in (other, 'vocal'))) body.append('') body.append('
ExcerptSong sourcePrevious close paraphraseNew themeTheme with runs
' + window + '
') + body.append(f'

Previous complete production solo

') prefix = f'elaborated/{name}_{part}' (root / (prefix + '.txt')).write_text(solo['report']) (root / (prefix + '.json')).write_text(json.dumps(solo['structure'], indent=2) + '\n') @@ -141,7 +142,7 @@ def render(root): def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_lead_melody_auditions')) + parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_melody_auditions')) action = parser.add_mutually_exclusive_group(required=True) action.add_argument('--capture', action='store_true') action.add_argument('--render', action='store_true') diff --git a/generator/solo/leads.py b/generator/solo/leads.py index 98485fc..d42b34a 100644 --- a/generator/solo/leads.py +++ b/generator/solo/leads.py @@ -1,317 +1,386 @@ -"""Complete paired lead ideas, realized by the existing phrase/output pipeline. +"""Source-phrase variations: edit actual music, then realize complete routes. -The bounded search branches on a whole timed hook or answer. Every pitch in -that group is measured from one fixed origin, never from a previously repaired -note. The original identity survives harmonic adaptation and passage changes. +The external lead references are audition controls, never production seeds. +All notes use sounding coordinates and all time uses quarter-note Fractions. """ from __future__ import annotations import dataclasses import fractions -import itertools -import time -import generator.harmony import generator.model -import generator.solo.drafts as drafts -import generator.solo.harmonic as harmonic -import generator.solo.policy as policy -import generator.solo.types as types +import generator.fretboard +from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts F = fractions.Fraction -def identities(references, incoming): - reference = references[0] - low, high = reference.features[0][1:] - source = tuple(n for n in reference.notes if low <= n.start < high) - rhythm = tuple(n.end - n.start for n in source) - if len(rhythm) != 4: - raise ValueError('lead reference requires a complete four-attack identifying figure') - rhythms = (rhythm, (rhythm[1], rhythm[2], rhythm[0], rhythm[3]), (F(1), F(1, 2), F(3, 2), F(1))) - shapes = ((0, 3, 2, 0), (0, -3, -2, 0), (0, 2, 1, -1), (0, 0, 2, 1)) - result = [types.LeadIdentity(f'{reference.id}:lead:{i}:{j}', durations, degrees, reference.id) - for i, durations in enumerate(rhythms) for j, degrees in enumerate(shapes)] - if incoming.lead_identity: - result = [incoming.lead_identity, *(x for x in result if x != incoming.lead_identity)] - return tuple(result[:policy.DEFAULT.lead_identities]) -def timings(start, length, durations, finish, harmony): - """Fit meter on an eighth-note grid; never compress into tiny tuplets.""" - cursor, result = start, [] - ideal = [duration * length / sum(durations) * 2 for duration in durations] - ticks = [max(1, int(value)) for value in ideal] - available = int(length * 2) - if available < len(ticks) or length * 2 != available: +def signature(notes): + return tuple((n.start, n.end, n.pitch.number()) for n in notes) + + +def shift(notes, octave): + return tuple(dataclasses.replace(n, pitch=dataclasses.replace(n.pitch, octave=n.pitch.octave+octave), + position=None, role='source', event_id=n.event_id or n.sources[0]) for n in notes) + + +def available(harmony, start, end, *, members=False): + span = harmony.at(start) + allowed = types.permitted(harmony.choices, start, end) + if members: + allowed &= harmony.members(start, end) + if not span or not span.scale: return () - while sum(ticks) < available: - index = max(range(len(ticks)), key=lambda i: (ideal[i] - ticks[i], -i)) - ticks[index] += 1 - while sum(ticks) > available: - index = max((i for i in range(len(ticks)) if ticks[i] > 1), key=lambda i: (ticks[i] - ideal[i], -i)) - ticks[index] -= 1 - for duration in ticks: - end = min(finish, cursor + F(duration, 2)) - if cursor < end: - # An authored rest in the harmony is not filled with a melodic fallback. - spans = [s for s in harmony.spans if s.start < end and s.end > cursor] - if spans and all(s.scale for s in spans): - result.append((cursor, end)) - cursor = end - return tuple(result) + return tuple(p for i in range(-35, 28) if 41 <= (p := types.scale_pitch(span.scale, i)).number() <= 76 and p.number()%12 in allowed) -def group_options(spans, degrees, register, harmony, previous, identifier, *, home=(), terminal=False): - """Enumerate whole paired figures, including non-nearest melodic intervals.""" - from generator.solo.realization import options - from generator.solo.planning import target_pitches - if not spans: - return ((0., ()),) - cache = getattr(harmony, 'lead_groups', None) - if cache is None: - harmony.lead_groups = cache = {} - key = (spans, degrees, register, previous.pitch if previous else None, identifier, tuple(n.pitch for n in home), terminal) - if key in cache: - return cache[key] - domains = [] - for index, (start, end) in enumerate(spans): - available = options(harmony.choices, start, end, register) - if end - start >= policy.DEFAULT.long_note: - members = harmony.members(start, end) - available = tuple(p for p in available if p.number() % 12 in members) - if terminal and index == len(spans) - 1: - chord = next(c.span.chord for c in harmony.choices if c.span.start <= start < c.span.end) - eligible = target_pitches(chord, True) & harmony.members(start, end) - available = tuple(p for p in available if p.number() % 12 in eligible) - if not available: - return () - domains.append(available) - collection = harmony.at(spans[0][0]).scale - result = [] - for origin in domains[0]: - index = types.scale_index(collection, origin.number()) - desired = tuple(origin.number() + n.pitch.number() - home[0].pitch.number() for n in home) if home else tuple(types.scale_pitch(collection, index + degree).number() for degree in degrees) - desired = desired[:len(spans)] - if len(desired) < len(spans): - continue - narrowed = [(origin,)] + [tuple(sorted(domain, key=lambda p: (abs(p.number() - target), p))[:2]) for domain, target in zip(domains[1:], desired[1:])] - for pitches in itertools.product(*narrowed): - actual = tuple(b.number() - a.number() for a, b in zip(pitches, pitches[1:])) - intended = tuple(b - a for a, b in zip(desired, desired[1:])) - # A return must supply evidence of the complete heard figure, not just - # its first interval. Harmonic adaptation may alter interval quality. - if home and any((a > 0) - (a < 0) != (b > 0) - (b < 0) or abs(a - b) > 2 for a, b in zip(actual, intended)): - continue - shape = sum(abs(a - b) / 12 + .5 * (((a > 0) - (a < 0)) != ((b > 0) - (b < 0))) for a, b in zip(actual, intended)) / max(1, len(actual)) - if not home and shape > .45: - continue - notes = tuple(types.Note(start, end, pitch, (identifier,), 'motif', event_id=f'{identifier}:{start}') for (start, end), pitch in zip(spans, pitches)) - stopped = sum(end - start >= 1 and p.number() % 12 not in harmony.at(start).roles.basic for (start, end), p in zip(spans, pitches)) / len(notes) - connection = min(1, abs(origin.number() - previous.pitch.number()) / 12) if previous else 0 - result.append((shape + .3 * stopped + .15 * connection, notes)) - result.sort(key=lambda v: (v[0], tuple(n.pitch for n in v[1]))) - selected = tuple(result[:policy.DEFAULT.lead_realizations]) - cache[key] = selected - return selected -def make_phrase(plan, notes, groups, identity, incoming, harmony, tempo, fit): - from generator.solo import realization +def adapt(notes, harmony): + """Document required harmonic rewrites; never repair a sequence one step at a time.""" + result, edits = [], [] + for index, note in enumerate(notes): + terminal = index == len(notes)-1 + choices = available(harmony,note.start,note.end,members=terminal or note.end-note.start >= 2) + pieces = [(note.start,note.end,choices)] + if not choices: + pieces = [(a,b,available(harmony,a,b,members=terminal or b-a >= 2)) for _,a,b in harmony.segments(note)] + for j,(start,end,domain) in enumerate(pieces): + if not domain: + if harmony.at(start) and harmony.at(start).scale: + return (), () + edits.append(types.DraftEdit('missing_harmony',start,end,(note.event_id,),(), 'explicit no-chord space')) + continue + pitch = min(domain,key=lambda p:(abs(p.number()-note.pitch.number()),p.number())) + identifier = note.event_id if j == 0 else note.event_id+f':harmonic:{j}' + actual = dataclasses.replace(note,start=start,end=end,pitch=pitch,event_id=identifier) + result.append(actual) + if (start,end,pitch) != (note.start,note.end,note.pitch): + edits.append(types.DraftEdit('harmonic_adaptation',start,end,(note.event_id,),(identifier,), + f'{note.pitch.label()} to {pitch.label()}: sounding duration / {harmony.at(start).chord}')) + if result: + last=result[-1] + domain=available(harmony,last.start,last.end,members=True) + if not domain: + return (),() + if last.pitch.number()%12 not in harmony.members(last.start,last.end): + pitch=min(domain,key=lambda p:(abs(p.number()-last.pitch.number()),p.number())) + result[-1]=dataclasses.replace(last,pitch=pitch) + edits.append(types.DraftEdit('harmonic_destination',last.start,last.end,(last.event_id,),(last.event_id,), + f'{last.pitch.label()} to {pitch.label()}: final sounding arrival before release')) + return tuple(result),tuple(edits) + + +def identifying(notes, source): + """A complete heard figure, chosen by its musical content rather than its index.""" if not notes: - return None - first = next((g for g in groups if g[2] in ('statement', 'recall', 'answer_theme')), groups[0]) - home_notes = tuple(n for n in notes if first[0] <= n.start < first[1]) - home = realization.make_identity(first[3], identity.id, home_notes, first[0], first[1], first[2], harmony) - transformations, features, pairs = [], [], [] - parent = incoming.development.id if incoming.development else identity.id - for start, end, operation, group_id in groups: - actual = tuple(n for n in notes if start <= n.start < end) - if not actual: - continue - directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(actual, actual[1:])) - transformations.append(types.Transformation(group_id, parent, home.id, operation, start, end, tuple(n.event_id for n in actual), tuple((n.start - start, n.end - start) for n in actual), directions, (), identity.source)) - parent = group_id - if operation in ('statement', 'recall', 'answer_theme', 'repeat_with_harmonic_adaptation') and len(actual) == len(identity.degrees): - features.append(types.IdentifyingFeature(group_id + ':hook', tuple(n.event_id for n in actual), tuple((n.start - actual[0].start, n.end - n.start) for n in actual), tuple(b.pitch.number() - a.pitch.number() for a, b in zip(actual, actual[1:])), identity.id)) - if operation == 'repeat_with_harmonic_adaptation': - pairs.extend((a.event_id, b.event_id) for a, b in zip(home_notes, actual)) - anchors = [] - for note, role in ((notes[0], 'opening'), *((n, 'intermediate') for n in notes[1:-1] if any(n.start == g[0] and g[2] == 'repeat_with_harmonic_adaptation' for g in groups)), (notes[-1], 'terminal')): - span = harmony.at(note.start) - if note.pitch.number() % 12 not in span.roles.basic: - if role != 'terminal': - continue - return None - anchors.append(types.Anchor(note.event_id + ':anchor', note.start, note.start, note.pitch, span.roles.basic, plan.register, note.end - note.start, role, span.chord, hold_until=note.end)) - last = notes[-1] - passages = (types.ActivitySpan(plan.start, last.end, 'active', plan.activity, F('0.925')),) - if last.end < plan.end: - passages += (types.ActivitySpan(last.end, plan.end, 'space', F(), F()),) - motif = types.Motif(home.id, home.notes, home.duration, tuple(s.chord for s in harmony.spans if s.start < first[1] and s.end > first[0]), 'invented paired lead: ' + identity.id) - actual_plan = dataclasses.replace(plan, motif=motif, passages=passages, release=plan.end - last.end, landmarks=(), anchor_options=(tuple(anchors),), ending='breath' if last.end < plan.end else 'sustain', closure='closed') - draft = types.MelodicDraft(plan.id + ':' + identity.id + ':' + policy.stable_rank(plan.seed, tuple(n.pitch for n in notes))[:10], plan.start, plan.end, notes, tuple(transformations), tuple(a.id.removesuffix(':anchor') for a in anchors), (), tuple(anchors), home, first[2], tuple(g[0] for g in groups[1:]), tuple(features), identity.source, motif, identity, tuple(pairs)) - phrase = realization.checked_phrase(actual_plan, draft, notes, (), incoming, harmony, tempo) - if phrase: - state = dataclasses.replace(phrase.state, lead_identity=identity) - # Different complete musical constructions compete before any ornament. - return dataclasses.replace(phrase, state=state, score=phrase.score + fit, initial=draft, refined=draft) + return () + count = min(4,max(1,len(notes)//2)) + windows = [notes[i:i+count] for i in range(len(notes)-count+1)] + def rank(group): + intervals = tuple(b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:])) + rhythm = {(n.end-n.start) for n in group} + # Favor a rhythm/turn with information; leave long holds available for development. + return (-len(rhythm),-len(set(intervals)),sum(max(F(),n.end-n.start-1) for n in group),group[0].start) + group = min(windows,key=rank) + return (types.IdentifyingFeature(source+':figure',tuple(n.event_id for n in group), + tuple((n.start-group[0].start,n.end-n.start) for n in group), + tuple(b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:])),source),) -def candidates(plan, measures, choices, incoming, tempo, context): - from generator.solo import realization, sequences - harmony_key = ('lead_harmony', policy.stable_rank('harmony', choices)) - if harmony_key not in context.resources: - context.resources[harmony_key] = harmonic.Harmony(choices) - harmony = context.resources[harmony_key] - active = [s for s in harmony.spans if s.scale and s.start < plan.end and s.end > plan.start] - if not active: - silent_plan = dataclasses.replace(plan, passages=(types.ActivitySpan(plan.start, plan.end, 'space', F(), F()),), release=plan.end-plan.start, landmarks=(), anchor_options=()) - draft = types.MelodicDraft(plan.id + ':space', plan.start, plan.end, (), (), (), (), (), None, 'space') - phrase = realization.checked_phrase(silent_plan, draft, (), (), incoming, harmony, tempo) - return (dataclasses.replace(phrase, initial=draft, refined=draft),) if phrase else () - finish = min(plan.end, active[-1].end) - boundaries, cursor = [], F() - for measure in measures: - length = generator.model.fraction(measure['duration']) - if plan.start <= cursor < finish: - boundaries.append((cursor, min(cursor + length, finish))) - cursor += length - result = [] - for identity in identities(context.references, incoming): - states = [(0., (), (), ())] - for index, (start, end) in enumerate(boundaries): - final = index == len(boundaries) - 1 - hook = index in (0, 2) and not final - returning = hook and index == 2 - operation = ('recall' if incoming.lead_identity == identity else 'answer_theme' if incoming.lead_identity else 'statement') if index == 0 else 'repeat_with_harmonic_adaptation' if returning else 'close' if final else 'extend' if index >= 4 else 'answer' - # Paired answers retain the hook's direction of departure. Ending - # alternatives supply timing and pitches together, not inserted holds. - specs = [(identity.durations, identity.degrees)] if hook else [((F(1), F(1, 2), F(1, 2), F(1), F(1)), (0, 1, 2, 1, -1)), ((F(2), F(1, 2), F(1, 2), F(1)), (0, 1, 2, 1))] - if index >= 4 and not final: - reference = context.references[0] - profiles = [d for d in reference.development if d[1] == index * 4 and d[3]] - if profiles: - _, low, high, degrees = profiles[0] - source = tuple(n for n in reference.notes if low <= n.start < high) - specs = [(tuple(n.end - n.start for n in source), degrees)] - if final: - specs = [((F(1), F(1, 2), F(1, 2), F(2)), (0, -1, -2, -1)), ((F(1, 2), F(1, 2), F(1), F(1)), (0, 2, 1, 0))] - if end - start < 2: - specs = [((end - start,), (0,))] - elif end - start < 4: - specs = [((F(1, 2), end - start - F(1, 2)), (0, -1)), ((F(1), end - start - 1), (0, 1))] - following = [] - for cost, notes, groups, home in states: - for variant, (durations, degrees) in enumerate(specs): - release = F(1) if final and variant == 1 and end - start >= 4 else F() - spans = timings(start, end - start - release, durations, end - release, harmony) - identifier = f'{plan.id}:{identity.id}:{index}:{variant}' - previous = notes[-1] if notes else incoming.last - heard = home if returning else incoming.home.notes if index == 0 and incoming.home and incoming.lead_identity == identity else () - for local_cost, actual in group_options(spans, degrees, plan.register, harmony, previous, identifier, home=heard, terminal=final): - if not actual: - following.append((cost, notes, groups, home)) - continue - following.append((cost + local_cost, (*notes, *actual), (*groups, (start, end, operation, identifier)), actual if index == 0 else home)) - states = sorted(following, key=lambda v: (v[0], tuple(n.pitch for n in v[1])))[:policy.DEFAULT.lead_realizations] - context.diagnostics['evaluated_extensions'] += len(following) - context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states)) - if not states: - context.diagnostics.setdefault('lead_failures', []).append({'phrase': plan.id, 'identity': identity.id, 'start': str(start), 'end': str(end), 'operation': operation, 'reason': 'no complete figure satisfies the harmonic, duration and return constraints within the bounded candidate pool'}) - break - for cost, notes, groups, home in states: - if not notes: - continue - phrase = make_phrase(plan, notes, groups, identity, incoming, harmony, tempo, cost / max(1, len(groups))) - if phrase: - # A heard identity is a real option across passages, not obligatory - # perpetual development of whichever fragment happened most recently. - if incoming.lead_identity and identity != incoming.lead_identity: - phrase = dataclasses.replace(phrase, score=phrase.score + .15) - result.append(phrase) - context.diagnostics['generated_candidates'] += len(result) - return sequences.diverse(result, lambda p: (p.score, policy.stable_rank(plan.seed, p.draft.id)), lambda p: p.draft.lead_identity.id, policy.DEFAULT.phrase_alternatives) -def approach_pitch(target, number): - """Spell a semitone neighbor using the adjacent diatonic letter.""" - letters = generator.model.LETTERS - step = 1 if number > target.number() else -1 - index = letters.index(target.letter) + step - octave, degree = divmod(index, 7) - candidates = [] - for letter, octave in ((letters[degree], target.octave + octave), (target.letter, target.octave)): - natural = generator.model.NATURAL[letter] + 12 * (octave + 1) - candidates.append(types.Pitch(letter, F(number - natural), octave)) - # A natural above A-flat is clearer than B-double-flat; an ordinary - # leading tone still uses its adjacent letter (D-sharp into E). - return min(candidates, key=lambda p: abs(p.alter)) - -def chromatic(phrase, incoming, harmony, tempo): - from generator.solo import realization - notes = realization.emitted_notes(phrase) - protected = {*phrase.draft.protected, *phrase.draft.structural, *(e for f in phrase.draft.features for e in f.events)} - budget = min(policy.DEFAULT.chromatic_max_per_eight_bars, max(1, int((phrase.plan.end - phrase.plan.start) / 8))) - proposals = [] - for index, (note, target) in enumerate(zip(notes, notes[1:])): - if note.event_id in protected or note.end - note.start != F(1, 2) or note.start % 1 != F(1, 2) or note.end != target.start or len(harmony.segments(note)) != 1: - continue - span, destination = harmony.at(note.start), harmony.at(target.start) - if not span or not span.scale or not destination or target.pitch.number() % 12 not in destination.roles.basic: - continue - for number in (target.pitch.number() - 1, target.pitch.number() + 1): - if number % 12 in span.scale.pitches or not phrase.plan.register[0] <= number <= phrase.plan.register[1]: - continue - pitch = approach_pitch(target.pitch, number) - treatment = types.HarmonicTreatment(note.event_id + ':chromatic', 'chromatic_approach', number, note.start, note.end, target.pitch.number(), target.start, F(), note.event_id, target.event_id) - smooth = abs(number - (notes[index - 1].pitch.number() if index else note.pitch.number())) - proposals.append((smooth, abs(number - note.pitch.number()), note.start, index, pitch, treatment)) - chosen_times, used_routes = [], set() - previous_times = [t.start for t in incoming.treatments if t.kind == 'chromatic_approach'] - original_score = phrase.score - for _, _, _, index, pitch, treatment in sorted(proposals, key=lambda p: p[:4]): - if len(chosen_times) >= budget or any(abs(treatment.start - t) < 8 for t in (*previous_times, *chosen_times)): - continue - route_ids = {r.id for r in phrase.routes if any(n.event_id == treatment.event for n in r.notes)} - if route_ids & used_routes: - continue - original = realization.emitted_notes(phrase) - changed = tuple(dataclasses.replace(n, pitch=pitch, position=None, role='chromatic_approach') if n.event_id == treatment.event else n for n in original) - contracts = (*phrase.state.treatments, treatment) - if not harmony.evaluate(changed, contracts, complete=True).valid: - continue - draft = realization.rebase_draft(phrase.draft, changed, harmony) - edit = types.DraftEdit('chromatic_approach', treatment.start, treatment.deadline, (treatment.event,), (treatment.event,), 'semitone resolution to ' + treatment.resolution_event) - checked = realization.checked_phrase(phrase.plan, draft, changed, contracts, incoming, harmony, tempo, (*phrase.edits, edit)) - if checked: - routes = tuple(dataclasses.replace(r, notes=tuple(dataclasses.replace(n, pitch=pitch, position=None, role='chromatic_approach') if n.event_id == treatment.event else n for n in r.notes), relationship=r.relationship + ('; chromatic approach' if r.id in route_ids else '')) for r in phrase.routes) - phrase = dataclasses.replace(checked, score=original_score, initial=phrase.initial, refined=phrase.refined, routes=routes, state=dataclasses.replace(checked.state, lead_identity=phrase.state.lead_identity)) - chosen_times.append(treatment.start) - used_routes.update(route_ids) - return phrase - -def alternatives(plan, measures, choices, sequences, tempo, previous, context): - from generator.solo import realization - incoming = previous if isinstance(previous, types.ContinuationState) else types.ContinuationState('', last=previous) - context.resources[id(sequences)] = sequences - key = ('paired-lead', plan, incoming, policy.stable_rank('harmony', choices), tuple(generator.model.fraction(m['duration']) for m in measures), tempo, id(sequences), tuple(r.version for r in context.references), policy.describe()['fingerprint'], context.stage) - if key in context.results: - context.diagnostics['cache_hits'] += 1 - return context.results[key] - started = time.monotonic() - context.diagnostics['searches'] += 1 - complete = candidates(plan, measures, choices, incoming, tempo, context) - if not complete: - raise realization.SearchExhausted(f'{plan.id}: no complete paired lead reaches the terminal harmony within {policy.DEFAULT.lead_identities} identities / {policy.DEFAULT.lead_realizations} realizations; not proof of incompatibility') - harmony = harmonic.Harmony(choices) - result = [] - for phrase in complete: - score, identity = phrase.score, phrase.state.lead_identity - if context.stage != 'initial': - # The complete plain tune is selected by the same score in all stages. - # Ornament quality cannot rescue a weak underlying idea. - phrase = realization.elaborate(phrase, incoming, harmony, sequences, tempo, context) - diatonic = realization.rebase_draft(phrase.draft, realization.emitted_notes(phrase), harmony) - phrase = dataclasses.replace(phrase, refined=diatonic, score=score, state=dataclasses.replace(phrase.state, lead_identity=identity)) - if context.stage == 'elaborated': - phrase = chromatic(phrase, incoming, harmony, tempo) - result.append(dataclasses.replace(phrase, score=score)) - context.diagnostics['construction_seconds'] = context.diagnostics.get('construction_seconds', 0) + time.monotonic() - started - context.diagnostics.setdefault('complete_ideas', {})[policy.stable_rank('incoming', (plan.id, incoming))[:16]] = [{'id': p.draft.id, 'identity': p.draft.lead_identity.id if p.draft.lead_identity else 'space', 'score': p.score, 'attacks': len(p.initial.notes), 'return_pairs': len(p.draft.correspondence), 'edits': [e.kind for e in p.edits]} for p in result] - context.results[key] = tuple(result) +def variations(notes, source, harmony): + """One purposeful timing or pitch edit; no generic replacement openings.""" + result=[] + if source.pickup and notes and source.start==0: + length=-source.pickup[0].start + if notes[0].end-length>=F(1,2): + pickup=tuple(dataclasses.replace(n,start=n.start+length,end=n.end+length) for n in source.pickup) + changed=(*pickup,dataclasses.replace(notes[0],start=length),*notes[1:]) + if harmony.evaluate(changed,complete=True).valid: + result.append((changed,(types.DraftEdit('pickup_entrance',F(),notes[0].end,(notes[0].event_id,), + tuple(n.event_id for n in pickup)+(notes[0].event_id,),source.id+': bring source pickup inside the solo and shorten its first hold'),))) + for i,(before,note) in enumerate(zip(notes,notes[1:]),1): + if before.end == note.start and before.end-before.start >= 1 and note.end-note.start >= F(1,2): + for displacement in (F(-1,2),F(1,2)): + time = note.start+displacement + if time-before.start < F(1,2) or note.end-time < F(1,2): + continue + changed=(*notes[:i-1],dataclasses.replace(before,end=time),dataclasses.replace(note,start=time),*notes[i+1:]) + if harmony.evaluate(changed,complete=True).valid: + edit=types.DraftEdit('rhythmic_paraphrase',before.start,note.end,(before.event_id,note.event_id),(before.event_id,note.event_id), + f'{source.id}: retain both pitches and their order; move shared boundary {displacement} beat') + result.append((changed,(edit,))) + # Structural color is chosen in relation to the original turn, never a new random contour. + if 0 < i < len(notes)-1: + after=notes[i+1] + for p in available(harmony,note.start,note.end,members=note.end-note.start>=1): + if p == note.pitch or abs(p.number()-note.pitch.number())>2: + continue + direction=lambda a,b:(b>a)-(b= F(3,2): + tail=dataclasses.replace(notes[-1],end=notes[-1].end-F(1,2)) + result.append(((*notes[:-1],tail),(types.DraftEdit('ending_release',tail.end,notes[-1].end,(tail.event_id,),(tail.event_id,),source.id+': half-beat breath after the arrival'),))) return tuple(result) + + +def rhythm_catalog(library, meter): + # Keep complete snippet objects. Slices below retain real rests and durations. + return drafts.catalog(library,meter) + + +def connection_patterns(start,end,entry,destination,rhythms,meter): + length=end-start + spans={( (F(),length), ):'held'} + for identifier,pattern,total in rhythm_catalog(rhythms,meter): + if total < length: + continue + # Retrieve every exact-duration window, including offbeat and cross-bar windows. + for offset in sorted({F(),*(a for a,_ in pattern)}): + if offset+length>total: + continue + if any(a=2: + result.append(((*events,destination),identifier)) + return result + + +def elaborate(notes, features, harmony, library, rhythms, meter, source, *, sustain_entry=False): + """Edit an exposed held tail or a complete interior span, with both endpoints known.""" + fixed={e for f in features for e in f.events} + windows=[] + for i,(note,target) in enumerate(zip(notes,notes[1:])): + if note.event_id in fixed or note.end != target.start: + continue + if note.end-note.start>=F(3,2): + start=max(note.start+F(1,2),target.start-3) + windows.append((i,start,target,False)) + # A source-held ending can arrive later via a related in-scale turn. + if notes and notes[-1].event_id not in fixed and notes[-1].end-notes[-1].start>=F(5,2): + tail=notes[-1] + for duration in (F(1,2),F(1),F(3,2),F(2)): + target=dataclasses.replace(tail,start=tail.end-duration,event_id=tail.event_id+':arrival') + start=max(tail.start+F(1,2),target.start-3) + if target.start-start>=1: + windows.append((len(notes)-1,start,target,True)) + result=[] + for i,start,target,terminal in windows: + entry=notes[i];end=target.start + prefix=dataclasses.replace(entry,end=start) + seed=dataclasses.replace(entry,start=start,event_id=entry.event_id+':tail',role='connection') + for pattern,rhythm_id in connection_patterns(start,end,seed,target,rhythms,meter): + for route in routes.candidates(pattern,{target.event_id},harmony,library,(41,76),'develop '+source.id): + if sustain_entry: + # The route owns its complete sounding entry, including the old hold. + joined=dataclasses.replace(route.notes[0],start=entry.start,event_id=entry.event_id) + route=dataclasses.replace(route,notes=(joined,*route.notes[1:])) + actual=(*notes[:i],*route.notes,*notes[i+1 if terminal else i+2:]) + else: + actual=(*notes[:i],prefix,*route.notes,*notes[i+1 if terminal else i+2:]) + if not harmony.evaluate(actual,complete=True).valid: + continue + if any(abs(b.pitch.number()-a.pitch.number())>7 for a,b in zip(route.notes,route.notes[1:])): + continue + route=dataclasses.replace(route,sources=(*route.sources,'rhythm:'+rhythm_id)) + edit=types.DraftEdit('complete_route',start,target.start,(entry.event_id,),tuple(n.event_id for n in route.notes), + source.id+'; '+rhythm_id+'; '+('later terminal arrival on ' if terminal else 'lead into ')+target.pitch.label()) + result.append((actual,edit,route)) + # Whole routes compete; bounded diversity retains different locations and rhythms. + return sequences.diverse(result,lambda v:(abs(len(v[0])*4/float(source.end-source.start)-5), + sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(v[2].notes,v[2].notes[1:])),v[2].id), + lambda v:(v[1].start,v[2].destination.end-v[2].destination.start,len(v[2].notes)),8) + + +def make_phrase(source, original, refined, notes, edits, route_list, octave, part, harmony, tempo, seed): + from generator.solo import realization + features=identifying(refined,source.id) + # Only fully retained source turns become committed anchor events. + anchors=[] + for i,n in enumerate(notes): + if n.pitch.number()%12 not in harmony.members(n.start,n.end): + continue + role='terminal' if i==len(notes)-1 else 'opening' if i==0 else 'intermediate' + if role=='intermediate' and n.end-n.start<2: + continue + anchors.append(types.Anchor(n.event_id+':anchor',n.start,n.start,n.pitch,harmony.members(n.start,n.end), + (41,76),n.end-n.start,role,harmony.at(n.start).chord,hold_until=n.end)) + if notes and (not anchors or anchors[-1].role!='terminal'): + return None + identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,signature(notes))[:12] + motif=types.Motif(source.id,tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start) for n in refined), + source.end-source.start,tuple(s.chord for s in harmony.spans if s.startsource.start),'song melody '+source.family) + home=realization.make_identity(identifier,source.id,refined,source.start,source.end,'source_variation',harmony) + transform=types.Transformation(identifier,source.id,source.family,'source_variation',source.start,source.end, + tuple(n.event_id for n in notes),tuple((n.start-source.start,n.end-source.start) for n in notes),preserves=(),source=source.id) + draft=types.MelodicDraft(identifier,source.start,source.end,notes,(transform,), + tuple(a.id.removesuffix(':anchor') for a in anchors),(),tuple(anchors),home,'source_variation',features=features, + reference=source.id,reference_motif=motif,source_phrase=source,octave_transfer=octave, + source_links=tuple((n.event_id,n.sources) for n in notes)) + spaces=[];cursor=source.start + for n in notes: + if n.start>cursor:spaces.append(types.ActivitySpan(cursor,n.start,'space',F(),F())) + spaces.append(types.ActivitySpan(n.start,n.end,'active',F(5,4),F())) + cursor=n.end + if cursor=end:break + finish=min(source.end,end) + notes=tuple(dataclasses.replace(n,start=max(F(),n.start),end=min(n.end,finish)) for n in source.notes if n.start0) + result.append(dataclasses.replace(source,end=finish,notes=notes,rests=tuple((a,min(b,finish)) for a,b in source.rests if asource.start for c in choices): + pool=() + for batch in range(2): + pool=themes.phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,analysis.tempo,context,seed,stage=stage,batch=batch,final=source.end==end) + if pool:break + context.diagnostics['theme_replans']=context.diagnostics.get('theme_replans',0)+1 + elif mode=='thematic': + pool=themes.silence(source,incoming,part,harmony,analysis.tempo,seed) + else: + pool=candidates(source,harmony,library,rhythms,meter,part,analysis.tempo,seed,stage=stage) + context.results[key]=pool;context.diagnostics['generated_candidates']+=len(pool) + context.diagnostics.setdefault('source_candidate_pools', {}).setdefault(source.id,[]).extend( + {'id':p.draft.id,'score':p.score,'octave':p.draft.octave_transfer,'attacks':len(p.draft.notes),'edits':[e.kind for e in p.edits],'source_family':source.family,'theme':p.state.home.id if p.state.home else None} for p in pool) + for candidate in pool: + # The thematic constructor has already placed all decisions from the + # actual incoming state; do not collapse its motif groups into one bar. + if mode=='thematic': + total=cost+candidate.score*float(source.end-source.start)/float(end)+transition(history[-1] if history else None,candidate) + following.append((total,(*history,candidate))) + continue + last=incoming.last + placements=placement.alternatives(tuple(n for d in candidate.decisions for n in d.notes),analysis.tempo,last,limit=2) + for travel,notes in placements: + decision=dataclasses.replace(candidate.decisions[0],notes=notes) if candidate.decisions else None + state=dataclasses.replace(candidate.state,notes=notes,last=notes[-1] if notes else last,travel=travel) + placed=dataclasses.replace(candidate,decisions=(decision,) if decision else (),state=state) + total=cost+candidate.score*float(source.end-source.start)/float(end)+.05*travel/max(1,len(notes))/2000+transition(history[-1] if history else None,placed) + following.append((total,(*history,placed))) + if not following: + raise realization.SearchExhausted(f'{part} {source.id}: {mode} feature, harmonic destination or continuation failed within the bounded search; not proof of incompatibility') + states=sequences.diverse(following,lambda v:(v[0],policy.stable_rank(seed,tuple((p.draft.id,p.state.last.position if p.state.last else None) for p in v[1]))), + lambda v:(v[1][-1].state.home.id if mode=='thematic' and v[1][-1].state.home else tuple(p.draft.octave_transfer for p in v[1])),policy.DEFAULT.chorus_frontier) + return tuple((cost,types.Solo(part,choices,phrases,analysis.tempo,end,{**context.diagnostics,'construction':'thematic_recomposition' if mode=='thematic' else 'source_phrase_variations','policy':policy.describe()['fingerprint']}, + tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases))) for cost,phrases in states) + + +def similarity(first,second): + """Compare composition independently of octave and fingering; callbacks are explicit.""" + def events(solo): + callbacks={e for p in solo.phrases if p.draft for f in p.draft.features if f.id.endswith(':callback') for e in f.events} + return tuple(n for p in solo.phrases if p.plan.relationship!='callback' for d in p.decisions for n in d.notes if n.event_id not in callbacks) + a,b=events(first),events(second) + def tokens(notes): + return {(n.start,n.end,n.pitch.number()%12) for n in notes} + left,right=tokens(a),tokens(b) + literal=len(left&right)/max(1,min(len(left),len(right))) + def runs(notes): + return {tuple((n.start-notes[i].start,n.end-n.start,n.pitch.number()-notes[i].pitch.number()) for n in notes[i:i+4]) for i in range(len(notes)-3)} + left,right=runs(a),runs(b) + interval=len(left&right)/max(1,min(len(left),len(right))) + return .65*literal+.35*interval diff --git a/generator/solo/placement.py b/generator/solo/placement.py index 8ac607a..7b90a56 100644 --- a/generator/solo/placement.py +++ b/generator/solo/placement.py @@ -19,25 +19,63 @@ def transition(previous, current, duration, tempo): # Movement is a preference, not an unsupported claim about a player's limits. return round(100 * distance / max(seconds, 0.05)) +def hand_positions(position): + return tuple(h for h in range(1, 10) if h <= position.fret <= h + 3) + + +@functools.lru_cache(maxsize=512) +def position_rank(position): + from generator.solo.policy import stable_rank + return stable_rank('guitar-position', position) + +def stable_path(path): + return tuple(position_rank(n.position) for n in path) + + @functools.lru_cache(maxsize=4096) -def place(notes: tuple[generator.solo.types.Note, ...], tempo: fractions.Fraction, previous: generator.solo.types.Note | None = None) -> tuple[int, tuple[generator.solo.types.Note, ...]] | None: - states = [(0, ())] +def alternatives(notes, tempo, previous=None, limit=2): + """Phrase paths with a four-fret hand region and distinct endpoint choices.""" + states = [(0, (), h) for h in hand_positions(previous.position)] if previous and previous.position else [(0, (), None)] for note in notes: - following = [] + following = {} for position in positions(note.pitch.number()): current = dataclasses.replace(note, position=position) - candidates = [] - for cost, path in states: - prior = path[-1] if path else previous - if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start): - continue - candidates.append((cost + (0 if note.continuation else travel(prior, current, tempo)), (*path, current))) - if candidates: - following.append(min(candidates, key=lambda item: (item[0], tuple(n.position for n in item[1])))) + for hand in hand_positions(position): + best = None + for cost, path, old_hand in states: + prior = path[-1] if path else previous + if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start): + continue + movement = 0 + if prior and not note.continuation: + seconds = max(.05, float((note.start-prior.start)*60/tempo)) + distance = abs(hand-old_hand) if old_hand is not None else 0 + distance += .25*abs(position.fret-prior.position.fret) + .5*abs(position.string_number-prior.position.string_number) + movement = round(100*distance/seconds) + candidate = (cost+movement, (*path,current), hand) + if best is None or candidate[0] < best[0] or candidate[0] == best[0] and stable_path(candidate[1]) < stable_path(best[1]): + best = candidate + if best is not None: + following[position,hand] = best if not following: - return None - states = following - return min(states, key=lambda item: (item[0], tuple(n.position for n in item[1]))) + return () + states = list(following.values()) + # Endpoints survive so the following phrase can choose a better transition. + selected, seen = [], set() + for cost,path,hand in sorted(states,key=lambda v:(v[0],stable_path(v[1]))): + endpoint = path[-1].position if path else None + if endpoint in seen: + continue + seen.add(endpoint); selected.append((cost,path)) + if len(selected) == limit: + break + return tuple(selected) + + +@functools.lru_cache(maxsize=4096) +def place(notes, tempo, previous=None): + result = alternatives(notes,tempo,previous,1) + return result[0] if result else None @functools.lru_cache(maxsize=128) def positions(number: int): diff --git a/generator/solo/policy.py b/generator/solo/policy.py index 577b008..39ce9ea 100644 --- a/generator/solo/policy.py +++ b/generator/solo/policy.py @@ -33,9 +33,14 @@ class Policy: occupancy_deadband: float = 0.1 lead_activity_range: tuple[float, float] = (1.0, 1.5) lead_occupancy_range: tuple[float, float] = (0.85, 1.0) - lead_identities: int = 12 - lead_realizations: int = 2 - chromatic_max_per_eight_bars: int = 4 + source_variations: int = 8 + chorus_frontier: int = 8 + theme_copy_threshold: float = 0.8 + theme_copy_beats: float = 8.0 + theme_copy_attacks: int = 4 + theme_seed_limit: int = 8 + theme_unit_alternatives: int = 8 + composition_version: str = 'thematic-recomposition-1' # Preserve the former default root seeds while decoupling them from policy edits. SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1' diff --git a/generator/solo/realization.py b/generator/solo/realization.py index e6c8313..f753e15 100644 --- a/generator/solo/realization.py +++ b/generator/solo/realization.py @@ -524,7 +524,7 @@ def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edit return None features = evaluation.features(score_notes, previous=incoming.last if not shared else None, movement=travel + (incoming.travel if shared else 0)) features.update(generator.solo.drafts.relationship_features(draft, notes)) - if draft.lead_identity: + if draft.lead_identity or draft.source_phrase: supported = {e for feature in draft.features for e in feature.events} motions = [(a, b) for a, b in zip(notes, notes[1:]) if not (a.event_id in supported and b.event_id in supported)] features['contour'] = sum(min(1, max(0, abs(b.pitch.number() - a.pitch.number()) - 5) / 12) for a, b in motions) / max(1, len(motions)) @@ -861,8 +861,7 @@ def revise_pair(first, second, incoming, choices, sequences, tempo, context): def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None): context = context if context is not None else SearchContext() if plan.treatment == 'lead': - from generator.solo import leads - return leads.alternatives(plan, measures, choices, sequences, tempo, previous, context) + raise ValueError('generic lead identities are retired; use composer.compose for source variations') # Keep resources alive while identity-keyed cache entries exist. A later # library cannot accidentally reuse the identity of a collected object. context.resources[id(sequences)] = sequences diff --git a/generator/solo/references.py b/generator/solo/references.py index 7096f17..16ec1a1 100644 --- a/generator/solo/references.py +++ b/generator/solo/references.py @@ -93,14 +93,24 @@ def identifying_features(notes, source): def feature_error(feature, notes): by_id = {n.event_id: n for n in notes} actual = [by_id.get(identity) for identity in feature.events] - if not all(actual): + if not actual or not all(actual): return 1.0 first = actual[0].start rhythm = tuple((n.start - first, n.end - n.start) for n in actual) movement = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(actual, actual[1:])) - timing = sum(a != b for a, b in zip(rhythm, feature.rhythm)) / max(1, len(rhythm)) - contour = sum(((a > 0) - (a < 0)) != ((b > 0) - (b < 0)) or abs(a - b) > 2 for a, b in zip(movement, feature.intervals)) / max(1, len(movement)) - return (timing + contour) / 2 + errors = [] + if 'rhythm' in feature.preserves: + errors.append(1.0 if len(rhythm) != len(feature.rhythm) else sum(a != b for a,b in zip(rhythm,feature.rhythm))/max(1,len(rhythm))) + if 'intervals' in feature.preserves or 'contour' in feature.preserves: + if len(movement) != len(feature.intervals) or not movement and feature.intervals: + errors.append(1.0) + else: + errors.append(sum(((a>0)-(a<0)) != ((b>0)-(b<0)) or 'intervals' in feature.preserves and abs(a-b)>2 for a,b in zip(movement,feature.intervals))/max(1,len(movement))) + if 'exact_intervals' in feature.preserves: + errors.append(float(movement != feature.intervals)) + if 'pitches' in feature.preserves: + errors.append(float(tuple(n.pitch.number() for n in actual) != feature.pitches)) + return max(errors,default=0.0) def relationship(notes, reference): """One joint relationship cost: timed movement, not absolute-pitch copying.""" diff --git a/generator/solo/report.py b/generator/solo/report.py index 8971113..6836146 100644 --- a/generator/solo/report.py +++ b/generator/solo/report.py @@ -12,6 +12,8 @@ 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') @@ -27,12 +29,18 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off 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 == 'lead': - lines.append(' Lead range: 4–6 attacks per four beats across the passage; active occupancy 85–100%; local breaths and runs allowed.') + 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: @@ -62,7 +70,7 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off 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 'chromatic approach' if note.role == 'chromatic_approach' else 'in-collection connection' + 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']: @@ -99,6 +107,30 @@ def structure(solo): 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 {'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])} + 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)} diff --git a/generator/solo/routes.py b/generator/solo/routes.py index f716d65..871856c 100644 --- a/generator/solo/routes.py +++ b/generator/solo/routes.py @@ -5,6 +5,7 @@ import dataclasses import fractions import generator.fretboard +import generator.solo.placement import generator.solo.policy import generator.solo.sequences import generator.solo.types as types @@ -25,12 +26,24 @@ def advance(scale, previous, step): return tuple(outgoing[abs(step) - 1:abs(step)]) def candidates(notes, fixed, harmony, library, register, relationship='connect theme'): + # Cache concrete routes only with their endpoints, timing, scale context and + # vocabulary. The per-Harmony cache cannot leak across collection changes. + if not hasattr(harmony,'route_cache'): + harmony.route_cache={} + harmony.route_libraries={} + harmony.route_libraries[id(library)]=library + key=(notes,frozenset(fixed),id(library),register) + if key not in harmony.route_cache: + harmony.route_cache[key]=_candidates(notes,fixed,harmony,library,register) + return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.route_cache[key]) + +def _candidates(notes, fixed, harmony, library, register): """Every returned candidate includes its entry, complete rhythm and destination. Callers validate the whole surrounding sounding context and guitar path. This function applies exact scale paths, fixed events and range constraints. """ - if not 3 <= len(notes) <= 16: + if not 2 <= len(notes) <= 16: return () first, destination = notes[0], notes[-1] span = harmony.at(first.start) @@ -62,14 +75,14 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t break if (template.event_id in fixed or template is destination) and pitch.number() != template.pitch.number(): break - if not generator.fretboard.single_positions_for_pitch(fractions.Fraction(pitch.number())): + if not generator.solo.placement.positions(pitch.number()): break realized.append(dataclasses.replace(template, pitch=template.pitch if pitch.number() == template.pitch.number() else pitch, position=None)) if len(realized) != len(notes): continue route_spans = tuple((max(first.start, c.span.start), min(destination.end, c.span.end), c.scale.label()) for c in harmony.choices if c.scale and c.span.start < destination.end and c.span.end > first.start) identity = generator.solo.policy.stable_rank('complete-route', (tuple(realized), path.sources))[:16] - result.append(types.RouteCandidate(identity, tuple(realized), destination, route_spans, relationship, path.sources)) + result.append(types.RouteCandidate(identity, tuple(realized), destination, route_spans, 'connect theme', path.sources)) return generator.solo.sequences.diverse(result, lambda r: (sum(abs(b.pitch.number() - a.pitch.number()) for a, b in zip(r.notes, r.notes[1:])), r.id), lambda r: generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(r.notes, r.notes[1:]))), diff --git a/generator/solo/scoring.py b/generator/solo/scoring.py index ec5add4..576deb4 100644 --- a/generator/solo/scoring.py +++ b/generator/solo/scoring.py @@ -81,7 +81,7 @@ class Evaluation: deviation = max(0, abs(attacks - expected) - max(tolerance, generator.solo.policy.DEFAULT.activity_deadband * length / self.duration)) activity += length * min(1, deviation / max(1, expected)) occupancy += length * max(0, abs(occupied / length - occupied_goal) - generator.solo.policy.DEFAULT.occupancy_deadband) - if self.plan.treatment == 'lead': + if self.plan.treatment in ('lead', 'source_variation', 'thematic_recomposition'): # One broad range over the complete thought; quiet bars are not failed # local quotas. Missing harmony and explicit release are excluded here. active = tuple(window for window in self.windows if window[4] > 0) @@ -92,6 +92,8 @@ class Evaluation: activity = length * corridor(attacks / max(length, .001), low, high) / high low, high = generator.solo.policy.DEFAULT.lead_occupancy_range occupancy = length * corridor(occupied / max(length, .001), low, high) + if self.plan.treatment in ('source_variation', 'thematic_recomposition'): + occupancy = 0.0 # Space belongs to the source and its explicit edits. start, end = float(self.plan.start), float(self.until) durations = [max(0, min(b, end) - max(a, start)) for a, b, _ in sounded] register = sum(length * min(1, corridor(pitch, *self.plan.register) / 12) for (_, _, pitch), length in zip(sounded, durations)) / max(sum(durations), 0.001) diff --git a/generator/solo/similarity.py b/generator/solo/similarity.py new file mode 100644 index 0000000..2b5a8e6 --- /dev/null +++ b/generator/solo/similarity.py @@ -0,0 +1,92 @@ +"""Duration-aware comparisons of realized music, independent of event labels.""" +from __future__ import annotations + +import functools + +from generator.solo import types, policy + + +def events(notes): + return tuple((float(n.start),float(n.end),n.pitch.number()) for n in types.sounding_notes(notes)) + + +def occupancy(left, right, modulo=False): + """Intersection of pitch functions; repeated attacks cannot dilute a hold.""" + i=j=0; total=0. + while i=2] + return dict(pitch_time=occupancy(candidate,source)/denominator, + octave_insensitive=occupancy(candidate,source,True)/denominator, + attack_agreement=sum((a,b,p%12) in attacks for a,b,p in source)/max(1,len(source)), + arrival_agreement=sum((a,b,p%12) in attacks for a,b,p in arrivals)/max(1,len(arrivals)), + interval_rhythm_coverage=min(1.,length/denominator),longest_fragment_beats=length,longest_fragment_attacks=count) + + +def compare(candidate, source): + return compare_events(events(candidate),events(source)) + + +@functools.lru_cache(maxsize=8192) +def copy_evidence(candidate, source, start, end): + """Scan source-aligned windows, without an added-event denominator advantage.""" + minimum=policy.DEFAULT.theme_copy_beats + threshold=policy.DEFAULT.theme_copy_threshold + fragment,count=interval_match(candidate,source) + copied=fragment>=minimum and count>=policy.DEFAULT.theme_copy_attacks + best=0.; best_offset=0. + if end-start>=minimum and candidate: + windows=sorted({start,*(a for a,_,_ in candidate if start<=a<=end-minimum)}) + for left in windows: + right=min(end,left+minimum) + offsets={a[0]-b[0] for a in candidate if left<=a[0]left) + count_in=sum(left<=a+offsetbest:best,best_offset=score,offset + if score>=threshold:copied=True + return {'near_copy':copied,'aligned_occupancy':best,'alignment_offset':best_offset, + 'longest_fragment_beats':fragment,'longest_fragment_attacks':count} + + +def near_copy(notes, source, start, end): + return copy_evidence(events(notes),events(source),float(start),float(end)) diff --git a/generator/solo/themes.py b/generator/solo/themes.py new file mode 100644 index 0000000..3883d0d --- /dev/null +++ b/generator/solo/themes.py @@ -0,0 +1,395 @@ +"""Thematic transformations used by the existing complete-draft composer. + +Source features initialize a heard theme. Subsequent complete routes develop +that realization, conditioned on the actual incoming musical state. +""" +from __future__ import annotations + +import dataclasses +from fractions import Fraction as F + +import generator.model +import generator.harmony +from generator.solo import types, policy, sequences, routes, references, similarity + + +def source_notes(analysis): + return tuple(dataclasses.replace(n,start=max(F(),n.start-analysis.body_offset),end=n.end-analysis.body_offset, + event_id=n.sources[0],position=None) for n in analysis.melody if n.end>analysis.body_offset) + + +def windows(analysis, measures, end, harmony): + """Existing phrase representation; source extraction seams do not close it.""" + bounds=[F()] + for measure in measures:bounds.append(bounds[-1]+generator.model.fraction(measure['duration'])) + melody=source_notes(analysis); result=[]; index=0 + while bounds[index]start) + result.append(types.SourcePhrase(f'theme_window_{start}_{finish}',start,finish,notes,(),'')) + index=stop + # Explicit missing harmony interrupts sounding material, not thematic memory. + divided=[] + for window in result: + cuts=sorted({window.start,window.end,*(max(window.start,s.start) for s in harmony.spans if not s.scale and s.startwindow.start),*(min(window.end,s.end) for s in harmony.spans if not s.scale and s.startwindow.start)}) + for start,finish in zip(cuts,cuts[1:]): + notes=tuple(dataclasses.replace(n,start=max(start,n.start),end=min(finish,n.end),continuation=n.startstart) + divided.append(dataclasses.replace(window,id=f'theme_window_{start}_{finish}',start=start,end=finish,notes=notes)) + return tuple(divided) + + +def silence(source,incoming,part,harmony,tempo,seed): + from generator.solo import realization + motif=types.Motif('intentional_space',(),source.end-source.start,(),'explicit missing harmony') + plan=types.PhrasePlan(part+'_'+source.id,source.start,source.end,motif,'space','thematic_recomposition',None,(41,76),F(),F(), + (types.ActivitySpan(source.start,source.end,'space',F(),F()),),passage_id=source.id,passage_start=source.start,passage_end=source.end) + draft=types.MelodicDraft(plan.id,source.start,source.end,(),(),(),(),(),incoming.home,'space',source_phrase=source,source_reference=incoming.source_reference) + phrase=realization.checked_phrase(plan,draft,(),incoming.treatments,incoming,harmony,tempo) + if phrase is None:return () + state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks) + original=dataclasses.replace(draft,notes=source.notes) + return (dataclasses.replace(phrase,state=state,initial=original,refined=draft,score=0.),) + + +def short_fragment(source,incoming,part,harmony,tempo,seed,final): + """An explicitly reduced pickup around missing harmony; not a new theme.""" + from generator.solo import leads,realization + reference=incoming.home.notes if incoming.home else source.notes + if not reference:return () + template=dataclasses.replace(reference[0],start=source.start,end=source.end,position=None,continuation=False,event_id=source.id+':reduction') + result=[] + for pitch in pitches(harmony,template,incoming.last.pitch.number() if incoming.last else template.pitch.number(),members=True): + note=dataclasses.replace(template,pitch=pitch) + motif=types.Motif('reduction',reference,source.end-source.start,(),'explicit short fragment; no claim of full motif fidelity') + anchor=types.Anchor(note.event_id+':arrival',note.start,note.start,pitch,harmony.members(note.start,note.end),(41,76),note.end-note.start,'terminal' if final else 'intermediate',harmony.at(note.start).chord,hold_until=note.end) + transform=types.Transformation(note.event_id,reference[0].event_id,incoming.home.id if incoming.home else '', 'fragment',note.start,note.end,(note.event_id,),((F(),note.end-note.start),),preserves=()) + draft=types.MelodicDraft(part+'_'+note.event_id,source.start,source.end,(note,),(transform,),(note.event_id,),(),(anchor,),incoming.home,'fragment',source_phrase=source,source_reference=incoming.source_reference) + plan=types.PhrasePlan(draft.id,source.start,source.end,motif,'fragment','thematic_recomposition',None,(41,76),F(5,4),F(),ending='sustain' if final else 'connected',closure='closed' if final else 'open',passage_id=source.id,passage_start=source.start,passage_end=source.end) + phrase=realization.checked_phrase(plan,draft,(note,),incoming.treatments,incoming,harmony,tempo) + if phrase: + state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks) + result.append(dataclasses.replace(phrase,state=state,initial=draft,refined=draft)) + return tuple(result) + + +def seeds(analysis, bar, seed): + melody=tuple(dataclasses.replace(n,start=n.start-analysis.body_offset,end=n.end-analysis.body_offset, + event_id=n.sources[0]) for n in analysis.melody) + groups=[] + for i in range(len(melody)): + for count in ((len(melody),) if len(melody)<3 else (3,4,5)): + group=melody[i:i+count] + if len(group)!=count or group[-1].end-group[0].start>max(bar,F(3)) or group[0].start>=8*bar: + continue + local=tuple(dataclasses.replace(n,start=n.start-group[0].start,end=n.end-group[0].start) for n in group) + parent=next((s.id for s in analysis.source_phrases if s.start<=max(0,group[0].start)total or any(a=len(ref.notes):values[(*head,(arrival,length-release))]=label+f'; arrival {hold}, release {release}' + return sequences.diverse(tuple(values.items()),lambda x:(max(0,len(x[0])-1.5*float(length),float(length)-len(x[0])),policy.stable_rank(ref.id,x[0])), + lambda x:(len(x[0]),x[0][-1][1]-x[0][-1][0],x[0][0][0]),4) + + +def pitches(harmony,note,base,*,members=False,limit=6): + from generator.solo.leads import available + domain=available(harmony,note.start,note.end,members=members or note.end-note.start>=2) + return sequences.diverse(domain,lambda p:(abs(p.number()-base),p.number()), + lambda p:((p.number()-base>0)-(p.number()-base<0),p.number()//12),limit) + + +def relative_steps(notes,harmony): + result=[] + for a,b in zip(notes,notes[1:]): + scale=harmony.at(b.start).scale + try:step=types.scale_index(scale,b.pitch.number())-types.scale_index(scale,a.pitch.number()) + except ValueError: + lo,hi=sorted((a.pitch.number(),b.pitch.number())) + intervening=range(lo+1,hi+1) if b.pitch.number()>a.pitch.number() else range(lo,hi) + count=sum(p%12 in scale.pitches for p in intervening) + step=count if b.pitch.number()>a.pitch.number() else -count + result.append(step) + return tuple(result) + + +def instantiate(templates,first,steps,harmony): + result=[dataclasses.replace(templates[0],pitch=first)] + for note,step in zip(templates[1:],steps): + span=harmony.at(note.start) + if not span or not span.scale:return () + options=routes.advance(span.scale,result[-1].pitch.number(),step) + if not options:return () + p=options[0] + if not 41<=p.number()<=76 or p.number()%12 not in types.permitted(harmony.choices,note.start,note.end):return () + result.append(dataclasses.replace(note,pitch=p)) + return tuple(result) if len(result)==len(templates) else () + + +def prefix_paths(templates,ref,first,harmony,library): + count=len(ref.notes); original=ref.notes + if count==1:return ((dataclasses.replace(templates[0],pitch=first),),) + if ref.treatment in ('opening_fragment','characteristic_interval','callback'): + delta=first.number()-original[0].pitch.number() + if ref.treatment in ('opening_fragment','callback') and delta%12:return () + result=[] + from generator.solo.leads import available + for template,n in zip(templates,original): + target=n.pitch.number()+delta + p=next((p for p in available(harmony,template.start,template.end,members=template.end-template.start>=2) if p.number()==target),None) + if p is None:return () + result.append(dataclasses.replace(template,pitch=p)) + return (tuple(result),) + paths=[] + end_template=templates[count-1] + source_motion=original[-1].pitch.number()-original[0].pitch.number() + for target in pitches(harmony,end_template,first.number()+source_motion,limit=4): + template=tuple(dataclasses.replace(n,pitch=first if i==0 else target) for i,n in enumerate(templates[:count])) + paths.extend(r.notes for r in routes.candidates(template,{template[-1].event_id},harmony,library,(41,76),'seed '+ref.id)) + valid=[p for p in paths if not references.feature_error(feature(ref,p),p)] + return sequences.diverse(valid,lambda ns:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(ns,ns[1:])),tuple(n.pitch for n in ns)), + lambda ns:sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ns,ns[1:]))),4) + + +@dataclasses.dataclass(frozen=True) +class Unit: + notes: tuple[types.Note,...] + feature: types.IdentifyingFeature + reference: types.SourceReference + operation: str + anchor: types.Anchor + score: float + source: str + routes: tuple[types.RouteCandidate,...] = () + + +def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0): + # Position and the recent realization participate even when two paths share a last pitch. + if not hasattr(harmony,'fingerprint'):harmony.fingerprint=policy.stable_rank('harmony',harmony.choices) + key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,home,operation,terminal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT) + if key in context.results: + context.diagnostics['cache_hits']+=1;return context.results[key] + result=[]; count=len(ref.notes) + for pattern,label in patterns(ref,start,finish,rhythms,meter,context,home=home,operation=operation,terminal=terminal): + if len(pattern)3 and notes[-1].end>home.duration/2:notes=notes[:-1] + return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes, + 'rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','extend','fragment') else 'contour_seed') + + +def make_home(unit,harmony,start=None,end=None): + from generator.solo.realization import make_identity + start=unit.notes[0].start if start is None else start + identity=policy.stable_rank('solo-theme',(unit.reference.id,unit.notes))[:16] + home=make_identity('solo_theme_'+identity,unit.reference.id,unit.notes,start,unit.notes[-1].end if end is None else end,'introduce',harmony) + return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony)) + + +def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False): + from generator.solo import leads,realization + if source.end-source.start<2: + return short_fragment(source,incoming,part,harmony,tempo,seed,final) + bar=F(meter.split('/')[0])*4/int(meter.split('/')[1]) + unit_length=2*bar + head=source_notes(analysis) + seed_key=('source-seeds',analysis,bar,seed) + if seed_key not in context.pools:context.pools[seed_key]=seeds(analysis,bar,seed) + roots=context.pools[seed_key] + states=[(0.,(),incoming.home,incoming)] + cursor=source.start + while cursor=8*bar and cursor==source.start and used<2: + requests.extend((dataclasses.replace(r,treatment='callback'),'callback') for r in roots[:2] if r.end-r.start<=bar) + for ref,operation in requests: + if ref.notes[-1].end>finish-cursor: + reduced=tuple(n for n in ref.notes if n.end<=finish-cursor) + if len(reduced)<2:continue + ref=dataclasses.replace(ref,notes=reduced,treatment='contour_seed') + operation='answer' + options=unit_candidates(ref,cursor,finish,state,harmony,library,rhythms,meter,context,home=home,operation=operation, + terminal=final and finish==source.end,batch=batch) + for unit in options: + if introducing and similarity.near_copy(unit.notes,head,cursor,finish)['near_copy']: + context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue + actual_home=home or make_home(unit,harmony,cursor,finish) + following=dataclasses.replace(state,home=actual_home,last=unit.notes[-1],notes=unit.notes,source_reference=state.source_reference or ref) + next_states.append((cost+unit.score,(*units,unit),actual_home,following)) + states=sequences.diverse(next_states,lambda v:(v[0],policy.stable_rank(seed,tuple(u.notes for u in v[1]))), + lambda v:(v[2].id,v[1][-1].notes[-1].pitch.number()),2 if incoming.home and finishcursor:activity.append(types.ActivitySpan(cursor,n.start,'space',F(),F())) + activity.append(types.ActivitySpan(n.start,n.end,'active',F(5,4),F())) + cursor=n.end + if cursor tuple[int, ...]: return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:])) +@dataclasses.dataclass(frozen=True) +class SourcePhrase: + id: str + start: fractions.Fraction + end: fractions.Fraction + notes: tuple[Note, ...] + rests: tuple[tuple[fractions.Fraction, fractions.Fraction], ...] + family: str + pickup: tuple[Note, ...] = () + @dataclasses.dataclass(frozen=True) class TargetEvent: earliest: fractions.Fraction @@ -125,6 +135,7 @@ class RealizedMotif: treatment: str chord_degrees: tuple[tuple[int, ...], ...] = () source_start: fractions.Fraction = fractions.Fraction() + degree_steps: tuple[int, ...] = () def contour(self): return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:])) @@ -146,6 +157,8 @@ class ContinuationState: selected_anchors: tuple[Anchor, ...] = () ending: str = '' lead_identity: LeadIdentity | None = None + source_reference: SourceReference | None = None + callbacks: int = 0 @dataclasses.dataclass(frozen=True) class Transformation: @@ -177,6 +190,17 @@ class IdentifyingFeature: rhythm: tuple[tuple[fractions.Fraction, fractions.Fraction], ...] intervals: tuple[int, ...] source: str + preserves: tuple[str, ...] = ('rhythm', 'intervals') + pitches: tuple[int, ...] = () + +@dataclasses.dataclass(frozen=True) +class SourceReference: + id: str + source_id: str + start: fractions.Fraction + end: fractions.Fraction + notes: tuple[Note, ...] + treatment: str @dataclasses.dataclass(frozen=True) class RouteCandidate: @@ -205,6 +229,10 @@ class MelodicDraft: reference_motif: Motif | RealizedMotif | None = None lead_identity: LeadIdentity | None = None correspondence: tuple[tuple[str, str], ...] = () + source_phrase: SourcePhrase | None = None + octave_transfer: int = 0 + source_links: tuple[tuple[str, tuple[str, ...]], ...] = () + source_reference: SourceReference | None = None def rests(self): cursor, result = self.start, [] for note in self.notes: diff --git a/tests/test_solo.py b/tests/test_solo.py index 09d212d..1239f88 100644 --- a/tests/test_solo.py +++ b/tests/test_solo.py @@ -116,7 +116,7 @@ class SoloTests(unittest.TestCase): source = song(count=6, chords=('CM7',)) result = compose(source) self.assertLessEqual(len({p.plan.motif.id for p in result.phrases}), 3) - self.assertEqual(result.phrases[0].plan.motif.id, result.phrases[1].plan.motif.id) + self.assertTrue(all(p.draft.source_phrase for p in result.phrases)) self.assertFalse(any(d.treatment.startswith('quote') for p in result.phrases for d in p.decisions)) original = result.phrases[0].plan explicit = dataclasses.replace(original, treatment='exact_quote', target=None, anchor_options=(), gestures=(), passage_id='', passage_start=None, passage_end=None) @@ -154,7 +154,7 @@ class SoloTests(unittest.TestCase): self.assertEqual(result.phrases[-1].plan.end, fractions.Fraction(43, 2)) def test_search_exhaustion_does_not_claim_impossibility(self): source = song() - with unittest.mock.patch('generator.solo.realization.draft_pitch_paths', return_value=((), 'no route within the draft budget')), self.assertRaisesRegex(generator.solo.realization.SearchExhausted, 'not proof'): + with unittest.mock.patch('generator.solo.themes.phrase_candidates', return_value=()), self.assertRaisesRegex(generator.solo.realization.SearchExhausted, 'not proof'): compose(source) def test_time_aware_placement_does_not_penalize_absolute_frets(self): p = generator.solo.types.Pitch('c', fractions.Fraction(), 4) diff --git a/tests/test_solo_drafts.py b/tests/test_solo_drafts.py index beea583..743977c 100644 --- a/tests/test_solo_drafts.py +++ b/tests/test_solo_drafts.py @@ -187,12 +187,13 @@ class DraftCompositionTests(unittest.TestCase): self.assertTrue(generator.solo.placement.place(draft.notes, self.solo.tempo, previous)) self.assertTrue(draft.transformations) previous = phrase.state.last - def test_home_survives_several_developments_and_phrase_boundary(self): + def test_source_family_survives_phrase_variations(self): first, second = self.solo.phrases self.assertEqual(first.state.home.id, second.state.home.id) - self.assertTrue(all(t.home == first.state.home.id for p in self.solo.phrases for t in p.draft.transformations)) - self.assertNotEqual(second.state.development.id, second.state.home.id) - self.assertEqual(second.state.selected_anchors, first.state.selected_anchors) + for phrase in self.solo.phrases: + self.assertTrue(all(t.home == phrase.state.home.id for t in phrase.draft.transformations)) + self.assertTrue(phrase.state.home.chord_degrees) + self.assertTrue(phrase.draft.source_links) def test_groups_cross_barlines_without_reference_timing_reset(self): # Exercise an explicitly spanning construction; do not require a changed # search objective to pick the same old winner merely to test timing. diff --git a/tests/test_solo_lead_tunes.py b/tests/test_solo_lead_tunes.py index a6d8a23..370ed79 100644 --- a/tests/test_solo_lead_tunes.py +++ b/tests/test_solo_lead_tunes.py @@ -1,154 +1,145 @@ -"""Construction and permission contracts; listening remains the taste test.""" +"""Production source variations, strict scale permission and paired interpretation.""" +import copy import dataclasses +from fractions import Fraction as F import pathlib import unittest -from fractions import Fraction as F -from generator.solo import analysis, composer, drafts, harmonic, leads, references, realization, scoring, types -import tests.test_solo as solo_fixture +from generator.solo import analysis, composer, drafts, harmonic, leads, placement, realization, scoring, types +import generator.fretboard +import generator.model +import tests.test_solo as fixture +import tests.test_solo_development as notes_fixture import tests.test_solo_passages as passage_fixture -import tests.test_solo_development as fixture -import tests.rhythm_fixtures import tests.scale_fixtures +import tests.rhythm_fixtures import tests.solo_fixtures -class ChromaticApproachTests(unittest.TestCase): - def setUp(self): - self.harmony = harmonic.Harmony(passage_fixture.choices(('C7', 'FM7'))) - self.approach = dataclasses.replace(fixture.note(F(7, 2), 4, 'g', -1), event_id='approach') - self.target = dataclasses.replace(fixture.note(4, 6, 'f'), event_id='resolution') - self.treatment = types.HarmonicTreatment('test', 'chromatic_approach', 66, F(7, 2), F(4), 65, F(4), F(), 'approach', 'resolution') - def test_approach_resolves_across_chord_boundary_without_remapping_scale(self): - self.assertFalse(self.harmony.evaluate((self.approach, self.target), complete=True).valid) - result = self.harmony.evaluate((self.approach, self.target), (self.treatment,), complete=True) - self.assertTrue(result.valid, result.error) - self.assertFalse(result.pending) - report = self.harmony.describe((self.approach, self.target), (self.treatment,)) - self.assertEqual((report[0]['function'], report[0]['motion']), ('outside_collection', 'chromatic_approach')) - def test_pending_approach_survives_computational_boundary(self): - partial = self.harmony.evaluate((self.approach,), (self.treatment,), until=F(4)) - self.assertTrue(partial.valid) - self.assertEqual(partial.pending, (self.treatment,)) - self.assertFalse(self.harmony.evaluate((self.approach,), (self.treatment,), until=F(5)).valid) - def test_tie_partition_does_not_change_approach_or_sustain(self): - split = (dataclasses.replace(self.approach, end=F(15, 4)), dataclasses.replace(self.approach, start=F(15, 4), continuation=True, event_id='tie'), dataclasses.replace(self.target, end=F(5)), dataclasses.replace(self.target, start=F(5), continuation=True, event_id='target_tie')) - whole = (self.approach, self.target) - self.assertEqual(self.harmony.evaluate(whole, (self.treatment,), complete=True), self.harmony.evaluate(split, (self.treatment,), complete=True)) - self.assertEqual(self.harmony.describe(whole, (self.treatment,)), self.harmony.describe(split, (self.treatment,))) - self.assertEqual(len(types.sounding_notes(split)), 2) - def test_labels_cannot_excuse_arbitrary_notes(self): - cases = [ - (dataclasses.replace(self.approach, start=F(3)), self.target, self.treatment), - (self.approach, dataclasses.replace(self.target, start=F(9, 2)), self.treatment), - (self.approach, dataclasses.replace(self.target, pitch=fixture.note(0, 1, 'e').pitch), self.treatment), - (self.approach, self.target, dataclasses.replace(self.treatment, event='unrelated')), - (dataclasses.replace(self.approach, role='quote'), self.target, self.treatment), - (self.approach, self.target, dataclasses.replace(self.treatment, deadline=F(5))), - ] - for approach, target, treatment in cases: - with self.subTest(approach=approach, target=target, treatment=treatment): - self.assertFalse(self.harmony.evaluate((approach, target), (treatment,), complete=True).valid) - def test_resolution_must_be_chord_member_and_valid_through_its_hold(self): - nonmember = dataclasses.replace(self.target, pitch=fixture.note(4, 6, 'g').pitch) - treatment = dataclasses.replace(self.treatment, resolution=67) - self.assertFalse(self.harmony.evaluate((self.approach, nonmember), (treatment,), complete=True).valid) - long_approach = dataclasses.replace(self.approach, start=F(2)) - self.assertFalse(self.harmony.evaluate((long_approach, self.target), (dataclasses.replace(self.treatment, start=F(2)),), complete=True).valid) - def test_approach_spelling_avoids_unnecessary_double_accidentals(self): - target = fixture.note(0, 1, 'a', -1).pitch - self.assertEqual(leads.approach_pitch(target, 69).label(), 'A4') - target = fixture.note(0, 1, 'e').pitch - self.assertEqual(leads.approach_pitch(target, 63).label(), 'D#4') - def test_chromatic_spacing_carries_across_passage_boundary(self): - notes = (dataclasses.replace(self.approach, pitch=fixture.note(0, 1, 'g').pitch), self.target) - plan = dataclasses.replace(fixture.plan(), end=F(8), release=F(2), target=None, passages=()) - group = types.Transformation('group', '', '', 'answer', F(), F(8), tuple(n.event_id for n in notes), tuple((n.start, n.end) for n in notes), preserves=()) - draft = types.MelodicDraft('spacing', F(), F(8), notes, (group,), ('resolution',), (), (), None, 'answer') - empty = types.ContinuationState('previous') - phrase = realization.checked_phrase(plan, draft, notes, (), empty, self.harmony, F(120)) - self.assertIsNotNone(phrase) - self.assertTrue(leads.chromatic(phrase, empty, self.harmony, F(120)).edits) - recent = dataclasses.replace(empty, treatments=(dataclasses.replace(self.treatment, start=F(-7, 2)),)) - self.assertFalse(leads.chromatic(phrase, recent, self.harmony, F(120)).edits) - -class CompleteLeadTests(unittest.TestCase): +class SourceVariationTests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.references = references.load_library(pathlib.Path('data/lead_phrases')) - cls.source = solo_fixture.song(count=8, chords=('Gm7', 'C7', 'FM7', 'BbM7', 'Em7b5', 'A7', 'Dm7', 'Dm7')) - cls.context = realization.SearchContext() - cls.result = cls.compose(cls.context) + cls.document=fixture.song(count=8,chords=('CM7',)) + cls.context=realization.SearchContext() + cls.solo=cls.compose(cls.document,cls.context) @classmethod - def compose(cls, context, stage='elaborated'): - return composer.compose(analysis.analyze(cls.source), cls.source['staves'][0]['measures'], 'acoustic', F(32), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library(), references=cls.references, context=context, stage=stage) - def test_complete_tune_precedes_decorations_and_contains_a_return(self): - for phrase in self.result.phrases: - self.assertTrue(phrase.draft.lead_identity) - self.assertTrue(phrase.draft.correspondence) - self.assertTrue(drafts.contract_valid(phrase.initial, phrase.initial.notes)[0]) - self.assertTrue(drafts.contract_valid(phrase.initial, realization.emitted_notes(phrase))[0]) - source = {n.event_id: n for n in phrase.initial.notes} - for a, b in phrase.draft.correspondence: - self.assertEqual(source[a].end - source[a].start, source[b].end - source[b].start) - self.assertNotIn('diminish', [t.operation for t in phrase.draft.transformations]) - for route in phrase.routes: - run = peak = 0 - end = None - for note in route.notes: - eighth = note.end-note.start == F(1,2) - run = run+1 if eighth and end == note.start else int(eighth) - peak = max(peak, run) - end = note.end - self.assertGreaterEqual(peak, 4) - composer.validate(self.result) - self.assertTrue(all(1 <= n.position.fret <= 12 for n in self.result.notes())) - def test_same_inputs_and_cache_preserve_music(self): - second = self.compose(self.context) - self.assertEqual(self.result.notes(), second.notes()) - self.assertGreater(self.context.diagnostics['cache_hits'], 0) - def test_lead_evaluation_is_independent_of_computational_partitions(self): - phrase = self.result.phrases[0] - whole = phrase.initial.notes - split = [] - for note in whole: - cuts = sorted({note.start,note.end} | {F(i,2) for i in range(64) if note.start < F(i,2) < note.end}) - split.extend(dataclasses.replace(note,start=a,end=b,continuation=i>0) for i,(a,b) in enumerate(zip(cuts,cuts[1:]))) - harmony = harmonic.Harmony(self.result.choices) - self.assertEqual(harmony.describe(whole),harmony.describe(split)) - self.assertEqual(scoring.features(phrase.plan,whole,self.result.choices),scoring.features(phrase.plan,split,self.result.choices)) - self.assertTrue(drafts.contract_valid(phrase.initial,split)[0]) - def test_activity_and_occupancy_are_ranges(self): - plan = dataclasses.replace(fixture.plan(), start=F(), end=F(4), release=F(), treatment='lead', passages=()) - choices = passage_fixture.choices(('C7',)) - for count in (4, 5, 6): - notes = tuple(fixture.note(F(4*i, count), F(4*(i+1), count), 'e') for i in range(count)) - values = scoring.features(plan, notes, choices) - self.assertEqual(values['activity'], 0) - self.assertEqual(values['occupancy'], 0) - self.assertFalse(scoring.feasible(plan, (), choices, F(4))) - def test_new_identity_is_a_joint_rhythm_and_pitch_object(self): - identities = leads.identities(self.references, types.ContinuationState('')) - self.assertEqual(len(identities), 12) - self.assertGreater(len({i.durations for i in identities}), 1) - self.assertGreater(len({i.degrees for i in identities}), 1) - for identity in identities: - self.assertEqual(len(identity.degrees), len(identity.durations)) - self.assertEqual(sum(identity.durations), 4) - def test_invented_material_can_be_replaced_but_committed_hook_is_preserved(self): - phrase = self.result.phrases[0] - changed = tuple(n for n in phrase.initial.notes if n.event_id != phrase.draft.correspondence[0][1]) - self.assertFalse(drafts.contract_valid(phrase.initial, changed)[0]) - def test_missing_harmony_remains_space_with_lead_generation_enabled(self): - document = solo_fixture.song(count=4, chords=()) - solo = composer.compose(analysis.analyze(document), document['staves'][0]['measures'], 'acoustic', F(16), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library(), references=self.references) - self.assertFalse(solo.notes()) - composer.validate(solo) - def test_vocal_pickup_cutoff_does_not_compress_a_full_ending(self): - solo = composer.compose(analysis.analyze(self.source), self.source['staves'][0]['measures'], 'electric', F(29), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library(), references=self.references) - composer.validate(solo) - self.assertTrue(all(n.end <= 29 and n.end-n.start >= F(1,2) for n in solo.notes())) - tail = [n for n in solo.notes() if n.start >= 28] - self.assertEqual([(n.start,n.end) for n in tail], [(28,29)]) + def compose(cls,document,context=None,end=32): + return composer.compose(analysis.analyze(document),document['staves'][0]['measures'],'acoustic',F(end), + scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),context=context,mode='paraphrase') + def test_production_is_traceable_to_complete_source_phrases(self): + self.assertTrue(self.solo.phrases) + for p in self.solo.phrases: + self.assertIsNotNone(p.draft.source_phrase) + self.assertTrue(p.draft.source_links) + self.assertTrue(p.edits) + self.assertGreater(len(p.draft.source_phrase.notes),8) + self.assertTrue(drafts.contract_valid(p.draft,composer.phrase_notes(p))[0]) + self.assertTrue(all(n.sources for n in p.draft.notes)) + def test_same_input_and_cache_preserve_complete_music(self): + other=self.compose(self.document,self.context) + self.assertEqual(self.solo.notes(),other.notes()) + self.assertGreater(self.context.diagnostics['cache_hits'],0) + def test_source_change_drives_music_with_fixed_harmony(self): + changed=copy.deepcopy(self.document) + for m in changed['staves'][0]['measures']: + for n in m['voices'][0]['notes']: + n['pitches'][0]['letter']='g' if n['pitches'][0]['letter']=='c' else n['pitches'][0]['letter'] + other=self.compose(changed) + self.assertNotEqual(leads.signature(self.solo.notes()),leads.signature(other.notes())) + self.assertNotEqual(self.solo.phrases[0].initial.notes[0].pitch.number()%12,other.phrases[0].initial.notes[0].pitch.number()%12) + def test_source_ties_and_rests_are_retained_before_variation(self): + melody=(notes_fixture.note(0,5,'c'),notes_fixture.note(6,9,'e'),notes_fixture.note(9,12,'g'),notes_fixture.note(12,16,'c')) + melody=tuple(dataclasses.replace(n,sources=(f's{i}',)) for i,n in enumerate(melody)) + source=analysis.source_phrases(melody,F(),F(16),F(4)) + self.assertEqual(tuple(n for p in source for n in p.notes)[0].end,5) + self.assertTrue(any((F(5),F(6)) in p.rests for p in source)) + def test_strict_map_rejects_chromatic_labels(self): + harmony=harmonic.Harmony(passage_fixture.choices(('C7',))) + approach=dataclasses.replace(notes_fixture.note(F(5,2),3,'f',1),event_id='approach') + target=dataclasses.replace(notes_fixture.note(3,4,'g'),event_id='target') + contract=types.HarmonicTreatment('removed','chromatic_approach',66,F(5,2),F(3),67,F(3),F(),'approach','target') + self.assertFalse(harmony.evaluate((approach,target),complete=True).valid) + self.assertFalse(harmony.evaluate((approach,target),(contract,),complete=True).valid) + def test_mapped_accidentals_and_half_steps_remain_valid(self): + scale=tests.scale_fixtures.library().scale('harmonic_minor',1,'D') + choices=passage_fixture.choices(('A7','Dm7'),duration=2,scale=scale) + harmony=harmonic.Harmony(choices) + notes=(notes_fixture.note(0,F(3,2),'e'),notes_fixture.note(F(3,2),2,'c',1),notes_fixture.note(2,4,'d')) + self.assertTrue(harmony.evaluate(notes,complete=True).valid) + def test_partition_does_not_change_sounding_evaluation(self): + phrase=self.solo.phrases[0] + whole=phrase.draft.notes;split=[] + for n in whole: + middle=(n.start+n.end)/2 + split.extend((dataclasses.replace(n,end=middle),dataclasses.replace(n,start=middle,continuation=True))) + harmony=harmonic.Harmony(self.solo.choices) + self.assertEqual(harmony.evaluate(whole,complete=True),harmony.evaluate(split,complete=True)) + self.assertEqual(scoring.features(phrase.plan,whole,self.solo.choices),scoring.features(phrase.plan,split,self.solo.choices)) + self.assertTrue(drafts.contract_valid(phrase.draft,split)[0]) + def test_low_register_and_tied_placement(self): + low=dataclasses.replace(notes_fixture.note(0,2,'a'),pitch=types.Pitch('a',F(),2)) + following=dataclasses.replace(low,start=2,end=4,continuation=True) + placed=placement.place((low,following),F(120)) + self.assertEqual(placed[1][0].position.string_number,6) + self.assertEqual(placed[1][0].position,placed[1][1].position) + self.assertEqual({p.string_number for number in range(41,77) for p in placement.positions(number)},set(range(1,7))) + def test_pair_metric_detects_octave_copy(self): + shifted=dataclasses.replace(self.solo,phrases=tuple(dataclasses.replace(p,decisions=tuple(dataclasses.replace(d,notes=leads.shift(d.notes,1)) for d in p.decisions)) for p in self.solo.phrases)) + self.assertEqual(leads.similarity(self.solo,shifted),1) + callback=dataclasses.replace(shifted,phrases=tuple(dataclasses.replace(p,plan=dataclasses.replace(p.plan,relationship='callback')) for p in shifted.phrases)) + self.assertEqual(leads.similarity(self.solo,callback),0) + def test_later_arrivals_compare_complete_routes_with_different_holds(self): + notes=tuple(dataclasses.replace(notes_fixture.note(a,b,k),event_id=str(a),sources=(str(a),)) + for a,b,k in ((0,1,'g'),(1,2,'e'),(2,8,'c'))) + source=types.SourcePhrase('ending',F(),F(8),notes,(),'family') + harmony=harmonic.Harmony(passage_fixture.choices(('C7','C7'))) + options=leads.elaborate(notes,(),harmony,tests.solo_fixtures.library(),tests.rhythm_fixtures.library(),'4/4',source) + self.assertEqual({r.destination.end-r.destination.start for _,_,r in options},{F(1,2),F(1),F(3,2),F(2)}) + for actual,_,route in options: + self.assertTrue(harmony.evaluate(actual,complete=True).valid) + self.assertEqual(actual[-1],route.destination) + self.assertEqual((actual[0].start,actual[-1].end),(0,8)) + def test_repeated_source_cadence_is_not_accidental_monotony(self): + first=self.solo.phrases[0] + changed=dataclasses.replace(first,draft=dataclasses.replace(first.draft, + source_phrase=dataclasses.replace(first.draft.source_phrase,family='unrelated'))) + self.assertAlmostEqual(leads.transition(first,changed)-leads.transition(first,first),.15) + def test_space_and_vocal_return_are_preserved(self): + silent=self.compose(fixture.song(count=8,chords=())) + self.assertFalse(silent.notes()) + shortened=self.compose(self.document,end=F(29)) + self.assertTrue(all(n.end<=29 for n in shortened.notes())) + composer.validate(shortened) + def test_pair_selection_produces_valid_distinct_source_variations(self): + a=analysis.analyze(self.document) + pair=composer.compose_pair(a,{part:(self.document['staves'][0]['measures'],F(32),None) for part in ('acoustic','electric')}, + scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),mode='paraphrase') + self.assertLess(leads.similarity(*pair),1) + self.assertEqual(pair[0].diagnostics['pairs_compared'],64) + for solo in pair: + composer.validate(solo) + for phrase in solo.phrases: + heard={n.event_id:n for n in composer.phrase_notes(phrase)} + for route in phrase.routes: + self.assertEqual(route.notes[-1].pitch,route.destination.pitch) + for note in route.notes: + self.assertEqual((heard[note.event_id].pitch,heard[note.event_id].start,heard[note.event_id].end),(note.pitch,note.start,note.end)) + def test_pickup_is_brought_inside_window_without_touching_previous_section(self): + melody=(notes_fixture.note(0,5,'g'),notes_fixture.note(5,6,'a'),notes_fixture.note(6,8,'g')) + melody=tuple(dataclasses.replace(n,sources=(f's{i}',),event_id=f's{i}') for i,n in enumerate(melody)) + pickup=tuple(dataclasses.replace(notes_fixture.note(i-3,i-2,name),sources=(f'p{i}',),event_id=f'p{i}') for i,name in enumerate(('c','d','e'))) + source=types.SourcePhrase('pickup',F(),F(8),melody,(),'family',pickup) + harmony=harmonic.Harmony(passage_fixture.choices(('C7','C7'))) + options=[notes for notes,edits in leads.variations(melody,source,harmony) if edits[0].kind=='pickup_entrance'] + self.assertTrue(options) + self.assertEqual(options[0][0].start,0) + self.assertEqual(options[0][3].start,3) + self.assertEqual(options[0][3].end,5) + self.assertTrue(harmony.evaluate(options[0],complete=True).valid) + def test_occupancy_is_not_an_objective_and_active_silence_is_invalid(self): + plan=dataclasses.replace(notes_fixture.plan(),end=F(4),release=F(),passages=(),treatment='source_variation') + choices=passage_fixture.choices(('C7',)) + for end in (F(1),F(4)): + self.assertEqual(scoring.features(plan,(notes_fixture.note(0,end,'e'),),choices)['occupancy'],0) + self.assertFalse(scoring.feasible(plan,(),choices,F(4))) -if __name__ == '__main__': - unittest.main() +if __name__=='__main__':unittest.main() diff --git a/tests/test_solo_passages.py b/tests/test_solo_passages.py index 5ee1aab..2e8af1a 100644 --- a/tests/test_solo_passages.py +++ b/tests/test_solo_passages.py @@ -115,24 +115,15 @@ class HarmonicPassageTests(unittest.TestCase): self.assertEqual(generator.solo.types.scale_index(scale, 65) - generator.solo.types.scale_index(scale, 64), 1) class PassageStateTests(unittest.TestCase): - def test_eight_bar_cadence_carries_realized_identity_and_anchor_line(self): + def test_source_phrases_carry_identity_and_completed_anchors(self): import tests.test_solo solo = tests.test_solo.compose(tests.test_solo.song(count=8, chords=('Gm7', 'C7', 'FM7', 'BbM7', 'Em7b5', 'A7', 'Dm7', 'Dm7'))) - self.assertEqual(len(solo.passages), 1) - self.assertEqual((solo.passages[0].start, solo.passages[0].end), (0, 32)) - self.assertEqual([(p.plan.start, p.plan.end) for p in solo.phrases], [(0, 16), (16, 32)]) - first, second = solo.phrases - self.assertEqual(first.plan.ending, 'connected') - self.assertEqual(first.state.motifs, second.state.motifs[:len(first.state.motifs)]) - self.assertEqual(first.anchors, second.anchors) - self.assertTrue(any(a.role == 'intermediate' for a in second.anchors)) - self.assertTrue(all(generator.solo.harmonic.anchor_note(a, second.state.notes) for a in second.anchors)) - heard = {m.id: m for m in second.state.motifs} - self.assertGreaterEqual(len(heard), 5) - for motif in list(heard.values())[1:]: - self.assertIn(motif.parent, heard) - self.assertTrue(motif.chord_degrees) - self.assertTrue(generator.solo.harmonic.anchors_valid(second.anchors, solo.notes(), solo.end, complete=True, closure='closed')[0]) + self.assertEqual((solo.passages[0].start, solo.passages[-1].end), (0, 32)) + for phrase in solo.phrases: + self.assertTrue(phrase.draft.source_phrase) + self.assertTrue(phrase.state.home.chord_degrees) + self.assertTrue(all(generator.solo.harmonic.anchor_note(a, phrase.state.notes) for a in phrase.anchors)) + self.assertTrue(generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=True, closure='closed')[0]) def test_development_references_realization_instead_of_source_template(self): plan = tests.test_solo_development.plan() harmony = generator.solo.harmonic.Harmony(choices(('C7',))) diff --git a/tests/test_solo_themes.py b/tests/test_solo_themes.py new file mode 100644 index 0000000..c8b23a6 --- /dev/null +++ b/tests/test_solo_themes.py @@ -0,0 +1,132 @@ +"""Source influence and realized-theme continuity are different contracts.""" +import dataclasses +from fractions import Fraction as F +import unittest + +from generator.solo import analysis, composer, drafts, harmonic, leads, references, realization, similarity, themes, types +import tests.test_solo as fixture +import tests.test_solo_development as notes_fixture +import tests.test_solo_passages as harmony_fixture +import tests.scale_fixtures +import tests.solo_fixtures +import tests.rhythm_fixtures + + +def melody(values): + return tuple(dataclasses.replace(notes_fixture.note(a,b,p),event_id=str(i),sources=(str(i),)) for i,(a,b,p) in enumerate(values)) + +class ThemeContracts(unittest.TestCase): + def test_selected_rhythm_does_not_also_require_source_pitches(self): + source=melody(((0,1,'c'),(1,2,'d'),(2,3,'e'))) + ref=types.SourceReference('source','phrase',F(),F(3),source,'rhythmic_seed') + actual=melody(((8,9,'g'),(9,10,'e'),(10,11,'c'))) + self.assertEqual(references.feature_error(themes.feature(ref,actual),actual),0) + contour=dataclasses.replace(ref,treatment='contour_seed') + self.assertGreater(references.feature_error(themes.feature(contour,actual),actual),0) + + def test_a_single_event_is_not_evidence_of_a_multinote_feature(self): + source=melody(((0,1,'c'),(1,2,'d'),(2,3,'e'))) + ref=types.SourceReference('source','phrase',F(),F(3),source,'rhythmic_seed') + contract=themes.feature(ref,source) + self.assertEqual(references.feature_error(contract,source[:1]),1) + self.assertEqual(references.feature_error(themes.feature(ref,source[:1]),source[:1]),1) + + def test_copy_comparison_cannot_be_diluted_by_octaves_runs_or_reattacks(self): + source=melody(((0,2,'c'),(2,4,'e'),(4,6,'g'),(6,8,'c'))) + shifted=leads.shift(source,1) + self.assertTrue(similarity.near_copy(shifted,source,F(),F(8))['near_copy']) + repeated=tuple(part for n in shifted for part in (dataclasses.replace(n,end=n.start+1),dataclasses.replace(n,start=n.start+1,event_id=n.event_id+'r'))) + self.assertTrue(similarity.near_copy(repeated,source,F(),F(8))['near_copy']) + self.assertEqual(similarity.compare(repeated,source)['octave_insensitive'],1) + shortened=tuple(dataclasses.replace(n,end=n.end-F(1,2)) for n in shifted) + self.assertEqual(similarity.compare(shortened,source)['octave_insensitive'],.75) + relocated=tuple(dataclasses.replace(n,start=n.start+8,end=n.end+8) for n in shifted) + self.assertTrue(similarity.near_copy(relocated,source,F(8),F(16))['near_copy']) + + def test_tied_partition_does_not_change_similarity(self): + source=melody(((0,2,'c'),(2,4,'e'),(4,6,'g'),(6,8,'c'))) + split=tuple(piece for n in source for piece in (dataclasses.replace(n,end=n.start+1),dataclasses.replace(n,start=n.start+1,continuation=True))) + self.assertEqual(similarity.compare(source,source),similarity.compare(split,source)) + + def test_downward_cross_collection_steps_preserve_the_actual_target(self): + from generator.solo import routes + scale=tests.scale_fixtures.library().scale('major',6,'D') + harmony=harmonic.Harmony(harmony_fixture.choices(('Dm7',),scale=scale)) + first=dataclasses.replace(notes_fixture.note(0,1,'c'),pitch=types.Pitch('c',F(1),4)) + last=dataclasses.replace(notes_fixture.note(1,2,'b'),pitch=types.Pitch('b',F(-1),3)) + step,=themes.relative_steps((first,last),harmony) + self.assertEqual(step,-2) + self.assertEqual(routes.advance(scale,first.pitch.number(),step)[0].number(),last.pitch.number()) + + def test_two_note_route_uses_exact_scale_displacement(self): + from generator.solo import routes + harmony=harmonic.Harmony(harmony_fixture.choices(('C7',))) + notes=melody(((0,1,'g'),(1,2,'e'))) + options=routes.candidates(notes,{'1'},harmony,tests.solo_fixtures.library(),(41,76)) + self.assertTrue(options) + self.assertTrue(all(r.notes[-1].pitch==notes[-1].pitch for r in options)) + + def test_elaboration_may_continue_its_entry_instead_of_reattacking(self): + notes=melody(((0,3,'g'),(3,4,'e'))) + source=types.SourcePhrase('held_entry',F(),F(4),notes,(),'') + harmony=harmonic.Harmony(harmony_fixture.choices(('C7',))) + options=leads.elaborate(notes,(),harmony,tests.solo_fixtures.library(),tests.rhythm_fixtures.library(),'4/4',source,sustain_entry=True) + self.assertTrue(options) + for actual,edit,route in options: + self.assertEqual(actual[0].start,0) + self.assertGreater(actual[0].end,edit.start) + self.assertEqual(actual[:len(route.notes)],route.notes) + self.assertTrue(harmony.evaluate(actual,complete=True).valid) + +class ThemeProduction(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.document=fixture.song(count=8,chords=('CM7',)) + cls.analysis=analysis.analyze(cls.document) + cls.context=realization.SearchContext() + cls.kw=dict(scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),context=cls.context) + cls.solo=composer.compose(cls.analysis,cls.document['staves'][0]['measures'],'acoustic',F(32),**cls.kw) + + def test_one_heard_theme_develops_across_source_boundaries(self): + self.assertEqual(self.solo.diagnostics['construction'],'thematic_recomposition') + self.assertEqual(len({p.state.home.id for p in self.solo.phrases}),1) + for phrase in self.solo.phrases: + self.assertTrue(drafts.contract_valid(phrase.draft,composer.phrase_notes(phrase))[0]) + self.assertFalse(similarity.near_copy(phrase.refined.notes,themes.source_notes(self.analysis),phrase.plan.start,phrase.plan.end)['near_copy']) + self.assertEqual(self.solo.phrases[0].plan.closure,'open') + self.assertTrue(all(a.role=='intermediate' for a in self.solo.phrases[0].anchors)) + self.assertEqual(self.solo.phrases[-1].anchors[-1].role,'terminal') + self.assertTrue(any(t.operation=='repeat_with_harmonic_adaptation' for p in self.solo.phrases for t in p.draft.transformations)) + + def test_library_routes_match_the_actual_complete_theme(self): + self.assertTrue(any(p.routes for p in self.solo.phrases)) + for phrase in self.solo.phrases: + heard={n.event_id:n for n in composer.phrase_notes(phrase)} + for route in phrase.routes: + for note in route.notes: + actual=heard[note.event_id] + self.assertEqual((note.start,note.end,note.pitch),(actual.start,actual.end,actual.pitch)) + self.assertEqual(route.notes[-1].pitch,route.destination.pitch) + + def test_incoming_pitch_changes_concrete_continuation_pool(self): + harmony=harmonic.Harmony(self.solo.choices) + home=self.solo.phrases[0].state.home + ref=themes.home_reference(home,'answer') + context=realization.SearchContext() + outputs=[] + for octave in (3,4): + prior=dataclasses.replace(notes_fixture.note(15,16,'e'),pitch=types.Pitch('e',F(),octave)) + state=types.ContinuationState('prior',last=prior,home=home) + options=themes.unit_candidates(ref,F(16),F(24),state,harmony,self.kw['sequences'],self.kw['rhythms'],'4/4',context,home=home,operation='answer') + self.assertTrue(options) + outputs.append({u.notes[0].pitch.number() for u in options}) + self.assertNotEqual(*outputs) + + def test_deterministic_cache_and_actual_source_input(self): + repeated=composer.compose(self.analysis,self.document['staves'][0]['measures'],'acoustic',F(32),**self.kw) + self.assertEqual(self.solo.notes(),repeated.notes()) + changed=dataclasses.replace(self.analysis,melody=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,letter='g')) for n in self.analysis.melody)) + other=composer.compose(changed,self.document['staves'][0]['measures'],'acoustic',F(32),**self.kw) + self.assertNotEqual(leads.signature(self.solo.notes()),leads.signature(other.notes())) + +if __name__=='__main__':unittest.main()