From 59afd78205007a9d0152e222938b87e8bdf8f996 Mon Sep 17 00:00:00 2001 From: khodowany Date: Sat, 3 Oct 2026 10:12:12 -0500 Subject: [PATCH] Soloing --- README.md | 12 +- generator/solo/arrangement.py | 4 +- generator/solo/audition.py | 34 +++-- generator/solo/composer.py | 23 +++- generator/solo/enclosures.py | 192 +++++++++++++++++++++++++++ generator/solo/harmonic.py | 71 +++++++++- generator/solo/phrasing.py | 64 ++++++++- generator/solo/policy.py | 6 +- generator/solo/realization.py | 2 +- generator/solo/report.py | 15 ++- generator/solo/themes.py | 23 +++- generator/solo/types.py | 17 +++ tests/test_solo_drafts.py | 2 +- tests/test_solo_enclosures.py | 242 ++++++++++++++++++++++++++++++++++ 14 files changed, 666 insertions(+), 41 deletions(-) create mode 100644 generator/solo/enclosures.py create mode 100644 tests/test_solo_enclosures.py diff --git a/README.md b/README.md index 513accd..505964d 100644 --- a/README.md +++ b/README.md @@ -101,20 +101,28 @@ Source analysis retains complete sounding notes, rests, pickups and cross-bar ho 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. -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. +Scale permission and chord function remain distinct. Ordinary melody and runs must belong to all mapped collections they overlap; mapped accidentals such as C-sharp in D harmonic minor remain valid. A separate, complete enclosure contract can authorize one brief lower approach outside that map. Generic chromatic substitutions remain disabled. 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. 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. +Destination retrieval retains representatives of the actual chord degrees before narrowing timing/register choices. Thirds, sevenths, specified sixths, suspensions and altered fifths receive a small harmonic-function preference; roots and fifths remain eligible. The same bounded target cost considers incoming motion, recent destinations, the theme's direction and one following harmony. Terminal roots of the mapped tonic also remain competitive. A Dm triad does not acquire Dm7's seventh, and a non-chord slash bass does not become a melody target. + +After selecting the strict acoustic/electric pair and its mapped runs, `solo/enclosures.py` compares sparse local ornaments with direct arrivals. The initial vocabulary is a mapped upper neighbor followed by a lower semitone neighbor, each an eighth note, then the exact preselected chord-tone target. The upper is at most three semitones above the target. The lower can already be mapped; chromatic status earns no extra reward. Each candidate replaces a continuously sounding beat before an existing intermediate stop or terminal hold, optionally revising a complete mapped route up to two beats earlier. Identifying features, protected quotations, rests, target timing and the whole required hold remain intact. No target is reattacked merely to make an ornament possible. + +Validation checks the complete sounded gesture, all crossed harmony/scale spans, exact pitch and octave, arrival and release, and actual preparation/resolution timing. Only the contracted lower event can receive an exception. Missing, changed, clipped or unresolved targets invalidate the entire gesture. Tied splits do not change classification. A small chorus frontier retains direct options, with at most one enclosure per phrase and four per 32 bars; sparse use is a soft preference, not a quota. The home theme and development history stay strict. Placement is resolved across the final solo, and backing responds to the decorated foreground. Build-time `enclosures_enabled=False` on compose/compose_pair/arrangement skips only this final pass; no runtime fields or performance expression are added. + 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. 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. 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. -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. +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, candidate diagnostics, destination degrees and complete enclosure contracts. Out-of-map approach notes are reported separately. 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`. +The current enclosure comparison is under `build/solo_enclosure_auditions/index.html`: previous production, the new selected strict solo, and that same solo with enclosures, with fixed backing and the final responsive arrangement. `before_enclosure` retains the complete pre-ornament realization, distinct from the plain theme before runs. To capture a new comparison, preserve the prior snapshot as `before/snapshot.json`, then run `./modulus.py python -m generator.solo.audition --output build/solo_enclosure_auditions --capture after` and `--render`. `--no-enclosures` provides a strict capture. These build artifacts are ignored by Git. + To inspect generated assets without building the full package, run: ```sh diff --git a/generator/solo/arrangement.py b/generator/solo/arrangement.py index 7681a3e..705d229 100644 --- a/generator/solo/arrangement.py +++ b/generator/solo/arrangement.py @@ -85,7 +85,7 @@ def backing(baseline, foreground, part, rhythms, references, choices, initial=No generator.model.validate_document(document) return document['staves'][0]['measures'] -def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, contexts=None, references=(), stage='elaborated'): +def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, contexts=None, references=(), stage='elaborated', enclosures_enabled=True): """Compose once per song, replacing only already-placed solo section windows.""" analysis = generator.solo.analysis.analyze(source) sections = regions(source) @@ -105,7 +105,7 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, if sounding: cutoff -= generator.model.fraction(pickup['duration']) - min(sounding) requests[part] = (template, cutoff, initial) - selected = generator.solo.composer.compose_pair(analysis, requests, scales=scales, sequences=sequences, rhythms=rhythms, contexts=contexts, stage=stage) + selected = generator.solo.composer.compose_pair(analysis, requests, scales=scales, sequences=sequences, rhythms=rhythms, contexts=contexts, stage=stage,enclosures_enabled=enclosures_enabled) for solo, section_index, other in zip(selected, (3, 4), ('electric', 'acoustic')): part = solo.part first, count, offset, length = sections[section_index] diff --git a/generator/solo/audition.py b/generator/solo/audition.py index 2f41303..1c1e21c 100644 --- a/generator/solo/audition.py +++ b/generator/solo/audition.py @@ -78,13 +78,13 @@ def fingerprint(): def resources(): return (generator.rhythm.load_library(pathlib.Path('data/rhythms')), generator.scales.library.load_library(pathlib.Path('data/scales')), generator.solo.sequences.load_library(pathlib.Path('data/sequences'))) -def capture(root, stage, libraries=None, contexts=None): +def capture(root, stage, libraries=None, contexts=None, *, enclosures_enabled=True): destination = root / stage if destination.exists(): raise ValueError(f'{destination}: snapshot already exists; choose a new output root') rhythms, scales, sequences = libraries or resources() reference_library = generator.solo.references.load_library(pathlib.Path('data/lead_phrases')) - result = {'stage': stage, 'implementation': fingerprint(), 'reference_versions': [r.version for r in reference_library], 'songs': {}} + result = {'enclosures_enabled':enclosures_enabled,'stage': stage, 'implementation': fingerprint(), 'reference_versions': [r.version for r in reference_library], 'songs': {}} try: result['policy'] = importlib.import_module('generator.solo.policy').describe() except ModuleNotFoundError: @@ -94,7 +94,7 @@ def capture(root, stage, libraries=None, contexts=None): checkpoint = root / f'.{stage}_{path.stem}_checkpoint.json' if checkpoint.exists(): saved = json.loads(checkpoint.read_text()) - if saved['implementation'] == result['implementation'] and saved.get('reference_versions') == result['reference_versions'] and saved['song']['source_sha256'] == source_hash: + if saved.get('enclosures_enabled',True)==enclosures_enabled and saved['implementation'] == result['implementation'] and saved.get('reference_versions') == result['reference_versions'] and saved['song']['source_sha256'] == source_hash: result['songs'][path.stem] = saved['song'] result.setdefault('resumed_songs', []).append(path.stem) continue @@ -107,7 +107,7 @@ def capture(root, stage, libraries=None, contexts=None): if shared: for context in shared.values(): context.diagnostics = generator.solo.realization.SearchContext().diagnostics - generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos, revise=stage != 'phrase', contexts=shared, references=reference_library) + generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos, revise=stage != 'phrase', contexts=shared, references=reference_library,enclosures_enabled=enclosures_enabled) song = {'source_sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'parts': parts, 'solos': {}, 'elapsed_seconds': time.monotonic() - started} regions = generator.solo.arrangement.regions(document) for solo, index in zip(solos, (3, 4)): @@ -135,7 +135,7 @@ def capture(root, stage, libraries=None, contexts=None): drafts.append({'id': phrase.plan.id, 'draft_id': phrase.draft.id, 'first': begin, 'count': stop - begin, 'document': view}) song['solos'][solo.part]['draft_documents'] = drafts stages = {} - for stage_name in ('initial', 'refined'): + for stage_name in ('initial', 'refined', 'before_enclosure'): if not all(getattr(p, stage_name) for p in solo.phrases): continue notes = tuple(n for p in solo.phrases for n in getattr(p, stage_name).notes) @@ -157,7 +157,7 @@ def capture(root, stage, libraries=None, contexts=None): 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 root.mkdir(parents=True, exist_ok=True) - checkpoint.write_text(json.dumps({'implementation': result['implementation'], 'reference_versions': result['reference_versions'], 'song': song}) + '\n') + checkpoint.write_text(json.dumps({'enclosures_enabled':enclosures_enabled,'implementation': result['implementation'], 'reference_versions': result['reference_versions'], 'song': song}) + '\n') destination.mkdir(parents=True) (destination / 'snapshot.json').write_text(json.dumps(result, indent=2) + '\n') @@ -240,6 +240,19 @@ def render(root): samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache) clips.append((path, samples)) manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'window': label, 'first': offset, 'count': count, 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']}) + if stage == 'after' and 'before_enclosure' in solo.get('lead_stages', {}): + strict = excerpt(solo['lead_stages']['before_enclosure']['document'], offset, count) + strict_prefix=prefix.with_name(prefix.name+'_strict') + generator.musicxml_writer.write_musicxml(strict_prefix.with_suffix('.musicxml'),strict) + backing=excerpt(baseline['parts'][other],first,count) + vocal=excerpt(baseline['parts']['vocal'],first,count) + path=strict_prefix.with_name(strict_prefix.name+'_fixed').with_suffix('.wav') + clips.append((path,mix((strict,backing,vocal),fractions.Fraction(solo['tempo']),root,cache))) + manifest['clips'].append({'path':path.relative_to(root).as_posix(),'kind':'same_selected_theme_before_enclosures','window':label}) + if label=='full': + path=strict_prefix.with_name(strict_prefix.name+'_solo').with_suffix('.wav') + clips.append((path,mix((strict,),fractions.Fraction(solo['tempo']),root,cache))) + manifest['clips'].append({'path':path.relative_to(root).as_posix(),'kind':'strict_unaccompanied','window':label}) if stage == 'after' and 'refined' in solo.get('lead_stages', {}): plain = excerpt(solo['lead_stages']['refined']['document'], offset, count) plain_prefix = prefix.with_name(prefix.name + '_plain') @@ -328,13 +341,17 @@ def write_passage_index(root, snapshots): for stage in ('before', 'after'): metric = snapshots[stage]['songs'][name]['solos'][part]['metrics'] body.append(f"

{stage.title()}: {metric['attacks_per_four_beats']:.2f} attacks / four beats; {metric['sounding_occupancy']:.1%} occupancy; longest unplanned gap {metric['longest_unplanned_gap_beats']:g} beats.

") + strict_available='before_enclosure' in song['solos'][part].get('lead_stages',{}) plain_available='refined' in song['solos'][part].get('lead_stages',{}) - body.append('' + ('' if plain_available else '') + '') + body.append('
PassageBefore, fixed backingAfter, fixed backingAfter, responsive backingPlain theme, fixed backing
' + ('' if strict_available else '') + ('' if plain_available else '') + '') for window in ('full', 'opening8', 'closing4'): body.append(f'') for stage, mix_name in (('before', 'fixed'), ('after', 'fixed'), ('after', 'responsive')): prefix = stage + '/' + name + '_' + part + '_' + window body.append(f'') + if strict_available: + prefix=f'after/{name}_{part}_{window}_strict' + body.append(f'') if plain_available: prefix=f'after/{name}_{part}_{window}_plain' body.append(f'') @@ -352,13 +369,14 @@ def main(): action.add_argument('--capture', choices=(*STAGES, 'before', 'after', 'phrase', 'revised')) action.add_argument('--compare-phrases', action='store_true', help='capture both new stages using shared concrete draft pools') action.add_argument('--render', action='store_true') + parser.add_argument('--no-enclosures',action='store_true',help='retain the identical selected strict melody before ornamentation') args = parser.parse_args() if args.compare_phrases: libraries, contexts = resources(), {} capture(args.output, 'phrase', libraries, contexts) capture(args.output, 'revised', libraries, contexts) elif args.capture: - capture(args.output, args.capture) + capture(args.output, args.capture,enclosures_enabled=not args.no_enclosures) else: render(args.output) diff --git a/generator/solo/composer.py b/generator/solo/composer.py index cd9e658..80e9522 100644 --- a/generator/solo/composer.py +++ b/generator/solo/composer.py @@ -2,6 +2,9 @@ from __future__ import annotations import dataclasses import fractions +import time + +import generator.solo.enclosures import generator.fretboard import generator.harmony @@ -15,7 +18,8 @@ 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', mode='thematic'): +def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated', mode='thematic', enclosures_enabled=True): + started = time.monotonic() 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) @@ -26,13 +30,16 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia context = context if context is not None else generator.solo.realization.SearchContext() options = leads.solo_options(analysis, measures, part, end, choices, sequences, rhythms, context, stage, mode) result = options[0][1] + result.diagnostics['strict_composition_seconds'] = time.monotonic()-started + result = generator.solo.enclosures.refine(result,sequences,enclosures_enabled and stage=='elaborated' and mode=='thematic') validate(result) return result -def compose_pair(analysis, requests, *, scales, sequences, rhythms, contexts=None, stage='elaborated', mode='thematic'): +def compose_pair(analysis, requests, *, scales, sequences, rhythms, contexts=None, stage='elaborated', mode='thematic', enclosures_enabled=True): """Select two recognizable interpretations together, before arranging backing.""" from generator.solo import leads + started = time.monotonic() pools = {} for part, (measures,end,initial) in requests.items(): timeline = generator.scales.timeline.read({'measures': measures},initial=initial) @@ -52,10 +59,15 @@ def compose_pair(analysis, requests, *, scales, sequences, rhythms, contexts=Non 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) + strict_seconds=time.monotonic()-started + selected=[] for solo in (acoustic,electric): solo.diagnostics.update(pair_similarity=overlap,pairs_compared=len(pairs),solo_alternatives=len(pools[solo.part])) + solo.diagnostics['strict_pair_seconds']=strict_seconds + solo=generator.solo.enclosures.refine(solo,sequences,enclosures_enabled and stage=='elaborated' and mode=='thematic') validate(solo) - return acoustic,electric + selected.append(solo) + return tuple(selected) def phrase_notes(phrase): @@ -120,8 +132,6 @@ 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}') - 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 @@ -131,7 +141,8 @@ def validate(solo): raise ValueError(f'{phrase.plan.id}: planned harmonic arrival was not realized') harmony = generator.solo.harmonic.Harmony(solo.choices) contracts = tuple(dict.fromkeys(t for phrase in solo.phrases if phrase.state for t in phrase.state.treatments)) - checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True) + enclosures = tuple(e for p in solo.phrases if p.draft for e in p.draft.enclosures) + checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures) if not checked.valid: raise ValueError(f'{solo.part} solo: {checked.error}') for phrase in solo.phrases: diff --git a/generator/solo/enclosures.py b/generator/solo/enclosures.py new file mode 100644 index 0000000..5bf1e21 --- /dev/null +++ b/generator/solo/enclosures.py @@ -0,0 +1,192 @@ +"""Bounded target-driven ornaments after strict thematic pair selection. + +The main tune and its home identity remain strictly mapped. Every exception is +an atomic upper/lower/target gesture, never a replacement policy for single notes. +""" +from __future__ import annotations + +import dataclasses +import math +import time +from fractions import Fraction as F + +import generator.model +from generator.solo import types, harmonic, placement, policy, drafts, routes, phrasing + + +def mapped_pitch(number, start, end, harmony): + if number%12 not in types.permitted(harmony.choices,start,end):return None + span=harmony.at(start) + return types.scale_pitch(span.scale,types.scale_index(span.scale,number)) + + +def approach(target, harmony): + """Spelling follows the sounding collection, or explicitly spells resolution.""" + t=target.start; number=target.pitch.number() + upper=next((p for n in range(number+1,number+4) if (p:=mapped_pitch(n,t-1,t-F(1,2),harmony))),None) + if upper is None:return None + lower=mapped_pitch(number-1,t-F(1,2),t,harmony) + if lower is None: + if target.pitch.alter>0: + lower=dataclasses.replace(target.pitch,alter=target.pitch.alter-1) + else: + letters='cdefgab'; index=letters.index(target.pitch.letter) + letter=letters[(index-1)%7]; octave=target.pitch.octave-(index==0) + alter=number-1-generator.model.NATURAL[letter]-12*(octave+1) + lower=types.Pitch(letter,F(alter),octave) + if not all(placement.positions(p.number()) for p in (upper,lower)):return None + identity=target.event_id+':enclosure' + return (types.Note(t-1,t-F(1,2),upper,target.sources,'enclosure_upper',event_id=identity+':upper'), + types.Note(t-F(1,2),t,lower,target.sources,'enclosure_lower',event_id=identity+':lower')) + + +def declared_targets(phrase,harmony): + """Declare eligible existing stops before proposing their ornaments.""" + if not phrase.draft:return () + terminal={a.pitch.number():a for a in phrase.anchors if a.role=='terminal'} + values=[] + held=phrase.draft.notes[0].event_id if phrase.draft.notes and phrase.draft.notes[0].continuation else None + for note in drafts.local_sounding(phrase.draft.notes): + if note.event_id==held:continue + if note.continuation or note.role=='quote' or note.end-note.start<1:continue + if note.pitch.number()%12 not in harmony.members(note.start,note.end):continue + anchor=terminal.get(note.pitch.number()) + role='terminal' if anchor and anchor.earliest<=note.start<=anchor.latest else 'intermediate' + rank=(role!='terminal',phrasing.destination_cost(note.pitch,note.start,note.end,harmony,terminal=role=='terminal'),-(note.end-note.start),note.start) + values.append((rank,note,role)) + return tuple((n,role) for _,n,role in sorted(values)[:policy.DEFAULT.enclosure_targets]) + + +def replace_window(notes,start,end,replacement): + """Only rewrite continuously sounding material; preserve meaningful space.""" + cursor=start;prefix=[];suffix=[] + for note in notes: + if note.end<=start:prefix.append(note);continue + if note.start>=end:suffix.append(note);continue + if note.start>cursor:return None + cursor=max(cursor,note.end) + if note.startend:return None # never clip a pre-existing target sustain + if cursorstart and (n.event_id in ids or n.role=='quote') for n in draft.notes) and not any(t.startstart for t in draft.sustained_tails) + + +def movement(notes): + return sum(max(0,abs(b.pitch.number()-a.pitch.number())-3)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1) + + +def candidates(phrase,harmony,library,tempo,rejections): + draft=phrase.draft + if not draft:return () + if draft.notes and draft.notes[0].continuation: + # This local budget does not reopen a committed preceding phrase. Never + # invent a new attack by detaching its already-sounding entrance. + rejections.append({'target':draft.notes[0].event_id,'reason':'cross-phrase sustain retained; no local rewrite of its context'}) + return () + notes=types.sounding_notes(draft.notes);result=[] + for target,role in declared_targets(phrase,harmony): + start=target.start-1 + if startstart or not editable(draft,first.end,target.start):continue + templates=tuple(n for n in notes if first.start<=n.start=maximum:continue + spacing=.08*max(0,1-float(enclosure.target.start-arrivals[-1][0])/16) if enclosure and arrivals else 0. + repeated=.06*sum(pc==enclosure.target.pitch.number()%12 for _,pc in arrivals) if enclosure else 0. + history=(*arrivals,(enclosure.target.start,enclosure.target.pitch.number()%12)) if enclosure else arrivals + following.append((cost+delta+spacing+repeated,(*chosen,option),history)) + states=sorted(following,key=lambda x:(x[0]+sparse(len(x[2])),policy.stable_rank('enclosure-selection',x[2])))[:policy.DEFAULT.enclosure_frontier] + _,phrases,arrivals=min(states,key=lambda x:(x[0]+sparse(len(x[2])),policy.stable_rank('enclosure-selection',x[2]))) + # Resolve the complete guitar path once, including both sides of phrase joins. + raw=tuple(n for p in phrases for d in p.decisions for n in d.notes) + placed=placement.place(raw,solo.tempo) + if placed is None:raise ValueError('selected enclosure solo has no complete guitar path') + by_id={n.event_id:n for n in placed[1]};final=[] + for phrase in phrases: + decisions=tuple(dataclasses.replace(d,notes=tuple(by_id[n.event_id] for n in d.notes)) for d in phrase.decisions) + notes=tuple(n for d in decisions for n in d.notes) + state=dataclasses.replace(phrase.state,notes=notes,last=notes[-1] if notes else phrase.state.last) if phrase.state else None + final.append(dataclasses.replace(phrase,decisions=decisions,draft=dataclasses.replace(phrase.draft,notes=notes) if phrase.draft else None,state=state)) + diagnostics={**solo.diagnostics,'enclosures_enabled':True,'enclosure_seconds':time.monotonic()-started, + 'enclosure_candidates':options_count,'enclosure_rejections':rejections,'enclosures_selected':len(arrivals)} + return dataclasses.replace(solo,phrases=tuple(final),diagnostics=diagnostics) diff --git a/generator/solo/harmonic.py b/generator/solo/harmonic.py index 17b5e5a..0fe25e7 100644 --- a/generator/solo/harmonic.py +++ b/generator/solo/harmonic.py @@ -92,9 +92,65 @@ class Harmony: if self.step(a, b, note.start) and self.step(b, c, note.start): return 'neighbor' if a == c else 'passing' if (b - a) * (c - b) > 0 else '' return '' - def evaluate(self, notes, treatments=(), *, until=None, complete=False): + def enclosure_events(self, notes, enclosures, until=None): + """Validate whole gestures before granting any exception; never trust labels.""" + notes = generator.solo.types.sounding_notes(notes) + events = {} + for note in notes: + if note.event_id: + if note.event_id in events: + return {}, 'duplicate sounding event ID in enclosure context' + events[note.event_id] = note + accepted, intervals = {}, [] + for contract in enclosures: + upper, lower, target = contract.upper, contract.lower, contract.target + if len({n.event_id for n in (upper, lower, target)}) != 3 or not all(n.event_id for n in (upper, lower, target)): + return {}, contract.id + ': enclosure requires three distinct event IDs' + for expected in (upper, lower, target): + actual = events.get(expected.event_id) + if not actual or (actual.start, actual.end, actual.pitch) != (expected.start, expected.end, expected.pitch): + return {}, contract.id + ': missing or changed enclosure event ' + expected.event_id + if upper.end-upper.start != fractions.Fraction(1,2) or lower.end-lower.start != fractions.Fraction(1,2) or upper.end != lower.start or lower.end != target.start: + return {}, contract.id + ': enclosure must be two contiguous eighths immediately before its target' + if not 0 < upper.pitch.number()-target.pitch.number() <= 3 or lower.pitch.number() != target.pitch.number()-1: + return {}, contract.id + ': enclosure does not surround the exact target register' + if any(n.start < target.start and n.end > upper.start and n.event_id not in (upper.event_id,lower.event_id) for n in notes): + return {}, contract.id + ': interrupted or overlapping enclosure' + if any(a < target.start and b > upper.start for a,b in intervals): + return {}, contract.id + ': overlapping enclosure contracts' + if until is not None and target.end > until: + return {}, contract.id + ': enclosure resolution exceeds the available arrangement' + span = self.at(target.start) + tail = contract.protected_tail + if not span or span.chord != contract.chord or target.pitch.number()%12 not in self.members(target.start,target.end): + return {}, contract.id + ': target is not a chord member throughout its hold' + if contract.target_role not in ('intermediate','terminal') or tail.pitch != target.pitch.number() or not target.start <= tail.start < tail.end <= target.end: + return {}, contract.id + ': invalid protected destination tail' + for note in (upper, lower, target): + covered=note.start + for region,start,end in self.segments(note): + if start != covered or not region.scale: + return {}, contract.id + ': missing harmony in enclosure' + if note is not lower and note.pitch.number()%12 not in region.scale.pitches: + return {}, contract.id + ': upper approach or target outside mapped collection' + covered=end + if covered != note.end: + return {}, contract.id + ': incomplete harmony in enclosure' + # The upper neighbor is the nearest common mapped pitch above T. + allowed=generator.solo.types.permitted(self.choices,upper.start,upper.end) + if any(p%12 in allowed for p in range(target.pitch.number()+1,upper.pitch.number())): + return {}, contract.id + ': upper approach is not the nearest mapped neighbor' + accepted[lower.event_id]=contract + accepted[upper.event_id]=contract + intervals.append((upper.start,target.start)) + return accepted, '' + + def evaluate(self, notes, treatments=(), *, until=None, complete=False, enclosures=()): notes = generator.solo.types.sounding_notes(notes) until = until if until is not None else max((n.end for n in notes), default=fractions.Fraction()) + ornaments, error = self.enclosure_events(notes, enclosures, until) if enclosures else ({}, '') + if error: + return Result(False, 0, error=error) pending, valid_contracts = [], [] for treatment in treatments: if treatment.start >= until: @@ -130,11 +186,13 @@ class Harmony: 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), None) - if start > covered or not span.scale or number % 12 not in span.scale.pitches: + enclosure = ornaments.get(note.event_id) + exception = enclosure is not None and note.event_id == enclosure.lower.event_id + if start > covered or not span.scale or number % 12 not in span.scale.pitches and not exception: 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) - if function == 'basic' or note.role == 'quote': + if function == 'basic' or note.role == 'quote' or enclosure: continue if duration >= generator.solo.policy.DEFAULT.long_note and contract is None: return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end} over {span.chord}: sustained {function} without a planned treatment') @@ -144,13 +202,16 @@ class Harmony: if covered < note.end: return Result(False, 0, error=f'{note.pitch.label()} at {covered}: missing harmonic coverage') return Result(True, exposed / max(sounding, 0.001), tuple(pending)) - def describe(self, notes, treatments=()): + def describe(self, notes, treatments=(), enclosures=()): notes = generator.solo.types.sounding_notes(notes) + ornaments, error = self.enclosure_events(notes,enclosures) if enclosures else ({}, '') + if error: + raise ValueError(error) result = [] 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': 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), 'enclosure': ornaments[note.event_id].id if note.event_id in ornaments else '', 'scale_exception': note.event_id in ornaments and self.function(note.pitch.number(), span) == 'outside_collection', '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/phrasing.py b/generator/solo/phrasing.py index 5fff7e0..93d7c64 100644 --- a/generator/solo/phrasing.py +++ b/generator/solo/phrasing.py @@ -123,10 +123,10 @@ def endings(source, incoming, part, harmony, bar, *, limit=8): harmony.members(arrival,arrival+hold),(41,76),hold,'terminal',harmony.at(arrival).chord, hold_until=arrival+hold) cadence=sum(h.ending==(pitch.number()%12,arrival%4,hold) for h in incoming.history) - rank=abs(pitch.number()-goal)/24+.16*min(2,cadence)+.035*abs(float(hold)-3)+.015*float(release) + rank=destination_cost(pitch,arrival,arrival+hold,harmony,incoming,goal,True)+.16*min(2,cadence)+.035*abs(float(hold)-3)+.015*float(release) values.append((rank,anchor)) - selected=sequences.diverse(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])), - lambda x:(x[1].duration,x[1].earliest%bar,x[1].pitch.number()>goal),limit) + selected=destination_options(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])), + lambda x:x[1].pitch,lambda x:x[1].earliest,harmony,limit) return tuple(a for _,a in selected) @@ -159,3 +159,61 @@ def evaluate(notes, incoming, harmony, start, end, bar): leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1) history_cost=repetition(heard(notes,'develop'),incoming.history,bar) return 2*checked.exposure+entry+leaps+.35*max(0,4-density,density-6)+.8*history_cost + + +@functools.lru_cache(maxsize=512) +def defining_members(symbol): + """Written chord identity, without inventing omitted thirds or sevenths.""" + chord=generator.harmony.parse_symbol(symbol) + degrees=generator.harmony.degrees(chord) + root=generator.model.NATURAL[chord['root']['letter']]+generator.model.alteration(chord['root']['alter']) + selected={d for d in (3,6,7) if d in degrees} + if chord['kind'] in ('suspended-second','suspended-fourth'): + selected.update(d for d in (2,4) if d in degrees) + if degrees.get(5,0):selected.add(5) + return frozenset(int(root+generator.harmony.degree_semitones(d,degrees[d]))%12 for d in selected) + + +def destination_cost(pitch, start, end, harmony, incoming=None, goal=None, terminal=False): + """Shared bounded function/trajectory cost at retrieval and final selection.""" + span=harmony.at(start) + if not span or not span.scale:return float('inf') + number=pitch.number(); pc=number%12 + if pc not in harmony.members(start,end):return float('inf') + tonic=generator.model.parse_pitch_name(span.scale.root) + tonic_pc=int(generator.model.NATURAL[tonic['letter']]+generator.model.alteration(tonic['alter']))%12 + function=0. if pc in defining_members(span.chord) or terminal and pc==tonic_pc else .25 + register=min(.15,abs(number-goal)/80) if goal is not None else 0. + entry=min(.15,max(0,abs(number-incoming.last.pitch.number())-7)/80) if incoming and incoming.last else 0. + previous=next((h.ending[0] for h in reversed(incoming.history) if h.ending),None) if incoming else None + recurrence=.04 if previous==pc else 0. + following=next((s for s in harmony.spans if s.start>=end and s.scale and s.roles.basic),None) + lookahead=0. + if following: + future=[p for p in range(max(41,number-12),min(76,number+12)+1) if p%12 in following.roles.basic and p%12 in following.scale.pitches] + if future:lookahead=.04*min(1,min(abs(p-number) for p in future)/5) + # Contour comes from the heard theme, not a universal upward/nearest rule. + direction=0. + if incoming and incoming.home and len(incoming.home.notes)>1 and incoming.last: + motion=incoming.home.notes[-1].pitch.number()-incoming.home.notes[0].pitch.number() + direction=.025 if (number-incoming.last.pitch.number())*motion<0 else 0. + return function+register+entry+recurrence+lookahead+direction + + +def destination_family(pitch, time, harmony): + return next((degrees for pc,degrees in harmony.at(time).roles.degrees if pc==pitch.number()%12),()) + + +def destination_options(values, rank, pitch, time, harmony, limit): + """Keep chord-degree representatives before register/timing variants.""" + ordered=sorted(values,key=rank) + selected=[];seen=set() + for value in ordered: + family=destination_family(pitch(value),time(value),harmony) + if family not in seen: + selected.append(value);seen.add(family) + if len(selected)==limit:return tuple(selected) + for value in ordered: + if value not in selected:selected.append(value) + if len(selected)==limit:break + return tuple(selected) diff --git a/generator/solo/policy.py b/generator/solo/policy.py index 022b168..a0ed21a 100644 --- a/generator/solo/policy.py +++ b/generator/solo/policy.py @@ -48,7 +48,11 @@ class Policy: settling_target: int = 6 return_target: int = 2 history_bars: int = 16 - composition_version: str = 'thematic-phrasing-2' + enclosure_targets: int = 4 + enclosure_routes: int = 4 + enclosure_frontier: int = 8 + enclosure_max_per_32_bars: int = 4 + composition_version: str = 'chord-destinations-enclosures-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 f753e15..06e210d 100644 --- a/generator/solo/realization.py +++ b/generator/solo/realization.py @@ -510,7 +510,7 @@ def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edit except ValueError: return None complete = plan.end == (plan.passage_end or plan.end) - checked = harmony.evaluate(canonical, contracts, until=plan.end, complete=complete) + checked = harmony.evaluate(canonical, contracts, until=plan.end, complete=complete, enclosures=draft.enclosures) valid, _ = generator.solo.harmonic.anchors_valid(draft.anchors, canonical, plan.end, complete=complete, closure=plan.closure) if not checked.valid or not valid or not target_ok(notes, plan.target): return None diff --git a/generator/solo/report.py b/generator/solo/report.py index 205ef94..82f2849 100644 --- a/generator/solo/report.py +++ b/generator/solo/report.py @@ -42,6 +42,8 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off ref=draft.source_reference lines.append(f' Source influence {ref.id}: {ref.treatment}; source {ref.source_id}, beats {ref.start}-{ref.end}. Later developments refer to solo theme {phrase.state.home.id}.') lines.append(' Retained contracts: '+', '.join(f'{f.id}: {f.preserves}' for f in draft.features)) + for enclosure in draft.enclosures: + lines.append(f' Enclosure {enclosure.id}: {enclosure.upper.pitch.label()} → {enclosure.lower.pitch.label()} → {enclosure.target.pitch.label()}; {enclosure.target_role} over {enclosure.chord}; arrival {enclosure.target.start}; protected through {enclosure.target.end}') for route in phrase.routes: lines.append(f' Complete route {route.id}: {route.notes[0].start}-{route.destination.start}; destination {route.destination.pitch.label()}; {route.relationship}; sources {route.sources}') for transform in draft.transformations: @@ -72,17 +74,20 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off root = generator.scales.selection.root_number(choice.span.chord) degrees = [str(d) for d, a in generator.harmony.degrees(choice.span.chord).items() if (root + generator.harmony.degree_semitones(d, a)) % 12 == note.pitch.number() % 12] function = 'chord degree ' + '/'.join(degrees) if degrees else 'in-collection connection' + if note.pitch.number()%12 not in generator.solo.types.permitted(solo.choices,note.start,note.end): + function='validated enclosure exception' lines.append(f' {note.start}-{note.end}: {note.pitch.label()}; {note.role}; {generator.harmony.symbol(choice.span.chord)}, {function}; {choice.scale.label()}; string {note.position.string_number}, fret {note.position.fret}') lines.append('\nSounding harmonic spans (ties merged before classification):') for event in detail['harmonic_roles']: - if float(fractions.Fraction(event['sounding_duration'])) >= 1 or event['protected']: + if float(fractions.Fraction(event['sounding_duration'])) >= 1 or event['protected'] or event['enclosure']: lines.append(' ' + json.dumps(event, sort_keys=True)) return '\n'.join(lines) + '\n' def structure(solo): harmony = generator.solo.harmonic.Harmony(solo.choices) contracts = tuple(dict.fromkeys(t for p in solo.phrases if p.state for t in p.state.treatments)) - checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True) + enclosures = tuple(e for p in solo.phrases if p.draft for e in p.draft.enclosures) + checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures) def rational(value): if isinstance(value, dict): @@ -101,12 +106,12 @@ def structure(solo): anchors = [] for anchor in phrase.anchors: event = generator.solo.harmonic.anchor_note(anchor, notes) - anchors.append({**rational(dataclasses.asdict(anchor)), 'pitch': anchor.pitch.label(), 'completed': event is not None, 'actual_start': str(event.start) if event else None, 'actual_end': str(event.end) if event else None}) + anchors.append({**rational(dataclasses.asdict(anchor)), 'pitch': anchor.pitch.label(), 'degrees': next((list(degrees) for pc,degrees in harmony.at(anchor.earliest).roles.degrees if pc==anchor.pitch.number()%12),[]), 'completed': event is not None, 'actual_start': str(event.start) if event else None, 'actual_end': str(event.end) if event else None}) motifs = tuple(m for m in phrase.state.motifs if phrase.plan.start <= m.source_start < phrase.plan.end) if phrase.state else () entry = {'id': phrase.plan.id, 'passage': phrase.plan.passage_id, 'start': str(phrase.plan.start), 'end': str(phrase.plan.end), 'ending': phrase.plan.ending, 'closure': phrase.plan.closure, 'ending_completed': valid, 'ending_error': reason, 'anchors': anchors, 'motif_lineage': rational([dataclasses.asdict(m) for m in motifs])} if phrase.draft: entry.update({'draft': rational(dataclasses.asdict(phrase.draft)), 'draft_rests': rational(phrase.draft.rests()), 'edits': rational([dataclasses.asdict(e) for e in phrase.edits]), 'relationships': generator.solo.drafts.relationship_features(phrase.draft, tuple(n for d in phrase.decisions for n in d.notes)), 'home': phrase.state.home.id if phrase.state and phrase.state.home else None, 'current_development': phrase.state.development.id if phrase.state and phrase.state.development else None}) - entry['lead_stages'] = {name: {'id': draft.id, 'attacks': len(draft.notes), 'notes': rational([dataclasses.asdict(n) for n in draft.notes])} for name, draft in (('initial', phrase.initial), ('refined', phrase.refined)) if draft} + entry['lead_stages'] = {name: {'id': draft.id, 'attacks': len(draft.notes), 'notes': rational([dataclasses.asdict(n) for n in draft.notes])} for name, draft in (('initial', phrase.initial), ('refined', phrase.refined), ('before_enclosure',phrase.before_enclosure)) if draft} entry['complete_routes'] = rational([dataclasses.asdict(r) for r in phrase.routes]) if phrase.initial and phrase.refined: # A source hold may already be sounding at this generated phrase boundary. @@ -119,7 +124,7 @@ def structure(solo): entry['theme_relationship']=generator.solo.references.relationship(phrase.refined.notes,phrase.state.home) entry['score'] = phrase.score phrases.append(entry) - return {'phrasing': phrasing_report(solo), 'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), 'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, '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 {'phrasing': phrasing_report(solo), 'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), 'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])} def phrasing_report(solo): diff --git a/generator/solo/themes.py b/generator/solo/themes.py index 6a708ff..03a411a 100644 --- a/generator/solo/themes.py +++ b/generator/solo/themes.py @@ -162,9 +162,12 @@ def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operatio lambda x:(len(x[0]),tuple(b-a for a,b in x[0][:3]),x[0][0][0]),policy.DEFAULT.theme_rhythm_alternatives) -def pitches(harmony,note,base,*,members=False,limit=6): +def pitches(harmony,note,base,*,members=False,limit=6,incoming=None): from generator.solo.leads import available domain=available(harmony,note.start,note.end,members=members or note.end-note.start>=2) + if members: + return phrasing.destination_options(domain,lambda p:(phrasing.destination_cost(p,note.start,note.end,harmony,incoming,base),p.number()), + lambda p:p,lambda p:note.start,harmony,limit) 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) @@ -245,7 +248,7 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont # History is evaluated by the caller after retrieving these concrete options. # Every state component used to construct this pool is in its cache key. 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,ending,goal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT) + key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,incoming.history,home,operation,ending,goal,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) @@ -256,19 +259,24 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont 'theme',event_id=f'{ref.id}:{start}:{i}') for i,(a,b) in enumerate(pattern)) base=incoming.last.pitch.number() if incoming.last else ref.notes[0].pitch.number() firsts=pitches(harmony,templates[0],base,limit=6 if batch else 3) - targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,members=True,limit=policy.DEFAULT.theme_destinations) + targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,members=True,limit=policy.DEFAULT.theme_destinations,incoming=incoming) for first in firsts: for target in targets: # Rank joint jobs, retaining rhythm and destination coverage before # expensive path instantiation. The budget is spent on complete routes. - rank=max(0,abs(first.number()-base)-5)/12+abs(target.number()-(goal if goal is not None else base))/48 + rank=max(0,abs(first.number()-base)-5)/12+phrasing.destination_cost(target,templates[-1].start,templates[-1].end,harmony,incoming,goal if goal is not None else base,terminal) jobs.append((rank,templates,label,first,target)) jobs.sort(key=lambda j:(j[0],policy.stable_rank(ref.id,(j[1],j[3],j[4])))) - selected=[];rhythm_seen=set();target_seen=set();entry_seen=set() + selected=[];rhythm_seen=set();target_seen=set();entry_seen=set();degrees_seen=set() + for job in jobs: + family=phrasing.destination_family(job[4],job[1][-1].start,harmony) + if family not in degrees_seen: + selected.append(job);degrees_seen.add(family) for job in jobs: rhythm=tuple((n.start,n.end) for n in job[1]);target=job[4].number();entry=job[3].number() if rhythm not in rhythm_seen or target not in target_seen or entry not in entry_seen: - selected.append(job);rhythm_seen.add(rhythm);target_seen.add(target);entry_seen.add(entry) + if job not in selected:selected.append(job) + rhythm_seen.add(rhythm);target_seen.add(target);entry_seen.add(entry) job_limit=policy.DEFAULT.theme_route_jobs*(batch+1) for job in jobs: if len(selected)>=job_limit:break @@ -301,7 +309,7 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(actual,actual[1:]))/max(1,len(actual)-1) direction=min(1,abs(last.pitch.number()-goal)/12) if goal is not None else 0. # Activity belongs to the complete phrase; contracts are pass/fail. - score=2*checked.exposure+entry+leaps+.35*direction + score=2*checked.exposure+entry+leaps+phrasing.destination_cost(last.pitch,last.start,last.end,harmony,incoming,goal,terminal) anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),(41,76), last.end-last.start,'intermediate',harmony.at(last.start).chord,hold_until=last.end) result.append(Unit(actual,contract,ref,operation,anchor,score,label,connections)) @@ -445,6 +453,7 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits) if phrase is None:continue score=phrasing.evaluate(notes,incoming,harmony,source.start,source.end,bar) + score+=sum(phrasing.destination_cost(u.anchor.pitch,u.anchor.earliest,u.anchor.hold_until,harmony,incoming,phrasing.register_goal(incoming,part,u.notes[0].pitch.number()),u.anchor.role=='terminal') for u in units)/len(units) appearance_state=incoming for unit in units: score+=phrasing.appearance_cost(unit.operation,appearance_state,unit.notes[0].start,solo_end)/len(units) diff --git a/generator/solo/types.py b/generator/solo/types.py index 53ba515..3323e53 100644 --- a/generator/solo/types.py +++ b/generator/solo/types.py @@ -229,6 +229,21 @@ class RouteCandidate: relationship: str sources: tuple[str, ...] +@dataclasses.dataclass(frozen=True) +class EnclosureContract: + """A complete, timed upper/lower approach to a previously chosen arrival. + + These snapshots describe sounding events, independent of notation splits or + guitar placement. Only the lower event may receive a scale exception. + """ + id: str + upper: Note + lower: Note + target: Note + chord: str + target_role: str + protected_tail: SustainedTail + @dataclasses.dataclass(frozen=True) class MelodicDraft: id: str @@ -252,6 +267,7 @@ class MelodicDraft: source_links: tuple[tuple[str, tuple[str, ...]], ...] = () source_reference: SourceReference | None = None sustained_tails: tuple[SustainedTail, ...] = () + enclosures: tuple[EnclosureContract, ...] = () def rests(self): cursor, result = self.start, [] for note in self.notes: @@ -326,6 +342,7 @@ class Phrase: initial: MelodicDraft | None = None refined: MelodicDraft | None = None routes: tuple[RouteCandidate, ...] = () + before_enclosure: MelodicDraft | None = None @dataclasses.dataclass(frozen=True) class Solo: diff --git a/tests/test_solo_drafts.py b/tests/test_solo_drafts.py index 743977c..2a93bc8 100644 --- a/tests/test_solo_drafts.py +++ b/tests/test_solo_drafts.py @@ -183,7 +183,7 @@ class DraftCompositionTests(unittest.TestCase): self.assertIsNotNone(draft) self.assertEqual((draft.start, draft.end), (phrase.plan.start, phrase.plan.end)) prefix = (dataclasses.replace(previous, position=None),) if previous else () - self.assertTrue(harmony.evaluate((*prefix, *draft.notes), phrase.state.treatments, until=draft.end).valid) + self.assertTrue(harmony.evaluate((*prefix, *draft.notes), phrase.state.treatments, until=draft.end, enclosures=draft.enclosures).valid) self.assertTrue(generator.solo.placement.place(draft.notes, self.solo.tempo, previous)) self.assertTrue(draft.transformations) previous = phrase.state.last diff --git a/tests/test_solo_enclosures.py b/tests/test_solo_enclosures.py new file mode 100644 index 0000000..7b1f7bb --- /dev/null +++ b/tests/test_solo_enclosures.py @@ -0,0 +1,242 @@ +import dataclasses +from fractions import Fraction as F +import unittest + +from generator.solo import types, harmonic, enclosures, phrasing, composer, placement, notation, report +import generator.harmony +import generator.model +import tests.scale_fixtures +import tests.solo_fixtures +from tests.test_solo_passages import choices +from tests.test_solo import song + + +def note(start,end,letter,alter=0,octave=4,identity=''): + return types.Note(F(start),F(end),types.Pitch(letter,F(alter),octave),event_id=identity or f'{start}:{letter}') + + +def fixture(symbol='C7',target=None,scale=None): + target=target or note(4,8,'e',identity='target') + harmony=harmonic.Harmony(choices((symbol,symbol),scale=scale)) + pair=enclosures.approach(target,harmony) + contract=types.EnclosureContract('enclosure',*pair,target,harmony.at(target.start).chord,'terminal',types.SustainedTail(target.start,target.end,target.pitch.number())) + return harmony,(*pair,target),contract + + +class EnclosureContracts(unittest.TestCase): + def test_worked_examples_and_actual_collection_membership(self): + hm=tests.scale_fixtures.library().scale('harmonic_minor',1,'D') + for symbol,target,scale,expected in ( + ('C7',note(4,8,'e'),None,('F4','D#4','E4')), + ('FM7',note(4,8,'a',octave=3),None,('Bb3','G#3','A3')), + ('A7',note(4,8,'c',1),hm,('D4','C4','C#4')), + ('Dm',note(4,8,'d'),None,('E4','C#4','D4'))): + with self.subTest(symbol=symbol): + harmony,notes,contract=fixture(symbol,target,scale) + self.assertEqual(tuple(n.pitch.label() for n in notes),expected) + self.assertFalse(harmony.evaluate(notes).valid) + self.assertTrue(harmony.evaluate(notes,enclosures=(contract,),complete=True).valid) + self.assertEqual(harmony.evaluate(notes,enclosures=(contract,)).exposure,0) + self.assertEqual(sum(r['scale_exception'] for r in harmony.describe(notes,enclosures=(contract,))),1) + + def test_in_map_enclosure_does_not_need_exception(self): + h,notes,c=fixture('FM7',note(4,8,'f')) + self.assertEqual([n.pitch.label() for n in notes],['G4','E4','F4']) + self.assertTrue(h.evaluate(notes).valid) + self.assertTrue(h.evaluate(notes,enclosures=(c,)).valid) + self.assertFalse(any(r['scale_exception'] for r in h.describe(notes,enclosures=(c,)))) + + def test_missing_changed_target_wrong_octave_and_repeated_attack_fail(self): + h,notes,c=fixture() + for actual in (notes[:-1],(*notes[:-1],dataclasses.replace(notes[-1],pitch=types.Pitch('e',F(),3))), + (*notes[:-1],dataclasses.replace(notes[-1],start=F(9,2))), + (*notes[:-1],dataclasses.replace(notes[-1],end=F(6)),dataclasses.replace(notes[-1],start=F(6),event_id='reattack'))): + self.assertFalse(h.evaluate(actual,enclosures=(c,)).valid) + self.assertFalse(h.evaluate(notes,enclosures=(c,),until=F(7),complete=True).valid) + + def test_roles_alone_never_authorize_chromatic_pitches(self): + h,notes,c=fixture() + for role in ('enclosure_lower','chromatic_approach','quote'): + actual=tuple(dataclasses.replace(n,role=role) for n in notes) + self.assertFalse(h.evaluate(actual).valid) + + def test_complete_sounding_target_is_partition_invariant(self): + h,notes,c=fixture() + split=(*notes[:-1],dataclasses.replace(notes[-1],end=F(6)),dataclasses.replace(notes[-1],start=F(6),continuation=True,event_id='tie')) + self.assertEqual(h.evaluate(split,enclosures=(c,)),h.evaluate(notes,enclosures=(c,))) + self.assertEqual(h.describe(split,enclosures=(c,)),h.describe(notes,enclosures=(c,))) + self.assertEqual(types.sounding_notes(split),notes) + + def test_orphan_long_approach_order_and_duplicate_contracts_fail(self): + h,notes,c=fixture() + variants=(dataclasses.replace(c,upper=dataclasses.replace(c.upper,start=F(2))), + dataclasses.replace(c,lower=dataclasses.replace(c.lower,pitch=types.Pitch('f',F(1),4))), + dataclasses.replace(c,target=dataclasses.replace(c.target,event_id='missing')), + dataclasses.replace(c,chord='Dm')) + for changed in variants: + actual=(changed.upper,changed.lower,changed.target) + if changed.target.event_id=='missing':actual=notes + self.assertFalse(h.evaluate(actual,enclosures=(changed,)).valid) + self.assertFalse(h.evaluate(notes,enclosures=(c,c)).valid) + + def test_boundary_crossing_uses_arrival_chord_and_each_actual_scale(self): + hm=tests.scale_fixtures.library().scale('harmonic_minor',1,'D') + cs=choices(('A7','Dm')) + cs=(dataclasses.replace(cs[0],scale=hm),cs[1]) + h=harmonic.Harmony(cs);target=note(4,8,'d') + upper,lower=enclosures.approach(target,h) + c=types.EnclosureContract('boundary',upper,lower,target,'Dm','terminal',types.SustainedTail(F(4),F(8),62)) + self.assertTrue(h.evaluate((upper,lower,target),enclosures=(c,)).valid) + self.assertTrue(h.evaluate((upper,lower,target)).valid) # C# still belongs to A7's map + self.assertFalse(h.evaluate((upper,lower,target),enclosures=(dataclasses.replace(c,chord='A7'),)).valid) + missing=harmonic.Harmony((dataclasses.replace(cs[0],scale=None),cs[1])) + self.assertFalse(missing.evaluate((upper,lower,target),enclosures=(c,)).valid) + + def test_held_target_must_remain_chordal_after_boundary(self): + h,notes,c=fixture('Gm7',note(2,6,'f')) + h=harmonic.Harmony(choices(('Gm7','C7'))) + self.assertFalse(h.evaluate(notes,enclosures=(c,)).valid) + + def test_transposition_preserves_contract_validation(self): + h,notes,c=fixture() + for octave in (-1,0,1): + moved=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=n.pitch.octave+octave)) for n in notes) + contract=dataclasses.replace(c,upper=moved[0],lower=moved[1],target=moved[2],protected_tail=dataclasses.replace(c.protected_tail,pitch=moved[2].pitch.number())) + self.assertTrue(h.evaluate(moved,enclosures=(contract,)).valid) + + def test_spelling_and_notation_ties_tab_and_playback_survive(self): + import generator.playback + import generator.musicxml_writer + h,notes,c=fixture(target=note(3,7,'e',identity='target')) + placed=placement.place(notes,F(120))[1] + doc=song(count=2,chords=('C7',)) + measures=notation.measures(doc['staves'][0]['measures'],placed,'enclosure_test') + notation.replace_region(doc,0,measures) + generator.model.validate_document(doc) + import generator.fretboard + generator.fretboard.validate_document(doc) + pitches=[p for m in measures for v in m['voices'] for n in v['notes'] for p in n['pitches']] + self.assertTrue(any(p.get('tie_from') for p in pitches)) + self.assertTrue(any(p['letter']=='d' and generator.model.fraction(p['alter'])==1 for p in pitches)) + self.assertTrue(all(1<=p['tablature']['fret']<=12 for p in pitches)) + # The guitar's written octave must be removed exactly once at playback. + doc['staves'][0]['measures'][0]['transpose']={'diatonic':-7,'chromatic':-12,'octave_change':0} + doc['staves'][0]['measures'][1]['transpose']={'diatonic':-7,'chromatic':-12,'octave_change':0} + timeline=generator.playback.compile_timeline(doc) + self.assertEqual([generator.model.fraction(e['pitch']) for e in timeline['events']],[65,63,64]) + self.assertEqual([generator.model.fraction(e['duration']) for e in timeline['events']],[F(1,2),F(1,2),F(4)]) + tree=generator.musicxml_writer.musicxml_tree(doc) + self.assertTrue(any(e.text=='1' for e in tree.findall('.//pitch/alter'))) + + +class DestinationPriorities(unittest.TestCase): + def test_actual_symbols_define_members_without_slash_or_omitted_degrees(self): + cases={'C7':{4,10},'C':{4},'Cm':{3},'C6':{4,9},'Csus4':{5},'Csus2':{2},'C7b5':{4,6,10},'C7no3':{10},'C/D':{4},'C9':{4,10}} + for symbol,expected in cases.items(): + with self.subTest(symbol=symbol):self.assertEqual(phrasing.defining_members(symbol),expected) + + def test_guide_tones_compete_before_nearest_register_cutoff(self): + from generator.solo.themes import pitches + h=harmonic.Harmony(choices(('C7','FM7'))) + notes=pitches(h,note(2,4,'c'),60,members=True,limit=4) + self.assertEqual({p.number()%12 for p in notes},{0,4,7,10}) + cost=lambda p:phrasing.destination_cost(p,F(2),F(4),h,goal=60) + self.assertLess(cost(types.Pitch('e',F(),4)),cost(types.Pitch('c',F(),4))) + self.assertLess(cost(types.Pitch('b',F(-1),3)),cost(types.Pitch('g',F(),3))) + + +def solo_fixture(): + h=harmonic.Harmony(choices(('C7','C7'))) + notes=(note(0,1,'g'),note(1,2,'e'),note(2,3,'g'),note(3,4,'f'),note(4,8,'e',identity='target')) + notes=placement.place(notes,F(120))[1] + anchor=types.Anchor('close',F(4),F(4),notes[-1].pitch,frozenset({4}),(41,76),F(4),'terminal','C7',hold_until=F(8)) + motif=types.Motif('theme',notes[:3],F(3),()) + plan=types.PhrasePlan('test',F(),F(8),motif,'develop','thematic_recomposition',None,(41,76),F(1),F(),ending='sustain',closure='closed') + transform=types.Transformation('develop','','theme','develop',F(),F(8),tuple(n.event_id for n in notes),tuple((n.start,n.end) for n in notes),preserves=()) + draft=types.MelodicDraft('draft',F(),F(8),notes,(transform,),(),(),(anchor,),None,'develop',sustained_tails=(types.SustainedTail(F(4),F(8),64),)) + decision=types.Decision(F(),F(8),'fixture','develop',notes) + state=types.ContinuationState('test',notes=notes,last=notes[-1]) + phrase=types.Phrase(plan,(decision,),state=state,anchors=(anchor,),draft=draft) + return types.Solo('acoustic',h.choices,(phrase,),F(120),F(8)),h + + +class EnclosureRefinement(unittest.TestCase): + def test_atomic_candidates_preserve_target_timing_and_protections(self): + solo,h=solo_fixture();phrase=solo.phrases[0] + options=enclosures.candidates(phrase,h,tests.solo_fixtures.library(),solo.tempo,[]) + terminal=[p for _,p in options if p.draft.enclosures[0].target_role=='terminal'] + self.assertTrue(terminal) + for p in terminal: + self.assertEqual(p.draft.enclosures[0].target,phrase.draft.notes[-1]) + self.assertTrue(h.evaluate(p.draft.notes,enclosures=p.draft.enclosures).valid) + actual=placement.place(p.draft.notes,solo.tempo)[1] + placed=dataclasses.replace(p,decisions=(dataclasses.replace(p.decisions[0],notes=actual),),state=dataclasses.replace(p.state,notes=actual)) + composer.validate(dataclasses.replace(solo,phrases=(placed,))) + protected=dataclasses.replace(phrase,draft=dataclasses.replace(phrase.draft,protected=tuple(n.event_id for n in phrase.draft.notes))) + self.assertFalse(enclosures.candidates(protected,h,tests.solo_fixtures.library(),solo.tempo,[])) + + def test_intermediate_enclosure_leaves_continuation_and_terminal_hold_intact(self): + solo,h=solo_fixture();phrase=solo.phrases[0] + options=enclosures.candidates(phrase,h,tests.solo_fixtures.library(),solo.tempo,[]) + intermediate=[p for _,p in options if p.draft.enclosures[0].target_role=='intermediate'] + self.assertTrue(intermediate) + for candidate in intermediate: + target=candidate.draft.enclosures[0].target + self.assertTrue(any(n.start>target.start for n in candidate.draft.notes)) + self.assertEqual(candidate.draft.notes[-1],phrase.draft.notes[-1]) + self.assertEqual(candidate.state.home,phrase.state.home) + + def test_short_score_can_choose_direct_arrivals_without_a_quota(self): + solo,h=solo_fixture();phrase=solo.phrases[0] + protected=dataclasses.replace(phrase,draft=dataclasses.replace(phrase.draft,protected=tuple(n.event_id for n in phrase.draft.notes))) + result=enclosures.refine(dataclasses.replace(solo,phrases=(protected,)),tests.solo_fixtures.library()) + self.assertEqual(result.diagnostics['enclosures_selected'],0) + self.assertEqual([(n.pitch,n.start,n.end) for n in result.notes()],[(n.pitch,n.start,n.end) for n in solo.notes()]) + self.assertTrue(result.diagnostics['enclosure_rejections']) + + def test_already_sounding_entrance_is_not_detached_for_ornamentation(self): + solo,h=solo_fixture();p=solo.phrases[0] + held=dataclasses.replace(p.draft.notes[0],continuation=True) + p=dataclasses.replace(p,draft=dataclasses.replace(p.draft,notes=(held,*p.draft.notes[1:]))) + self.assertFalse(any(n.event_id==held.event_id for n,_ in enclosures.declared_targets(p,h))) + rejected=[] + self.assertFalse(enclosures.candidates(p,h,tests.solo_fixtures.library(),solo.tempo,rejected)) + self.assertIn('sustain retained',rejected[0]['reason']) + self.assertTrue(p.draft.notes[0].continuation) + + def test_no_gap_filling_and_no_unplayable_enclosure(self): + solo,h=solo_fixture();p=solo.phrases[0] + draft=dataclasses.replace(p.draft,notes=tuple(n for n in p.draft.notes if n.start!=3)) + candidates=enclosures.candidates(dataclasses.replace(p,draft=draft),h,tests.solo_fixtures.library(),solo.tempo,[]) + self.assertFalse(any(c.draft.enclosures[0].target.event_id=='target' for _,c in candidates)) + self.assertIsNone(enclosures.approach(note(4,8,'e',octave=5),h)) + + def test_disabled_is_exact_strict_input_and_selection_is_deterministic(self): + solo,h=solo_fixture();library=tests.solo_fixtures.library() + disabled=enclosures.refine(solo,library,False) + self.assertEqual(disabled.notes(),solo.notes()) + first=enclosures.refine(solo,library);second=enclosures.refine(solo,library) + self.assertEqual(first.notes(),second.notes()) + self.assertEqual(enclosures.strict(first).notes(),solo.notes()) + composer.validate(first);composer.validate(disabled) + self.assertTrue(report.structure(first)['harmonic_valid']) + + +class EnclosureCompositionAPI(unittest.TestCase): + def test_disable_only_removes_post_selection_ornaments(self): + from generator.solo import analysis + import tests.rhythm_fixtures + doc=song(count=8,chords=('Gm7','C7','FM7','BbM7','Em7b5','A7','Dm7','Dm7')) + a=analysis.analyze(doc) + args=(a,doc['staves'][0]['measures'],'acoustic',F(32)) + kwargs=dict(scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library()) + decorated=composer.compose(*args,**kwargs) + strict=composer.compose(*args,**kwargs,enclosures_enabled=False) + self.assertEqual(enclosures.strict(decorated).notes(),strict.notes()) + self.assertEqual([p.state.home for p in decorated.phrases],[p.state.home for p in strict.phrases]) + self.assertEqual([p.anchors for p in decorated.phrases],[p.anchors for p in strict.phrases]) + self.assertTrue(harmonic.Harmony(strict.choices).evaluate(strict.notes()).valid) + self.assertTrue(all(not p.draft.enclosures for p in strict.phrases)) + self.assertTrue(any(p.draft.enclosures for p in decorated.phrases)) + +if __name__=='__main__':unittest.main()
PassageBefore, fixed backingAfter, fixed backingAfter, responsive backingSame selected solo, without enclosuresPlain theme, fixed backing
{window}MusicXML · Report' + (f' · Passage data' if stage == 'after' else '') + 'Strict notationPlain theme notation