diff --git a/README.md b/README.md index 4a854db..68097b3 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,13 @@ 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, `data/sequences`, and `data/rhythms` to compose acoustic and electric solos with transformed motifs, connected melodic lines, varying activity, sustained arrivals, and planned space. Phrase landmarks guide motion across barlines; 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. The Python build uses these collections, the song melody, `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. + +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. + +Concrete continuations start from the actual incoming pitch and guitar position. Relative routes from `data/sequences` use exact octave-aware scale indices; a collection change adapts actual pitches without transferring stale degree numbers. Realized statements supply subsequent rhythm, contour, harmonic-anchor and transformation history. Intermediate arrivals permit continuation; terminal arrivals must meet their duration and ending contract. Activity and occupancy targets remain separate from these harmonic requirements. Search budgets remain 64 states, 32 extensions and four retained alternatives, with a rolling gesture window and at most two explicit development-contract replans. Exhaustion reports the failed continuation or contract, not a claim of musical impossibility. + +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. To inspect generated assets without building the full package, run: diff --git a/generator/solo/audition.py b/generator/solo/audition.py index 688ddd7..01a325c 100644 --- a/generator/solo/audition.py +++ b/generator/solo/audition.py @@ -94,7 +94,7 @@ def capture(root, stage): regions = generator.solo.arrangement.regions(document) for solo, index in zip(solos, (3, 4)): first, count, offset, length = regions[index] - song['solos'][solo.part] = {'first': first, 'count': count, 'offset': str(offset), 'length': str(length), 'tempo': str(solo.tempo), 'metrics': metrics(solo), 'report': generator.solo.report.describe(solo, scales)} + song['solos'][solo.part] = {'first': first, 'count': count, 'offset': str(offset), 'length': str(length), 'tempo': str(solo.tempo), 'metrics': metrics(solo), 'report': generator.solo.report.describe(solo, scales), 'structure': generator.solo.report.structure(solo)} print(path.stem, solo.part, json.dumps(song['solos'][solo.part]['metrics']), flush=True) result['songs'][path.stem] = song destination.mkdir(parents=True) @@ -152,27 +152,33 @@ def mix(documents, tempo, root, cache): return output def render(root): - snapshots = {stage: json.loads((root / stage / 'snapshot.json').read_text()) for stage in STAGES} + stages = ('before', 'after') if (root / 'before' / 'snapshot.json').exists() else STAGES + snapshots = {stage: json.loads((root / stage / 'snapshot.json').read_text()) for stage in stages} cache, clips, manifest = {}, [], {'sample_rate': generator.playback.SAMPLE_RATE, 'stages': {}, 'clips': []} for stage, snapshot in snapshots.items(): manifest['stages'][stage] = {k: v for k, v in snapshot.items() if k != 'songs'} for song_name, song in snapshot['songs'].items(): - baseline = snapshots['baseline']['songs'][song_name] + baseline = snapshots[stages[0]]['songs'][song_name] if song['source_sha256'] != baseline['source_sha256']: raise ValueError(f'{song_name}: source changed between audition stages') for part, solo in song['solos'].items(): other = 'electric' if part == 'acoustic' else 'acoustic' - first, count = solo['first'], min(8, solo['count']) - foreground = excerpt(song['parts'][part], first, count) - prefix = root / stage / f'{song_name}_{part}' - generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), foreground) - prefix.with_suffix('.txt').write_text(solo['report']) - for responsive in [False, True] if stage == 'D' else [False]: - backing = excerpt((song if responsive else baseline)['parts'][other], first, count) - path = prefix.with_name(prefix.name + ('_responsive' if responsive else '_fixed')).with_suffix('.wav') - samples = mix((foreground, backing), fractions.Fraction(solo['tempo']), root, cache) - clips.append((path, samples)) - manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']}) + windows = [('full', 0, solo['count']), ('opening8', 0, min(8, solo['count'])), ('closing4', max(0, solo['count'] - 4), min(4, solo['count']))] if stages[0] == 'before' else [('', 0, min(8, solo['count']))] + for label, offset, count in windows: + first = solo['first'] + offset + foreground = excerpt(song['parts'][part], first, count) + prefix = root / stage / (f'{song_name}_{part}' + ('_' + label if label else '')) + generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), foreground) + prefix.with_suffix('.txt').write_text(solo['report']) + if 'structure' in solo: + prefix.with_suffix('.json').write_text(json.dumps(solo['structure'], indent=2) + '\n') + for responsive in [False, True] if stage == stages[-1] else [False]: + backing = excerpt((song if responsive else baseline)['parts'][other], first, count) + vocal = excerpt(baseline['parts']['vocal'], first, count) + path = prefix.with_name(prefix.name + ('_responsive' if responsive else '_fixed')).with_suffix('.wav') + 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']}) # One common attenuation preserves relative loudness and identical backing. peak = max(abs(value) for _, samples in clips for value in samples) gain = min(1, 0.95 / peak) if peak else 1 @@ -180,7 +186,7 @@ def render(root): for path, samples in clips: generator.playback.write_pcm(path, (value * gain for value in samples)) (root / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n') - write_index(root, snapshots) + write_passage_index(root, snapshots) if stages[0] == 'before' else write_index(root, snapshots) print(f'Rendered {len(clips)} comparisons under {root}', flush=True) def write_index(root, snapshots): @@ -197,11 +203,29 @@ def write_index(root, snapshots): body.append(f'

D with responsive backing:

') (root / 'index.html').write_text(''.join(body) + '\n') +def write_passage_index(root, snapshots): + body = ['Connected solo passages

Connected solo passages

Before and after use the same source songs, scale-selection settings, activity targets, tempo and root seed inputs. Fixed comparisons retain the original backing. Responsive comparisons include accompaniment recomposed around the new foreground. The returning vocal pickup and one shared mix gain are preserved.

Listen for a convincing settlement on the current chord, an idea developing across several bars, intermediate arrivals that continue, and a prepared ending. Metrics alone do not establish musical improvement. Fingerprints and diagnostics.

'] + for name, song in snapshots['after']['songs'].items(): + for part in song['solos']: + body.append('

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

') + 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.

") + body.append('') + 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'') + body.append('') + body.append('
PassageBefore, fixed backingAfter, fixed backingAfter, responsive backing
{window}MusicXML · Report' + (f' · Passage data' if stage == 'after' else '') + '
') + (root / 'index.html').write_text(''.join(body) + '\n') + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_auditions')) action = parser.add_mutually_exclusive_group(required=True) - action.add_argument('--capture', choices=STAGES) + action.add_argument('--capture', choices=(*STAGES, 'before', 'after')) action.add_argument('--render', action='store_true') args = parser.parse_args() capture(args.output, args.capture) if args.capture else render(args.output) diff --git a/generator/solo/composer.py b/generator/solo/composer.py index fe7e05a..45f2eac 100644 --- a/generator/solo/composer.py +++ b/generator/solo/composer.py @@ -8,6 +8,7 @@ import generator.harmony import generator.model import generator.scales.selection import generator.scales.timeline +import generator.solo.harmonic import generator.solo.analysis import generator.solo.planning import generator.solo.policy @@ -31,17 +32,37 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia context = generator.solo.realization.SearchContext() def realize(plan, previous): - return generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context) + original = plan + for attempt in range(generator.solo.policy.DEFAULT.replan_attempts + 1): + try: + options = generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context) + if attempt: + context.results[(original, previous)] = options + return options + except generator.solo.realization.SearchExhausted as error: + if attempt == generator.solo.policy.DEFAULT.replan_attempts or plan.treatment in ('quote', 'exact_quote'): + raise + context.diagnostics.setdefault('replanning', []).append({'phrase': plan.id, 'attempt': attempt + 1, 'reason': str(error), 'adjustment': 'contour reference' if attempt == 0 else 'opening reference'}) + # Replan only ordinary development contracts; harmony, anchor pitches, + # required holds, timing, activity and protected quotations stay fixed. + treatment = 'contour_reference' if attempt == 0 else 'opening_reference' + gestures = tuple(dataclasses.replace(g, treatment=treatment) if g.relationship not in ('statement', 'close') else g for g in plan.gestures) + plan = dataclasses.replace(plan, gestures=gestures) phrases = choose_phrases(plans, realize) context.diagnostics['policy'] = generator.solo.policy.describe()['fingerprint'] context.diagnostics['musical_identity'] = generator.solo.analysis.identity(analysis, measures, part) - result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end, context.diagnostics) + 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) validate(result) return result def phrase_notes(phrase): - return generator.solo.types.sounding_notes(n for d in phrase.decisions for n in d.notes) + notes = tuple(n for d in phrase.decisions for n in d.notes) + if notes and notes[0].continuation: + notes = (dataclasses.replace(notes[0], continuation=False), *notes[1:]) + return generator.solo.types.sounding_notes(notes) def transition_cost(first, second): before, after = phrase_notes(first), phrase_notes(second) @@ -70,20 +91,23 @@ def choose_phrases(plans, realize): ranked.append((option.score + incoming, option)) continue try: - following = realize(plans[index + 1], last) + following = realize(plans[index + 1], option.state or last) except generator.solo.realization.SearchExhausted as error: failure = error continue length = float(option.plan.end - option.plan.start) next_length = float(plans[index + 1].end - plans[index + 1].start) - cost = min((option.score * length + future.score * next_length) / (length + next_length) + transition_cost(option, future) for future in following) + shared_passage = bool(plan.passage_id and plan.passage_id == plans[index + 1].passage_id) + cost = min((future.score if shared_passage else (option.score * length + future.score * next_length) / (length + next_length)) + transition_cost(option, future) for future in following) ranked.append((cost + incoming, option)) if not ranked: raise failure or generator.solo.realization.SearchExhausted(f'{plan.id}: no feasible phrase transition') _, chosen = min(ranked, key=lambda item: (item[0], tuple((n.pitch, n.start, n.end, n.position) for n in phrase_notes(item[1])))) result.append(chosen) notes = phrase_notes(chosen) - if notes: + if chosen.state: + previous = chosen.state + elif notes: previous = notes[-1] return tuple(result) @@ -101,3 +125,14 @@ def validate(solo): notes = phrase_notes(phrase) if not generator.solo.realization.target_ok(notes, phrase.plan.target): 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) + if not checked.valid: + raise ValueError(f'{solo.part} solo: {checked.error}') + for phrase in solo.phrases: + if phrase.state: + complete = phrase.plan.end == (phrase.plan.passage_end or phrase.plan.end) + valid, error = generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=complete, closure=phrase.plan.closure) + if not valid: + raise ValueError(error) diff --git a/generator/solo/harmonic.py b/generator/solo/harmonic.py new file mode 100644 index 0000000..6e93d1c --- /dev/null +++ b/generator/solo/harmonic.py @@ -0,0 +1,162 @@ +"""Chord function on exact sounding intervals, separate from scale permission.""" +from __future__ import annotations + +import dataclasses +import fractions +import functools + +import generator.harmony +import generator.model +import generator.solo.policy +import generator.solo.types + +@dataclasses.dataclass(frozen=True) +class Roles: + basic: frozenset[int] + specified: frozenset[int] + degrees: tuple[tuple[int, tuple[int, ...]], ...] + +@functools.lru_cache(maxsize=512) +def degree_roles(kind, root, degrees): + basic, specified, labels = set(), set(), {} + for degree, alteration in degrees: + pitch = int(root + generator.harmony.degree_semitones(degree, alteration)) % 12 + structural = degree in (1, 3, 5, 6, 7) or degree in (2, 4) and kind in ('suspended-second', 'suspended-fourth') + (basic if structural else specified).add(pitch) + labels.setdefault(pitch, []).append(degree) + return Roles(frozenset(basic), frozenset(specified - basic), tuple((p, tuple(v)) for p, v in sorted(labels.items()))) + +def roles(chord): + if not chord or chord['kind'] == 'none': + return Roles(frozenset(), frozenset(), ()) + root = chord['root'] + number = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter']) + return degree_roles(chord['kind'], number, tuple(sorted(generator.harmony.degrees(chord).items()))) + +@dataclasses.dataclass(frozen=True) +class Span: + start: fractions.Fraction + end: fractions.Fraction + roles: Roles + scale: object + chord: str + +@dataclasses.dataclass(frozen=True) +class Result: + valid: bool + exposure: float + pending: tuple[generator.solo.types.HarmonicTreatment, ...] = () + error: str = '' + +class Harmony: + def __init__(self, choices): + self.choices = choices + self.spans = tuple(Span(c.span.start, c.span.end, roles(c.span.chord), c.scale, generator.harmony.symbol(c.span.chord) if c.span.chord else 'N.C.') for c in choices) + def at(self, time): + return next((s for s in self.spans if s.start <= time < s.end), None) + def segments(self, note): + return tuple((s, max(note.start, s.start), min(note.end, s.end)) for s in self.spans if s.start < note.end and s.end > note.start) + def members(self, start, end): + spans = [s for s in self.spans if s.start < end and s.end > start] + return frozenset.intersection(*(s.roles.basic for s in spans)) if spans else frozenset() + def function(self, number, span): + pitch = number % 12 + return 'basic' if pitch in span.roles.basic else 'specified_extension' if pitch in span.roles.specified else 'mapped_motion' if span.scale and pitch in span.scale.pitches else 'outside_collection' + def step(self, first, second, time): + span = self.at(time) + if not span or not span.scale: + return False + try: + return abs(generator.solo.types.scale_index(span.scale, first) - generator.solo.types.scale_index(span.scale, second)) == 1 + except ValueError: + return False + def connected_motion(self, notes, index): + if not 0 < index < len(notes) - 1: + return '' + before, note, after = notes[index - 1:index + 2] + if before.end != note.start or note.end != after.start: + return '' + a, b, c = (n.pitch.number() for n in (before, note, after)) + 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): + 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()) + pending, valid_contracts = [], [] + for treatment in treatments: + if treatment.start >= until: + 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): + return Result(False, 0, error=f'{treatment.id}: color is not specified by the overlapped harmony') + valid_contracts.append(treatment) + continue + if treatment.kind != 'suspension' or treatment.resolution is None or treatment.deadline is None: + return Result(False, 0, error=f'{treatment.id}: incomplete harmonic treatment') + held = next((n for n in notes if n.pitch.number() == treatment.pitch and n.start <= treatment.start - treatment.preparation and n.end > treatment.start), None) + if held is None: + return Result(False, 0, error=f'{treatment.id}: suspension lacks sounding preparation') + if treatment.pitch % 12 not in self.members(treatment.start - treatment.preparation, treatment.start): + return Result(False, 0, error=f'{treatment.id}: preparation is not a chord member') + resolution = next((n for n in notes if n.start == held.end and n.pitch.number() == treatment.resolution and n.start <= treatment.deadline), None) + if resolution: + span = self.at(resolution.start) + if not span or treatment.resolution % 12 not in span.roles.basic or not self.step(treatment.pitch, treatment.resolution, resolution.start): + return Result(False, 0, error=f'{treatment.id}: resolution is not an adjacent chord member') + elif complete or until > treatment.deadline or any(n.start >= held.end for n in notes if n is not held): + return Result(False, 0, error=f'{treatment.id}: unresolved suspension at deadline {treatment.deadline}') + else: + pending.append(treatment) + valid_contracts.append(treatment) + exposed, sounding = 0.0, 0.0 + for index, note in enumerate(notes): + number = note.pitch.number() + duration = note.end - note.start + sounding += float(duration) + covered = note.start + for span, start, end in self.segments(note): + 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) + if function == 'basic' or note.role == 'quote': + continue + contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end), None) + 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') + connected = duration < generator.solo.policy.DEFAULT.stopping_note and self.connected_motion(notes, index) + cost = 0 if contract or connected else 1 if duration >= generator.solo.policy.DEFAULT.stopping_note else 0.25 + exposed += float(end - start) * cost + 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=()): + notes = generator.solo.types.sounding_notes(notes) + result = [] + for index, note in enumerate(notes): + for span, start, end in self.segments(note): + 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 treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end]}) + return result + +def anchor_note(anchor, notes, *, until=None): + for note in notes: + if note.pitch.number() != anchor.pitch.number(): + continue + arrival = note.start if anchor.onset_required else max(note.start, anchor.earliest) + needed = max(arrival + anchor.duration, anchor.hold_until or arrival) + if anchor.earliest <= arrival <= anchor.latest and note.start <= arrival < note.end and note.end >= min(needed, until if until is not None else needed): + return note + return None + +def anchors_valid(anchors, notes, until, *, complete=False, closure='open'): + for anchor in anchors: + if anchor.earliest >= until and not complete: + continue + note = anchor_note(anchor, notes, until=None if complete else until) + if note is None and (complete or until > anchor.latest): + return False, f'{anchor.id}: required {anchor.role} arrival {anchor.earliest}-{anchor.latest} on {anchor.pitch.label()} not completed' + if note and anchor.role == 'terminal' and closure == 'closed' and any(n.start > note.start for n in notes): + return False, f'{anchor.id}: terminal anchor touched then abandoned' + return True, '' diff --git a/generator/solo/motifs.py b/generator/solo/motifs.py new file mode 100644 index 0000000..14dc994 --- /dev/null +++ b/generator/solo/motifs.py @@ -0,0 +1,56 @@ +"""Realized gesture identities and their explicit development relationships.""" +from __future__ import annotations + +import dataclasses +import fractions + +import generator.solo.harmonic +import generator.solo.scoring +import generator.solo.types + +def reference(plan, gesture, realized): + if not realized or gesture is None or gesture.relationship == 'statement': + return plan.motif + # Repetition returns to the introduced identity; extension/answer develops + # the most recently heard related gesture, never an unrelated catalog winner. + return realized[0] if gesture.relationship == 'repeat_with_harmonic_adaptation' else realized[-1] + +def local_notes(notes, start, end): + return tuple(dataclasses.replace(n, start=n.start - start, end=min(n.end, end) - start, position=None, continuation=False) for n in notes if start <= n.start < end) + +def complete(gesture, reference, notes, harmony): + actual = local_notes(notes, gesture.start, gesture.end) + sounded = any(n.start < gesture.end and n.end > gesture.start for n in notes) + active = any(s.scale and s.start < gesture.end and s.end > gesture.start for s in harmony.spans) + if not active: + return True, 0.0, None + if gesture.relationship == 'close': + return sounded, 0.0, None + required = min(len(reference.notes), 2) + if len(actual) < required: + return False, 1.0, None + fidelity = generator.solo.scoring.motif_fidelity(actual, reference, gesture.treatment) + if gesture.treatment == 'rhythmic_reference': + expected = tuple((n.start, min(n.end, gesture.end - gesture.start)) for n in reference.notes if n.start < gesture.end - gesture.start) + if tuple((n.start, n.end) for n in actual) != expected: + return False, 1.0, None + elif gesture.relationship != 'statement' and fidelity >= 0.75: + return False, fidelity, None + degrees = [] + for n in actual: + span = harmony.at(gesture.start + n.start) + degrees.append(dict(span.roles.degrees).get(n.pitch.number() % 12, ()) if span else ()) + result = generator.solo.types.RealizedMotif(gesture.id, reference.id, actual, gesture.end - gesture.start, gesture.relationship, gesture.treatment, tuple(degrees), gesture.start) + return True, fidelity, result + +def interval_steps(reference, harmony, source_start=None): + result = [] + source_start = getattr(reference, 'source_start', source_start or fractions.Fraction()) + for first, second in zip(reference.notes, reference.notes[1:]): + span = harmony.at((source_start or fractions.Fraction()) + first.start) + try: + step = generator.solo.types.scale_index(span.scale, second.pitch.number()) - generator.solo.types.scale_index(span.scale, first.pitch.number()) if span and span.scale else None + except ValueError: + step = None + result.append((step, second.pitch.number() - first.pitch.number())) + return tuple(result) diff --git a/generator/solo/planning.py b/generator/solo/planning.py index ce1fdf9..a5bdf63 100644 --- a/generator/solo/planning.py +++ b/generator/solo/planning.py @@ -1,10 +1,12 @@ from __future__ import annotations +import dataclasses import fractions import generator.harmony import generator.model import generator.scales.selection +import generator.solo.harmonic import generator.solo.analysis import generator.solo.policy import generator.solo.sequences @@ -91,67 +93,135 @@ def activity_passages(start, end, release, activity, shape=0): arrival = i == len(spans) - 1 rate = arrival_rate if arrival else fractions.Fraction(str(min(2, float(desired) * weights[min(i, 2)] / max(denominator, 0.001)))).limit_denominator(1000) occupancy = generator.solo.policy.DEFAULT.arrival_occupancy if arrival else generator.solo.policy.DEFAULT.active_occupancy - result.append(generator.solo.types.Passage(a, b, 'sustained_arrival' if arrival else 'active', rate, fractions.Fraction(str(occupancy)))) + result.append(generator.solo.types.ActivitySpan(a, b, 'sustained_arrival' if arrival else 'active', rate, fractions.Fraction(str(occupancy)))) if release: - result.append(generator.solo.types.Passage(music_end, end, 'space', fractions.Fraction(), fractions.Fraction())) + result.append(generator.solo.types.ActivitySpan(music_end, end, 'space', fractions.Fraction(), fractions.Fraction())) return tuple(result) +def decision(seed, name): + return int(generator.solo.policy.stable_rank(seed, name)[:8], 16) + +def same_harmony(left, right): + return bool(left and right and left.span.chord and right.span.chord and generator.harmony.symbol(left.span.chord) == generator.harmony.symbol(right.span.chord)) + +def resolutions(choices): + distinct = [] + for choice in choices: + if not distinct or not same_harmony(distinct[-1], choice): + distinct.append(choice) + return tuple(right for left, right in zip(distinct, distinct[1:]) if left.span.chord and right.span.chord and generator.scales.selection.dominant(left.span.chord) and (generator.scales.selection.root_number(left.span.chord) - generator.scales.selection.root_number(right.span.chord)) % 12 == 7) + +def anchor_backbones(identifier, start, end, release, register, choices, bar, seed): + active = [c for c in choices if c.scale and c.span.start < end - release and c.span.end > start] + if not active: + return tuple([()]) + finish = min(end - release, active[-1].span.end) + terminal_duration = min(fractions.Fraction(2), (finish - start) / 2) + terminal_start = max(start, active[-1].span.start, finish - terminal_duration) + terminal_duration = finish - terminal_start + opening = max(start, active[0].span.start) + moments = [(opening, 'opening')] + meaningful = list(resolutions(active)) + [b for a, b in zip(active, active[1:]) if a.scale.pitches != b.scale.pitches] + for choice in sorted(meaningful, key=lambda c: c.span.start): + time = choice.span.start + if time >= moments[-1][0] + bar * fractions.Fraction(3, 2) and time + bar < terminal_start and len(moments) < 4: + moments.append((time, 'intermediate')) + if terminal_start <= opening + fractions.Fraction(1, 2): + moments = [] + moments.append((terminal_start, 'terminal')) + states = [(0.0, ())] + for index, (time, role) in enumerate(moments): + choice = next(c for c in active if c.span.start <= time < c.span.end) + chord_roles = generator.solo.harmonic.roles(choice.span.chord) + eligible = (chord_roles.basic | chord_roles.specified) & choice.scale.pitches + duration = terminal_duration if role == 'terminal' else fractions.Fraction(1, 2) + latest = time if role == 'terminal' else min(time + fractions.Fraction(3, 2), choice.span.end - duration, terminal_start - duration) + pitches = [generator.solo.types.scale_pitch(choice.scale, degree) for degree in range(-21, 28)] + pitches = [p for p in pitches if register[0] <= p.number() <= register[1] and p.number() % 12 in eligible] + desired = register[0] + (5 if index == 0 else 8 if role == 'terminal' else 11) + following = [] + for cost, anchors in states: + for pitch in pitches: + jump = abs(pitch.number() - anchors[-1].pitch.number()) if anchors else 0 + direction = 1 if role != 'terminal' else -1 + wrong_way = bool(anchors and (pitch.number() - anchors[-1].pitch.number()) * direction < 0) + preferred = target_pitches(choice.span.chord, role == 'terminal') + score = cost + jump / 12 + 0.3 * wrong_way + abs(pitch.number() - desired) / 18 + 0.3 * (pitch.number() % 12 not in chord_roles.basic) + 0.2 * (role == 'terminal' and pitch.number() % 12 not in preferred) + anchor = generator.solo.types.Anchor(f'{identifier}_anchor_{index + 1}', time, latest, pitch, frozenset(eligible), register, duration, role, generator.harmony.symbol(choice.span.chord), False, finish if role == 'terminal' else None) + following.append((score, (*anchors, anchor))) + states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], generator.solo.policy.stable_rank(seed, tuple(a.pitch for a in item[1]))), lambda item: (item[1][0].pitch.number(), item[1][-1].pitch.number()), generator.solo.policy.DEFAULT.alternatives)) + return tuple(anchors for _, anchors in states) + def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choices, end: fractions.Fraction, part: str, sequences: generator.solo.sequences.Library) -> tuple[generator.solo.types.PhrasePlan, ...]: boundaries = [fractions.Fraction()] for measure in measures: boundaries.append(min(end, boundaries[-1] + generator.model.fraction(measure['duration']))) - head, contrast, invented = choose_motifs(analysis, sequences, boundaries[1]) - seed = generator.solo.policy.stable_rank(generator.solo.analysis.identity(analysis, measures, part), generator.solo.policy.describe()['fingerprint']) - corridor = (55, 74) if part == 'acoustic' else (59, 79) - invented = new_motif(sequences, boundaries[1], choices=choices, register=corridor, seed=seed) - alternate = new_motif(sequences, boundaries[1], choices=choices, register=corridor, seed=seed, variant=1) - result, index = [], 0 + head, contrast, _ = choose_motifs(analysis, sequences, boundaries[1]) + seed = generator.solo.policy.stable_rank(generator.solo.analysis.identity(analysis, measures, part), generator.solo.policy.SEED_NAMESPACE) + activity = fractions.Fraction(str(generator.solo.policy.DEFAULT.acoustic_activity if part == 'acoustic' else generator.solo.policy.DEFAULT.electric_activity)) + result, index, passage_index, inventions = [], 0, 0, {} + cadences = resolutions(choices) while index < len(measures) and boundaries[index] < end: start = boundaries[index] - possible = list(range(min(index + 2, len(measures)), min(index + 4, len(measures)) + 1)) + stops = sorted({min(index + size, len(measures)) for size in (4, 6, 8)}) - def boundary_rank(stop): - time = boundaries[stop] - before = next((c for c in reversed(choices) if c.span.start < time), None) - after = next((c for c in choices if c.span.start >= time and c.span.chord), None) - resolution = before and after and before.span.chord and generator.scales.selection.dominant(before.span.chord) and (generator.scales.selection.root_number(before.span.chord) - generator.scales.selection.root_number(after.span.chord)) % 12 == 7 - source_index = min(stop + int(len(analysis.boundaries) > len(measures) + 1), len(analysis.boundaries) - 1) - source_time = analysis.boundaries[source_index] - preceding = [n for n in analysis.melody if n.start < source_time] - breathing = bool(preceding) and preceding[-1].end <= source_time and (preceding[-1].end < source_time or preceding[-1].end - preceding[-1].start >= 2) - return not resolution, not breathing, abs(stop - index - 3), stop + def destination_rank(stop): + finish = boundaries[stop] + last = next((c for c in reversed(choices) if c.scale and c.span.start < finish and c.span.end > start), None) + resolved = bool(last and any(start < c.span.start < finish and same_harmony(c, last) for c in cadences)) + dominant = bool(last and generator.scales.selection.dominant(last.span.chord)) + return (0 if resolved else 2 if dominant else 1), abs(stop - index - 4), finish - stop = min(possible, key=boundary_rank) + stop = min(stops, key=destination_rank) finish = boundaries[stop] - final = finish == end - phase = (start + finish) / (2 * end) - phrase_seed = generator.solo.policy.stable_rank(seed, (str(start), str(finish))) - choice = int(phrase_seed[:8], 16) - relationship = 'statement' if not result else 'repeat' if len(result) == 1 else 'answer' if len(result) % 2 else 'variation' - if part == 'acoustic': - motif = head if len(result) < 2 else (head, contrast, invented)[choice % 3] - else: - motif = invented if len(result) < 2 else (invented, alternate, head)[choice % 3] - treatment = 'opening_reference' if not result and motif.origin == 'melody' else 'invented' if motif.origin != 'melody' else 'answer' if relationship == 'answer' else ('rhythmic_reference', 'contour_reference')[choice % 2] + if finish <= start: + break + passage_index += 1 + passage_id = f'{part}_passage_{passage_index}' + passage_seed = generator.solo.policy.stable_rank(seed, (str(start), str(finish))) low, high = (55, 74) if part == 'acoustic' else (59, 79) - # Reserve the upper register for a prepared peak, not every active passage. + phase = (start + finish) / (2 * end) register = (low + (5 if fractions.Fraction(1, 2) <= phase < fractions.Fraction(3, 4) else 0), high if phase >= fractions.Fraction(1, 2) else high - 4) - activity = fractions.Fraction(str(generator.solo.policy.DEFAULT.acoustic_activity if part == 'acoustic' else generator.solo.policy.DEFAULT.electric_activity)) - ending = ('connected', 'sustain', 'breath')[choice % 3] - release = fractions.Fraction(1, 2) if choice % 2 else fractions.Fraction(1) - release = min(release, (finish - start) / 4) if ending == 'breath' else fractions.Fraction() - if treatment == 'rhythmic_reference' and motif.duration > finish - start - release: - treatment = 'contour_reference' - closure = 'closed' if final or relationship == 'answer' else 'open' - passages = activity_passages(start, finish, release, activity, choice % 3) - sounding = [p for p in passages if p.intent != 'space'] - peak = sounding[min(1, len(sounding) - 1)] - ascending = choice % 2 == 0 - landmarks = ( - generator.solo.types.Landmark(start, min(start + 1, peak.start), register[0] + (3 if ascending else 8), register[0] + (7 if ascending else 12), 0, 'entrance'), - generator.solo.types.Landmark(peak.start, peak.end, register[1] - 6 if ascending else register[0] + 2, register[1] - 2 if ascending else register[0] + 6, 1 if ascending else -1, 'peak' if ascending else 'low point'), - generator.solo.types.Landmark(sounding[-1].start, sounding[-1].end, register[0] + (4 if ascending else 8), register[0] + (9 if ascending else 13), -1 if ascending else 1, 'arrival'), - ) - result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', start, finish, motif, relationship, treatment, destination(choices, start, finish, final, release, closure), register, activity, release, passages, landmarks, ending, closure, phrase_seed)) + bar = boundaries[index + 1] - start + release = fractions.Fraction(1, 2) if decision(passage_seed, 'breathing') % 3 == 0 else fractions.Fraction() + release = min(release, (finish - start) / 8) + envelope = activity_passages(start, finish, release, activity, decision(passage_seed, 'activity') % 3) + anchors = anchor_backbones(passage_id, start, finish, release, register, choices, bar, passage_seed) + # Prefer four-bar phrasing; extend when a sustained harmony crosses that seam. + middle = min(index + 4, stop) + before = next((c for c in reversed(choices) if c.span.start < boundaries[middle]), None) + after = next((c for c in choices if c.span.start <= boundaries[middle] < c.span.end), None) + extended = stop - index > 4 and same_harmony(before, after) + phrase_stops = [stop] if extended or middle == stop else [middle, stop] + local = tuple(c for c in choices if c.span.start < boundaries[phrase_stops[0]] and c.span.end > start) + slot = decision(passage_seed, 'motif') % 3 + if part == 'acoustic': + slot = 0 if passage_index <= 2 else slot + if slot == 2 and 0 not in inventions: + inventions[0] = new_motif(sequences, bar, choices=local, register=register, seed=passage_seed) + motif = head if slot == 0 else contrast if slot == 1 else inventions[0] + else: + slot = 0 if passage_index == 1 else slot + if slot < 2 and slot not in inventions: + inventions[slot] = new_motif(sequences, bar, choices=local, register=register, seed=passage_seed, variant=slot) + motif = head if slot == 2 else inventions[slot] + gestures, cursor = [], start + while cursor < finish - release: + gesture_end = min(cursor + bar, finish - release) + closing = gesture_end == finish - release + changed = any(c.span.start == cursor and c.scale and prior.scale and c.scale.pitches != prior.scale.pitches for prior, c in zip(choices, choices[1:])) + relationship = 'statement' if not gestures else 'close' if closing else 'repeat_with_harmonic_adaptation' if changed else 'answer' if cursor >= start + (finish - start) / 2 else 'extend' + treatment = 'opening_reference' if not gestures and motif.origin == 'melody' else 'invented' if not gestures else 'rhythmic_reference' if relationship == 'repeat_with_harmonic_adaptation' else 'answer' if relationship in ('answer', 'close') else 'contour_reference' + gestures.append(generator.solo.types.Gesture(f'{passage_id}_gesture_{len(gestures) + 1}', cursor, gesture_end, relationship, treatment)) + cursor = gesture_end + phrase_start = start + for phrase_stop in phrase_stops: + phrase_end = boundaries[phrase_stop] + terminal = phrase_end == finish + treatment = 'opening_reference' if motif.origin == 'melody' else 'invented' + local_gestures = tuple(g for g in gestures if g.start < phrase_end and g.end > phrase_start) + landmarks = tuple(generator.solo.types.Landmark(a.earliest, a.latest, min(v[i].pitch.number() for v in anchors), max(v[i].pitch.number() for v in anchors), 0, a.role) for i, a in enumerate(anchors[0])) if anchors and anchors[0] else () + result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', phrase_start, phrase_end, motif, 'statement' if phrase_start == start else 'continue', treatment, None, register, activity, release if terminal else fractions.Fraction(), envelope, landmarks, 'breath' if terminal and release else 'sustain' if terminal else 'connected', 'closed' if terminal else 'open', passage_seed, passage_id, start, finish, anchors, local_gestures)) + phrase_start = phrase_end index = stop return tuple(result) diff --git a/generator/solo/policy.py b/generator/solo/policy.py index a427901..cafd4d6 100644 --- a/generator/solo/policy.py +++ b/generator/solo/policy.py @@ -11,18 +11,26 @@ class Policy: electric_activity: float = 1.375 active_occupancy: float = 0.75 arrival_occupancy: float = 0.9 - weights: tuple[tuple[str, float], ...] = (('activity', 4), ('occupancy', 4), ('fidelity', 3), ('landmarks', 3), ('register', 1), ('contour', 1), ('connection', 1), ('repetition', 1), ('movement', 0.5)) + weights: tuple[tuple[str, float], ...] = (('activity', 4), ('occupancy', 4), ('fidelity', 3), ('landmarks', 3), ('register', 1), ('contour', 1), ('connection', 1), ('repetition', 1), ('movement', 0.5), ('harmonic_exposure', 2)) beam_width: int = 64 extensions: int = 32 retrieval: int = 32 alternatives: int = 4 + long_note: float = 2.0 + stopping_note: float = 1.0 + suspension_preparation: float = 0.5 + suspension_resolution: float = 1.0 + replan_attempts: int = 2 + +# Preserve the former default root seeds while decoupling them from policy edits. +SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1' DEFAULT = Policy() def describe(): values = dataclasses.asdict(DEFAULT) identity = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest() - return {'fingerprint': identity, 'parameters': values} + return {'fingerprint': identity, 'seed_namespace': SEED_NAMESPACE, 'parameters': values} def stable_rank(seed, value): return hashlib.sha256((seed + repr(value)).encode()).hexdigest() diff --git a/generator/solo/realization.py b/generator/solo/realization.py index 05e58b6..7392c4d 100644 --- a/generator/solo/realization.py +++ b/generator/solo/realization.py @@ -3,12 +3,13 @@ from __future__ import annotations import dataclasses import fractions import functools -import collections import generator.fretboard import generator.model import generator.rhythm import generator.scales.selection +import generator.solo.harmonic +import generator.solo.motifs import generator.solo.placement import generator.solo.planning import generator.solo.scoring @@ -28,6 +29,7 @@ class Cell: end: fractions.Fraction patterns: tuple[tuple[str, tuple[tuple[fractions.Fraction, fractions.Fraction], ...]], ...] motif: generator.solo.types.Motif | None = None + gesture: generator.solo.types.Gesture | None = None @dataclasses.dataclass(frozen=True) class Candidate: @@ -36,6 +38,7 @@ class Candidate: treatment: str rank: tuple fidelity: float = 0 + treatments: tuple[generator.solo.types.HarmonicTreatment, ...] = () def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fractions.Fraction, activity: fractions.Fraction, offset=fractions.Fraction()): result = {} @@ -66,7 +69,7 @@ def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fract selected = generator.solo.sequences.diverse(result.items(), rank, family, 12) return tuple((identifier, pattern) for pattern, identifier in selected) -def cells(plan, measures, rhythms): +def cells(plan, measures, rhythms, choices=()): result, boundaries = [], [fractions.Fraction()] for measure in measures: boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration'])) @@ -82,7 +85,9 @@ def cells(plan, measures, rhythms): while start < music_end: index = next(i for i in range(len(measures)) if boundaries[i] <= start < boundaries[i + 1]) passage = next((p for p in plan.passages if p.start <= start < p.end), None) - end = min(music_end, boundaries[index + 1], passage.end if passage else music_end) + gesture = next((g for g in plan.gestures if g.start <= start < g.end), None) + chord_end = next((c.span.end for c in choices if c.span.start <= start < c.span.end), music_end) + end = min(music_end, boundaries[index + 1], passage.end if passage else music_end, gesture.end if gesture else music_end, chord_end) activity = passage.activity if passage else plan.activity patterns = rhythm_patterns(rhythms, measures[index]['time_signature'], end - start, activity, start - boundaries[index]) patterns += (('hold', ((fractions.Fraction(), end - start),)), ('space', ())) @@ -90,7 +95,7 @@ def cells(plan, measures, rhythms): onset = max(start, plan.target.earliest) - start patterns = (('arrival_and_release', ((onset, end - start),)), *patterns) motif = plan.motif if not result else None - result.append(Cell(start, end, patterns, motif)) + result.append(Cell(start, end, patterns, motif, gesture)) start = end if plan.release: result.append(Cell(music_end, plan.end, (('planned_release', ()),))) @@ -111,27 +116,6 @@ def options(choices, start, end, register): def target_ok(notes, target): return target is None or any(target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches for n in notes) -def thematic_paths(cell, spans, choices, plan): - states = [(0.0, ())] - for index, (start, end) in enumerate(spans): - following = [] - available = options(choices, start, end, plan.register) - for _, path in states: - for pitch in available: - reference = cell.motif.notes[min(len(cell.motif.notes) - 1, index * len(cell.motif.notes) // max(1, len(spans)))] - note = generator.solo.types.Note(start, end, pitch, reference.sources, 'motif') - notes = (*path, note) - cost = generator.solo.scoring.motif_fidelity(notes, cell.motif, plan.treatment) - direction = plan.landmarks[1].direction if len(plan.landmarks) > 1 else 0 - motion = notes[-1].pitch.number() - notes[0].pitch.number() - cost += 0.1 * (direction and motion * direction < 0) - cost += sum(generator.solo.scoring.corridor(n.pitch.number(), *plan.register) for n in notes) / (12 * len(notes)) - following.append((cost, notes)) - states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], tuple(n.pitch for n in item[1])), lambda item: (item[1][-1].pitch.number(), generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(item[1], item[1][1:])))), BEAM_WIDTH)) - if not states: - break - return [(generator.solo.scoring.motif_fidelity(notes, cell.motif, plan.treatment), notes) for _, notes in states] - def adapt_path(pitches, spans, choices, register): """Connect actual pitches across collections without carrying a stale degree index.""" states = [(0, ())] @@ -145,112 +129,237 @@ def adapt_path(pitches, spans, choices, register): states = sorted(following, key=lambda item: (item[0], tuple(n.pitch for n in item[1])))[:8] return [notes for _, notes in states] -def sequence_paths(spans, choices, plan, sequences): - if not spans: - return [((), 'space')] - first_scale = next((c.scale for c in choices if c.span.start <= spans[0][0] < c.span.end), None) - if first_scale is None: - return [] - allowed = tuple(generator.solo.types.permitted(choices, start, end) for start, end in spans) - if not all(allowed): - return [] - available = options(choices, *spans[0], plan.register) - if len(spans) == 1: - return [((generator.solo.types.Note(*spans[0], pitch, role='path'),), 'single') for pitch in available] - landmark = next((p for p in plan.landmarks if p.latest >= spans[0][0]), None) - center = (landmark.low + landmark.high) // 2 if landmark else sum(plan.register) // 2 - direction = landmark.direction if landmark else 0 - starts = sorted(available, key=lambda p: (generator.solo.scoring.corridor(p.number(), *plan.register), abs(p.number() - center), p))[:8] - paths, identities = [], set() - for first in starts: - degree = next(i for i in range(-21, 28) if generator.solo.types.scale_pitch(first_scale, i) == first) - destination = center - if plan.target and spans[-1][0] >= plan.target.earliest: - compatible = [p for p in options(choices, *spans[-1], plan.register) if p.number() % 12 in plan.target.pitches] - if compatible: - destination = min(compatible, key=lambda p: (abs(p.number() - center), p)).number() - retrieved = sequences.retrieve(len(spans), round((destination - first.number()) / 2), direction) - routes = [(p.offsets(), 'sequences ' + ' + '.join(p.sources)) for p in retrieved] - routes.append(((0,) * len(spans), 'repeated_note')) - for offsets, identifier in routes: - pitches = tuple(generator.solo.types.scale_pitch(first_scale, degree + step) for step in offsets) - if not all(max(41, plan.register[0] - 7) <= p.number() <= min(84, plan.register[1] + 5) for p in pitches): - continue - if all(p.number() % 12 in classes for p, classes in zip(pitches, allowed)): - realized = [tuple(generator.solo.types.Note(start, end, pitch, role='path') for (start, end), pitch in zip(spans, pitches))] - elif any(classes != allowed[0] for classes in allowed): - realized = adapt_path(pitches, spans, choices, plan.register) - identifier = 'harmonic_adaptation of ' + identifier - else: - continue - for notes in realized: - identity = tuple(n.pitch for n in notes) - if identity not in identities: - identities.add(identity) - paths.append((notes, identifier)) - return paths - -def candidates(cell, plan, choices, sequences): +def anchor_treatments(anchors, harmony): result = [] - target = plan.target if plan.target and cell.start <= plan.target.latest < cell.end else None + for anchor in anchors: + span = harmony.at(anchor.earliest) + if span and anchor.pitch.number() % 12 in span.roles.specified: + result.append(generator.solo.types.HarmonicTreatment(anchor.id + '_color', 'color', anchor.pitch.number(), anchor.earliest, anchor.hold_until or anchor.latest + anchor.duration)) + return tuple(result) + +def rhythm_variants(cell, plan, state, anchors, reference): + patterns = cell.patterns + gesture = cell.gesture + if cell.motif and plan.treatment in ('quote', 'exact_quote'): + return [(name, tuple((cell.start + a, cell.start + b) for a, b in pattern), {}, False) for name, pattern in patterns] + if gesture and gesture.treatment == 'rhythmic_reference' and state.motifs: + # The identity is the rhythm actually heard, including its rests. Cropping + # at a search boundary does not create a new rhythmic attack. + pattern = tuple((max(cell.start, gesture.start + n.start) - cell.start, min(cell.end, gesture.start + n.end) - cell.start) for n in reference.notes if gesture.start + n.start < cell.end and gesture.start + n.end > cell.start) + patterns = ((reference.id + ':rhythm', pattern),) + due = [a for a in anchors if cell.start <= a.latest and a.earliest < cell.end and not generator.solo.harmonic.anchor_note(a, state.notes)] + holding = next((a for a in anchors if state.last and state.last.role != 'quote' and a.hold_until and a.hold_until > cell.start and generator.solo.harmonic.anchor_note(a, state.notes, until=cell.start)), None) + if holding: + stop = min(cell.end, holding.hold_until) + return [('anchor_sustain', ((cell.start, stop),), {cell.start: holding.pitch}, True)] + result = [] + for name, pattern in patterns: + spans = tuple((cell.start + a, cell.start + b) for a, b in pattern) + variants = [(spans, {})] + for anchor in due: + following = [] + for current, forced in variants: + onsets = [a for a, b in current if anchor.earliest <= a <= anchor.latest and b - a >= min(anchor.duration, cell.end - a)] + if gesture and gesture.treatment == 'rhythmic_reference': + times = onsets[:2] + else: + times = list(dict.fromkeys(onsets[:2] + [max(cell.start, anchor.earliest)])) + for time in times: + stop = min(cell.end, anchor.hold_until or time + anchor.duration) + original_end = next((b for a, b in current if a == time), stop) + stop = max(stop, original_end) + if gesture and gesture.treatment == 'rhythmic_reference' and stop != original_end: + continue + overlaid = [(a, min(b, time)) for a, b in current if a < time] + overlaid += [(time, stop)] + [(a, b) for a, b in current if a >= stop] + following.append((tuple((a, b) for a, b in overlaid if b > a), {**forced, time: anchor.pitch})) + variants = following + result.extend((name, spans, forced, False) for spans, forced in variants) + return result + +def realize_route(spans, first, steps, forced, choices, plan, harmony, contracts, reference): + """Instantiate relative motion at actual pitches, adapting only at collection changes.""" + path = [] + for index, (start, end) in enumerate(spans): + available = options(choices, start, end, plan.register) + if not available: + return None + if start in forced: + available = tuple(p for p in available if p.number() == forced[start].number()) + if end - start >= generator.solo.policy.DEFAULT.long_note: + members = harmony.members(start, end) + available = tuple(p for p in available if p.number() % 12 in members or any(t.pitch == p.number() and t.start <= start and t.end >= end for t in contracts)) + if not available: + return None + intended = first.number() + if path: + prior = path[-1].pitch.number() + scale = harmony.at(start).scale + step, semitones = steps[(index - 1) % len(steps)] if steps else (0, 0) + try: + intended = generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, prior) + step).number() if step is not None else prior + semitones + except ValueError: + # The old pitch is not in the new collection: preserve its sounding + # coordinate and adapt its intended motion, never transfer degree IDs. + intended = prior + semitones + stopping = end - start >= generator.solo.policy.DEFAULT.stopping_note + basic = harmony.at(start).roles.basic + pitch = min(available, key=lambda p: (abs(p.number() - intended) + (0.6 if stopping and p.number() % 12 not in basic else 0), p)) + source = reference.notes[min(index, len(reference.notes) - 1)].sources if reference.notes else () + path.append(generator.solo.types.Note(start, end, pitch, source, 'motif')) + return tuple(path) + +def candidates(cell, plan, choices, sequences, state=None, anchors=(), harmony=None): + state = state or generator.solo.types.ContinuationState(plan.passage_id) + harmony = harmony or generator.solo.harmonic.Harmony(choices) + reference = generator.solo.motifs.reference(plan, cell.gesture, state.motifs) + treatment = cell.gesture.treatment if cell.gesture else plan.treatment if cell.motif else 'continue' + contracts = tuple(dict.fromkeys((*state.treatments, *anchor_treatments(anchors, harmony)))) + protected = cell.motif and plan.treatment in ('quote', 'exact_quote') local_plan = dataclasses.replace(plan, start=cell.start, end=cell.end, release=fractions.Fraction(), passages=tuple(dataclasses.replace(p, start=max(cell.start, p.start), end=min(cell.end, p.end)) for p in plan.passages if p.start < cell.end and p.end > cell.start)) evaluation = generator.solo.scoring.Evaluation(local_plan, choices) - for identifier, pattern in cell.patterns: - spans = tuple((cell.start + start, cell.start + end) for start, end in pattern) - active = tuple(i for i, (start, end) in enumerate(spans) if not any(c.scale is None and c.span.start < end and c.span.end > start for c in choices)) - local_cell = cell - if len(active) != len(spans): - if cell.motif and plan.treatment in ('quote', 'exact_quote'): + result = [] + for identifier, spans, forced, sustain in rhythm_variants(cell, plan, state, anchors, reference): + active = tuple((a, b) for a, b in spans if generator.solo.types.permitted(choices, a, b)) + if active != spans: + if protected: continue - spans = tuple(spans[i] for i in active) + spans = active identifier += ':harmonic_fragment' - if cell.motif: - references = tuple(cell.motif.notes[min(len(cell.motif.notes) - 1, i * len(cell.motif.notes) // len(pattern))] for i in active) - local_cell = dataclasses.replace(cell, motif=dataclasses.replace(cell.motif, notes=references)) - paths = [] - if local_cell.motif and spans: - if plan.treatment in ('quote', 'exact_quote') and ':fragment' not in identifier and ':harmonic_fragment' not in identifier and ':displaced' not in identifier: - for octaves in (0, 1, -1, 2, -2): - notes = tuple(generator.solo.types.Note(start, end, dataclasses.replace(source.pitch, octave=source.pitch.octave + octaves), source.sources, 'quote') for (start, end), source in zip(spans, local_cell.motif.notes)) - if valid_notes(notes, choices): - paths.append((notes, f'quote octave {octaves:+}', 0)) - if plan.treatment not in ('quote', 'exact_quote'): - for mismatch, notes in thematic_paths(local_cell, spans, choices, plan): - treatment = 'fragment' if 'fragment' in identifier else plan.treatment - paths.append((notes, treatment, mismatch)) + if not spans: + if not protected: + result.append(Candidate((), identifier, 'space', (generator.solo.scoring.value(evaluation.features(())),), treatments=contracts)) + continue + available = options(choices, *spans[0], plan.register) + pending = next((t for t in state.treatments if t.kind == 'suspension' and t.deadline >= cell.start and state.last and state.last.pitch.number() == t.pitch and state.last.end == cell.start and not any(n.pitch.number() == t.resolution and n.start >= t.start for n in state.notes)), None) + if pending: + resolution = next((p for p in available if p.number() == pending.resolution), None) + if resolution is None or spans[0][0] != cell.start: + continue + forced = {**forced, spans[0][0]: resolution} + sustain = False + last = state.last + next_anchor = next((a for a in anchors if a.latest >= cell.start and not generator.solo.harmonic.anchor_note(a, state.notes)), None) + destination = next_anchor.pitch.number() if next_anchor else last.pitch.number() if last else reference.notes[0].pitch.number() + if protected: + paths = [] + for octave in (0, 1, -1, 2, -2): + notes = tuple(generator.solo.types.Note(a, b, dataclasses.replace(n.pitch, octave=n.pitch.octave + octave), n.sources, 'quote') for (a, b), n in zip(spans, cell.motif.notes)) + if valid_notes(notes, choices): + paths.append((notes, f'quote octave {octave:+}')) + elif sustain and last: + paths = [((generator.solo.types.Note(*spans[0], last.pitch, role='sustain', continuation=True),), 'anchor_sustain')] else: - paths = [(notes, name, 0) for notes, name in sequence_paths(spans, choices, plan, sequences)] - for notes, treatment, identity in paths: - notes = tuple(dataclasses.replace(n, role='target' if n.role not in ('motif', 'quote') and target and target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches else n.role) for n in notes) - features = evaluation.features(notes) - fidelity = identity if cell.motif else 0 + near = last.pitch.number() if last else destination + starts = sorted(available, key=lambda p: (abs(p.number() - near), p))[:3] + if spans[0][0] in forced: + starts = [forced[spans[0][0]]] + scale = harmony.at(spans[0][0]).scale + target = min(available, key=lambda p: abs(p.number() - destination)) + paths = [] + motif_steps = generator.solo.motifs.interval_steps(reference, harmony) + gesture_start = cell.gesture.start if cell.gesture else cell.start + heard = tuple(n for n in state.notes if n.start >= gesture_start) + cursor = len(heard) + if motif_steps and last: + step, semitones = motif_steps[max(0, cursor - 1) % len(motif_steps)] + try: + expected = generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, last.pitch.number()) + step).number() if step is not None else last.pitch.number() + semitones + except ValueError: + expected = last.pitch.number() + semitones + supported = min(available, key=lambda p: (abs(p.number() - expected), p)) + if supported not in starts: + starts.append(supported) + if motif_steps: + motif_steps = motif_steps[cursor % len(motif_steps):] + motif_steps[:cursor % len(motif_steps)] + for first in starts: + displacement = generator.solo.types.scale_index(scale, target.number()) - generator.solo.types.scale_index(scale, first.number()) + if next_anchor and next_anchor.earliest > cell.end: + displacement = round(displacement * float((cell.end - cell.start) / (next_anchor.earliest - cell.start))) + direction = (displacement > 0) - (displacement < 0) + routes = [(motif_steps, 'develop ' + reference.id)] + retrieved = sequences.retrieve(len(spans), displacement, direction) + selected = generator.solo.sequences.diverse(retrieved, lambda p: (abs(p.offsets()[-1] - displacement), p.sources), lambda p: generator.solo.sequences.family(p.steps), 4) + for path in selected: + steps = tuple((step, generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, first.number()) + step).number() - first.number()) for step in path.steps) + if motif_steps and treatment != 'rhythmic_reference' and not (cell.gesture and cell.gesture.relationship == 'close'): + # Preserve identifying motion, then let the authored vocabulary + # connect it toward the next anchor. The next cell uses this result. + keep = min(2, len(steps)) + steps = tuple(motif_steps[i % len(motif_steps)] for i in range(keep)) + steps[keep:] + routes.append((steps, 'sequences ' + ' + '.join(path.sources) + '; develop ' + reference.id)) + for steps, name in routes: + notes = realize_route(spans, first, steps, forced, choices, plan, harmony, contracts, reference) + if notes: + paths.append((notes, name)) + if last and last.end == cell.start and spans[0][0] == cell.start and not protected: + # An existing sustain is a real alternative, independent of reattack. + for notes, name in list(paths): + if notes[0].pitch.number() == last.pitch.number() and last.role != 'quote': + paths.append(((dataclasses.replace(notes[0], pitch=last.pitch, continuation=True, role='sustain'), *notes[1:]), 'common_tone; ' + name)) + # Plan a prepared suspension from the incoming sound before constructing + # its continuation. The contract survives subsequent search boundaries. + if last and last.role != 'quote' and last.end == cell.start and cell.end - cell.start >= 1 and last.start <= cell.start - fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_preparation)): + span = harmony.at(cell.start) + prepared = last.pitch.number() % 12 in harmony.members(cell.start - fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_preparation)), cell.start) + if prepared and span and span.scale and last.pitch.number() % 12 in span.scale.pitches and last.pitch.number() % 12 not in span.roles.basic: + resolution_time = cell.start + fractions.Fraction(1, 2) + resolving = [p for p in options(choices, resolution_time, min(cell.end, resolution_time + fractions.Fraction(1, 2)), plan.register) if p.number() % 12 in span.roles.basic and harmony.step(last.pitch.number(), p.number(), resolution_time)] + for pitch in resolving: + contract = generator.solo.types.HarmonicTreatment(f'{plan.passage_id}_suspension_{cell.start}_{pitch.number()}', 'suspension', last.pitch.number(), cell.start, resolution_time, pitch.number(), cell.start + fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_resolution))) + for original, name in paths[:4]: + tail = tuple(n for n in original if n.start > resolution_time) + end = tail[0].start if tail else cell.end + suspended = (generator.solo.types.Note(cell.start, resolution_time, last.pitch, role='sustain', continuation=True), generator.solo.types.Note(resolution_time, end, pitch, role='motif'), *tail) + raw = tuple(dataclasses.replace(n, position=None) for n in state.notes) + if not raw: + continue + combined = generator.solo.types.sounding_notes((*raw, *suspended)) + checked = harmony.evaluate(combined, (*contracts, contract), until=cell.end) + if checked.valid: + features = evaluation.features(tuple(dataclasses.replace(n, continuation=False) for n in suspended)) + features['harmonic_exposure'] = checked.exposure + fidelity = generator.solo.scoring.motif_fidelity(tuple(n for n in combined if n.start >= (cell.gesture.start if cell.gesture else cell.start)), reference, treatment) + features['fidelity'] = fidelity + result.append(Candidate(suspended, identifier + '; prepared_suspension; ' + name, treatment, (generator.solo.scoring.value(features),), fidelity, (*contracts, contract))) + for notes, name in paths: + # Continuations are canonicalized with their incoming sounding event. + raw = tuple(dataclasses.replace(n, position=None) for n in state.notes) + combined = generator.solo.types.sounding_notes((*raw, *notes)) if raw or not notes[0].continuation else () + if not combined: + continue + harmonic = harmony.evaluate(combined, contracts, until=cell.end) + if not harmonic.valid: + continue + local = tuple(dataclasses.replace(n, continuation=False) for n in notes) + features = evaluation.features(local) + gesture_notes = tuple(n for n in combined if n.start >= (cell.gesture.start if cell.gesture else cell.start)) + fidelity = (generator.solo.scoring.motif_fidelity(gesture_notes, reference, treatment) if len(gesture_notes) > 1 else 0.5) if cell.gesture or cell.motif else 0 features['fidelity'] = fidelity - rank = (generator.solo.scoring.value(features),) - result.append(Candidate(notes, identifier, treatment, rank, fidelity)) - # A no-harmony passage is intentional silence, not a failed pitch search. - if not (cell.motif and plan.treatment in ('quote', 'exact_quote')) and not any(c.span.chord and c.span.start < cell.end and c.span.end > cell.start for c in choices): - result = [Candidate((), 'no_harmony', 'space', (0,))] - return sorted(result, key=lambda c: (c.rank, c.source, c.treatment, tuple(n.pitch for n in c.notes))) + features['harmonic_exposure'] = harmonic.exposure + features['connection'] = min(1, abs(notes[0].pitch.number() - last.pitch.number()) / 12) if last else 0 + result.append(Candidate(notes, identifier + '; ' + name, name if protected else treatment, (generator.solo.scoring.value(features),), fidelity, contracts)) + unique = {} + for candidate in sorted(result, key=lambda c: (c.rank, c.source, tuple(n.pitch for n in c.notes))): + unique.setdefault((candidate.notes, candidate.treatments), candidate) + return list(unique.values()) @dataclasses.dataclass class SearchContext: pools: dict = dataclasses.field(default_factory=dict) results: dict = dataclasses.field(default_factory=dict) - evaluations: dict = dataclasses.field(default_factory=collections.OrderedDict) diagnostics: dict = dataclasses.field(default_factory=lambda: {'searches': 0, 'cache_hits': 0, 'generated_candidates': 0, 'evaluated_extensions': 0, 'peak_states': 0, 'candidate_families': {}, 'surviving_families': {}}) def note_family(notes): return generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))) if notes else 'space' -def state_identity(state, plan): - _, decisions, last, _, notes = state - ending = (last.pitch, last.position, last.start, last.end) if last else None - times = tuple((float(n.start), float(n.end)) for n in notes) - windows = tuple((float(p.start), float(p.end)) for p in generator.solo.scoring.passages(plan)) - activity = tuple((sum(start <= a < end for a, _ in times), sum(max(0, min(b, end) - max(a, start)) for a, b in times)) for start, end in windows) - rhythm = tuple((n.start, n.end) for n in notes[-3:]) - progress = tuple(any(p.earliest <= n.start <= p.latest and p.low <= n.pitch.number() <= p.high for n in notes) for p in plan.landmarks) - return ending, activity, rhythm, progress, tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes[-4:], notes[-4:][1:])), tuple(d.treatment for d in decisions) +@dataclasses.dataclass(frozen=True) +class BeamState: + score: float + decisions: tuple + music: generator.solo.types.ContinuationState + travel: int = 0 + fidelity: tuple[float, ...] = () def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None): context = context if context is not None else SearchContext() @@ -259,78 +368,101 @@ def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=No context.diagnostics['cache_hits'] += 1 return context.results[key] context.diagnostics['searches'] += 1 + harmony = generator.solo.harmonic.Harmony(choices) + incoming = previous if isinstance(previous, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=previous) + continuing = bool(plan.passage_id and incoming.passage_id == plan.passage_id) + initial = incoming if continuing else generator.solo.types.ContinuationState(plan.passage_id, last=incoming.last, treatments=plan.treatments) + backbones = plan.anchor_options or tuple([()]) + # A caller explicitly scheduling quotation owns its rhythm and pitches. Such + # plans must provide compatible anchors; no automatic rewrite is permitted. + states = [BeamState(0, (), dataclasses.replace(initial, backbone=i), initial.travel, initial.fidelity) for i in ([initial.backbone] if continuing else range(len(backbones)))] if plan not in context.pools: - context.pools[plan] = tuple((cell, candidates(cell, plan, choices, sequences)) for cell in cells(plan, measures, rhythms)) - for _, pool in context.pools[plan]: - context.diagnostics['generated_candidates'] += len(pool) - for candidate in pool: - family = note_family(candidate.notes) - counts = context.diagnostics['candidate_families'] - counts[family] = counts.get(family, 0) + 1 - states = [(0.0, (), previous, 0, ())] - for cell, pool in context.pools[plan]: - evaluation = generator.solo.scoring.Evaluation(plan, choices, cell.end) - following, extensions = [], {} - for _, decisions, last, travel, sounded in states: - target_met = target_ok(sounded, plan.target) - - def extension_rank(candidate): - jump = abs(candidate.notes[0].pitch.number() - last.pitch.number()) / 12 if candidate.notes and last else 0 - return candidate.rank[0] + min(1, jump), candidate.source, candidate.treatment, tuple(n.pitch for n in candidate.notes) - - extension_key = (last.pitch if last else None, target_met) - if extension_key not in extensions: - eligible = [c for c in pool if not plan.target or plan.target.latest >= cell.end and cell.end < plan.end or target_met or target_ok(c.notes, plan.target)] - extensions[extension_key] = generator.solo.sequences.diverse(eligible, extension_rank, lambda c: (len(c.notes), note_family(c.notes), c.treatment if cell.motif else ''), CELL_LIMIT) - for candidate in extensions[extension_key]: - variants = [candidate.notes] - passage = next((p for p in plan.passages if p.start <= cell.start < p.end), None) - if cell.motif is None and last and last.end == cell.start and len(candidate.notes) == 1 and candidate.notes[0].start == cell.start and candidate.notes[0].pitch == last.pitch and passage and passage.intent == 'sustained_arrival': - variants.append((dataclasses.replace(candidate.notes[0], continuation=True, role='sustain'),)) - for variant in variants: - context.diagnostics['evaluated_extensions'] += 1 - placed = generator.solo.placement.place(variant, tempo, last) - if placed is None: + context.pools[plan] = cells(plan, measures, rhythms, choices) + evaluation_plan = dataclasses.replace(plan, start=plan.passage_start if plan.passage_start is not None else plan.start, end=plan.passage_end or plan.end) + last_error = '' + for cell in context.pools[plan]: + evaluation = generator.solo.scoring.Evaluation(evaluation_plan, choices, cell.end) + following, pools = [], {} + for state in states: + music = state.music + anchors = backbones[music.backbone] + reference = generator.solo.motifs.reference(plan, cell.gesture, music.motifs) + # Concrete results depend on pitch, fingering, motif, anchor progress, + # rhythm history and unresolved treatments. Abstract rhythms alone cache + # across unrelated incoming lines. + pool_key = (music, anchors) + if pool_key not in pools: + pool = candidates(cell, plan, choices, sequences, music, anchors, harmony) + context.diagnostics['generated_candidates'] += len(pool) + for candidate in pool: + family = note_family(candidate.notes) + counts = context.diagnostics['candidate_families'] + counts[family] = counts.get(family, 0) + 1 + pools[pool_key] = generator.solo.sequences.diverse(pool, lambda c: (c.rank, c.source), lambda c: (len(c.notes), note_family(c.notes), bool(c.notes and c.notes[0].continuation)), CELL_LIMIT) + for candidate in pools[pool_key]: + context.diagnostics['evaluated_extensions'] += 1 + placed = generator.solo.placement.place(candidate.notes, tempo, music.last) + if placed is None: + continue + comfort, notes = placed + canonical = generator.solo.types.sounding_notes((*music.notes, *notes)) + completed = cell.end == (plan.passage_end or plan.end) + harmonic = harmony.evaluate(canonical, candidate.treatments, until=cell.end, complete=completed) + valid, error = generator.solo.harmonic.anchors_valid(anchors, canonical, cell.end, complete=completed, closure=plan.closure) + if not harmonic.valid or not valid: + last_error = harmonic.error or error + continue + if plan.target and (plan.target.latest < cell.end or cell.end == plan.end) and not target_ok(canonical, plan.target): + last_error = 'legacy destination was not reached' + continue + motifs, fidelity = music.motifs, state.fidelity + if cell.gesture and cell.end == cell.gesture.end: + valid, mismatch, identity = generator.solo.motifs.complete(cell.gesture, reference, canonical, harmony) + if not valid: + last_error = f'{cell.gesture.id}: insufficient evidence of {cell.gesture.treatment} from {reference.id}' continue - comfort, notes = placed - decision = generator.solo.types.Decision(cell.start, cell.end, candidate.source, candidate.treatment, notes, candidate.fidelity) - combined = (*decisions, decision) - canonical = generator.solo.types.sounding_notes((*sounded, *notes)) - if plan.target and (plan.target.latest < cell.end or cell.end == plan.end) and not target_ok(canonical, plan.target): - continue - musical_key = (plan, cell.end, tuple((n.start, n.end, n.pitch, n.role) for n in canonical)) - if musical_key not in context.evaluations: - context.evaluations[musical_key] = evaluation.features(canonical) if evaluation.feasible(canonical) else None - if len(context.evaluations) > 32768: - context.evaluations.popitem(last=False) - else: - context.evaluations.move_to_end(musical_key) - evaluated = context.evaluations[musical_key] - if evaluated is None: - continue - features = dict(evaluated) - features['connection'] = min(1, abs(canonical[0].pitch.number() - previous.pitch.number()) / 12) if canonical and previous else 0 - features['movement'] = min(1, (travel + comfort) / max(1, len(canonical)) / 2000) - total = generator.solo.scoring.value(features) - following.append((total, combined, canonical[-1] if canonical else last, travel + comfort, canonical)) + fidelity += (mismatch,) + if identity: + motifs += (identity,) + if not evaluation.feasible(canonical): + last_error = 'planned activity, sustain or space not fulfilled' + continue + features = evaluation.features(canonical) + features['fidelity'] = (sum(fidelity) + candidate.fidelity) / (len(fidelity) + 1) + features['harmonic_exposure'] = harmonic.exposure + features['connection'] = min(1, abs(canonical[0].pitch.number() - incoming.last.pitch.number()) / 12) if canonical and incoming.last and not continuing else 0 + features['movement'] = min(1, (state.travel + comfort) / max(1, len(canonical)) / 2000) + if cell.gesture: + recent = tuple(n for n in canonical if cell.gesture.start <= n.start < cell.gesture.end) + intended_repeats = sum(a.pitch == b.pitch == c.pitch and a.start == x.start - cell.gesture.start and b.start == y.start - cell.gesture.start and c.start == z.start - cell.gesture.start for a, b, c in zip(reference.notes, reference.notes[1:], reference.notes[2:]) for x, y, z in zip(recent, recent[1:], recent[2:]) if x.pitch == y.pitch == z.pitch) + features['repetition'] = max(0, features['repetition'] - intended_repeats / max(1, len(canonical) - 2)) + total = generator.solo.scoring.value(features) + last = canonical[-1] if canonical else music.last + progress = tuple(a.id for a in anchors if generator.solo.harmonic.anchor_note(a, canonical)) + direction = (last.pitch.number() > music.last.pitch.number()) - (last.pitch.number() < music.last.pitch.number()) if last and music.last else music.direction + carried = generator.solo.types.ContinuationState(plan.passage_id, canonical, motifs, candidate.treatments, progress, direction, last, music.backbone, fidelity, state.travel + comfort) + decision = generator.solo.types.Decision(cell.start, cell.end, candidate.source, candidate.treatment, notes, candidate.fidelity, cell.gesture.id if cell.gesture else '', reference.id) + following.append(BeamState(total, (*state.decisions, decision), carried, state.travel + comfort, fidelity)) if not following: - raise SearchExhausted(f'{plan.id}, beats {cell.start + 1}-{cell.end + 1}: bounded solo search exhausted; no feasible phrase extension among {BEAM_WIDTH} states / {CELL_LIMIT} extensions (not proof that the passage is unplayable)') + raise SearchExhausted(f'{plan.id}, beats {cell.start + 1}-{cell.end + 1}: bounded solo search exhausted; {last_error or "no eligible continuation"}; {BEAM_WIDTH} states / {CELL_LIMIT} extensions (not proof that the passage is unplayable)') def rank(state): - return state[0], tuple((n.pitch, n.position, n.start, n.end) for n in state[4]) + return state.score, tuple((n.pitch, n.position, n.start, n.end) for n in state.music.notes) unique = {} for state in sorted(following, key=rank): - unique.setdefault(state_identity(state, plan), state) - states = list(generator.solo.sequences.diverse(unique.values(), rank, lambda s: (note_family(s[4]), len(s[4]), s[4][-1].pitch.number() if s[4] else None), BEAM_WIDTH)) - states.sort(key=rank) + unique.setdefault(state.music, state) + # A rolling one-gesture window keeps the longer plan bounded. Relevant + # motif/anchor/treatment state survives; a matching last fret is not identity. + limit = generator.solo.policy.DEFAULT.alternatives if cell.gesture and cell.end == cell.gesture.end else BEAM_WIDTH + states = list(generator.solo.sequences.diverse(unique.values(), rank, lambda s: (s.music.backbone, note_family(s.music.notes[-4:]), s.music.last.pitch if s.music.last else None), limit)) context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states)) - selected = generator.solo.sequences.diverse(states, lambda s: (s[0], tuple(n.pitch for n in s[4])), lambda s: (note_family(s[4]), len(s[4]), s[4][-1].pitch if s[4] else None), generator.solo.policy.DEFAULT.alternatives) + selected = generator.solo.sequences.diverse(states, lambda s: (s.score, tuple(n.pitch for n in s.music.notes)), lambda s: (s.music.backbone, note_family(s.music.notes[-4:])), generator.solo.policy.DEFAULT.alternatives) for state in selected: - family = note_family(state[4]) + family = note_family(state.music.notes[-4:]) counts = context.diagnostics['surviving_families'] counts[family] = counts.get(family, 0) + 1 - result = tuple(generator.solo.types.Phrase(plan, decisions, score) for score, decisions, _, _, _ in selected) + result = tuple(generator.solo.types.Phrase(plan, state.decisions, state.score, state.music, backbones[state.music.backbone]) for state in selected) context.results[key] = result return result diff --git a/generator/solo/report.py b/generator/solo/report.py index a1875b6..c8cc769 100644 --- a/generator/solo/report.py +++ b/generator/solo/report.py @@ -2,6 +2,7 @@ from __future__ import annotations import dataclasses import json +import fractions import generator.harmony import generator.model @@ -9,6 +10,7 @@ import generator.scales.report import generator.scales.selection import generator.scales.timeline import generator.solo.types +import generator.solo.harmonic 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') @@ -16,16 +18,29 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off lines = [generator.scales.report.passage(solo.part, region, positioned, scales), f'Phrase composition: {solo.part}', f'Placement reference tempo: {solo.tempo} quarter notes/minute. Runtime tempo changes are not a comfort guarantee.', 'Times below are quarter-note offsets from the start of this solo; pitches are sounding.'] if solo.diagnostics: lines.append('Composition diagnostics: ' + json.dumps(solo.diagnostics, sort_keys=True)) + detail = structure(solo) + for passage in detail['passages']: + lines.append(f"Melodic passage {passage['id']}: {passage['start']}-{passage['end']}; phrases {', '.join(passage['phrases'])}") + lines.append(f"Harmonic exposure: {detail['harmonic_exposure']:.4f}; unresolved treatments: {len(detail['unresolved_treatments'])}") for phrase in solo.phrases: plan = phrase.plan lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources))) lines.append(f' Contract: {plan.treatment}; closure: {plan.closure}; ending: {plan.ending}; score: {phrase.score:.4f}; identity: {plan.seed}') for passage in plan.passages: - lines.append(f' Passage {passage.start}-{passage.end}: {passage.intent}; attacks/beat {float(passage.activity):.3f}; occupancy {float(passage.occupancy):.2f}') + lines.append(f' Activity {passage.start}-{passage.end}: {passage.intent}; attacks/beat {float(passage.activity):.3f}; occupancy {float(passage.occupancy):.2f}') for landmark in plan.landmarks: lines.append(f' Landmark {landmark.name}: {landmark.earliest}-{landmark.latest}; sounding MIDI {landmark.low}-{landmark.high}; direction {landmark.direction}') if plan.target: lines.append(f' Destination: {plan.target.chord}; {plan.target.reason}; arrival {plan.target.earliest}-{plan.target.latest}') + for anchor in phrase.anchors: + actual = generator.solo.harmonic.anchor_note(anchor, phrase.state.notes if phrase.state else solo.notes()) + lines.append(f' Anchor {anchor.id}: {anchor.role}; {anchor.chord}; {anchor.pitch.label()}; window {anchor.earliest}-{anchor.latest}; hold {anchor.duration}; hold until {anchor.hold_until}; completed {actual is not None}') + if phrase.state: + valid, reason = generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=phrase.plan.end == phrase.plan.passage_end, closure=phrase.plan.closure) + lines.append(f' Ending contract fulfilled: {valid}; {reason}') + for motif in phrase.state.motifs: + if phrase.plan.start <= motif.source_start < phrase.plan.end: + lines.append(f' Realized motif {motif.id}: parent {motif.parent}; {motif.relationship}; {motif.treatment}; pitches ' + ' '.join(n.pitch.label() for n in motif.notes) + f'; rhythm ' + ' '.join(f'{n.start}:{n.end}' for n in motif.notes)) for decision in phrase.decisions: lines.append(f' {decision.start}-{decision.end}: {decision.source}; {decision.treatment}') if not decision.notes: @@ -36,4 +51,35 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off degrees = [str(d) for d, a in generator.harmony.degrees(choice.span.chord).items() if (root + generator.harmony.degree_semitones(d, a)) % 12 == note.pitch.number() % 12] function = 'chord degree ' + '/'.join(degrees) if degrees else 'in-collection connection' lines.append(f' {note.start}-{note.end}: {note.pitch.label()}; {note.role}; {generator.harmony.symbol(choice.span.chord)}, {function}; {choice.scale.label()}; string {note.position.string_number}, fret {note.position.fret}') + lines.append('\nSounding harmonic spans (ties merged before classification):') + for event in detail['harmonic_roles']: + if float(fractions.Fraction(event['sounding_duration'])) >= 1 or event['protected']: + lines.append(' ' + json.dumps(event, sort_keys=True)) return '\n'.join(lines) + '\n' + +def structure(solo): + harmony = generator.solo.harmonic.Harmony(solo.choices) + contracts = tuple(dict.fromkeys(t for p in solo.phrases if p.state for t in p.state.treatments)) + checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True) + + def rational(value): + if isinstance(value, dict): + return {key: rational(item) for key, item in value.items()} + if isinstance(value, (tuple, list, set, frozenset)): + return [rational(item) for item in value] + if isinstance(value, fractions.Fraction): + return str(value) + return value + + phrases = [] + for phrase in solo.phrases: + notes = phrase.state.notes if phrase.state else solo.notes() + complete = phrase.plan.end == (phrase.plan.passage_end or phrase.plan.end) + valid, reason = generator.solo.harmonic.anchors_valid(phrase.anchors, notes, phrase.plan.end, complete=complete, closure=phrase.plan.closure) + anchors = [] + for anchor in phrase.anchors: + event = generator.solo.harmonic.anchor_note(anchor, notes) + anchors.append({**rational(dataclasses.asdict(anchor)), 'pitch': anchor.pitch.label(), 'completed': event is not None, 'actual_start': str(event.start) if event else None, 'actual_end': str(event.end) if event else None}) + motifs = tuple(m for m in phrase.state.motifs if phrase.plan.start <= m.source_start < phrase.plan.end) if phrase.state else () + phrases.append({'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])}) + 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])} diff --git a/generator/solo/scoring.py b/generator/solo/scoring.py index 7c99ba6..26af682 100644 --- a/generator/solo/scoring.py +++ b/generator/solo/scoring.py @@ -10,8 +10,8 @@ def passages(plan): if plan.passages: return plan.passages end = plan.end - plan.release - active = (generator.solo.types.Passage(plan.start, end, 'active', plan.activity, fractions.Fraction(3, 4)),) if end > plan.start else () - return active + ((generator.solo.types.Passage(end, plan.end, 'space', fractions.Fraction(), fractions.Fraction()),) if plan.release else ()) + active = (generator.solo.types.ActivitySpan(plan.start, end, 'active', plan.activity, fractions.Fraction(3, 4)),) if end > plan.start else () + return active + ((generator.solo.types.ActivitySpan(end, plan.end, 'space', fractions.Fraction(), fractions.Fraction()),) if plan.release else ()) def intervals(choices, start, end): return tuple((max(start, c.span.start), min(end, c.span.end)) for c in choices if c.scale and c.span.start < end and c.span.end > start) @@ -23,12 +23,14 @@ def corridor(pitch, low, high): return max(low - pitch, 0, pitch - high) def motif_fidelity(notes, motif, treatment): - if treatment in ('quote', 'exact_quote', 'rhythmic_reference'): + if treatment in ('quote', 'exact_quote'): return 0.0 + if treatment == 'rhythmic_reference': + return 1.0 if len(notes) < min(2, len(motif.notes)) else 0.0 actual = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:])) expected = motif.contour() if not actual or not expected: - return 0.0 if len(notes) >= min(1, len(motif.notes)) else 1.0 + return 0.0 if len(notes) == len(motif.notes) == 1 else 1.0 count = min(2, len(actual), len(expected)) if treatment == 'opening_reference' else max(1, len(actual) // 2) if treatment == 'answer' else len(actual) errors = [] for index in range(count): @@ -36,7 +38,8 @@ def motif_fidelity(notes, motif, treatment): motion = actual[index] direction = ((reference > 0) - (reference < 0)) != ((motion > 0) - (motion < 0)) errors.append(0.75 * direction + 0.25 * min(1, abs(motion - reference) / 12)) - return sum(errors) / len(errors) + evidence = min(2, len(expected)) + return max(sum(errors) / len(errors), max(0, evidence - len(actual)) / evidence) class Evaluation: """Prepare fixed harmonic windows once; only soft penalties use float time.""" diff --git a/generator/solo/types.py b/generator/solo/types.py index ddd1446..3798cb2 100644 --- a/generator/solo/types.py +++ b/generator/solo/types.py @@ -65,13 +65,80 @@ class TargetEvent: reason: str @dataclasses.dataclass(frozen=True) -class Passage: +class ActivitySpan: start: fractions.Fraction end: fractions.Fraction intent: str activity: fractions.Fraction occupancy: fractions.Fraction +@dataclasses.dataclass(frozen=True) +class HarmonicTreatment: + id: str + kind: str + pitch: int + start: fractions.Fraction + end: fractions.Fraction + resolution: int | None = None + deadline: fractions.Fraction | None = None + preparation: fractions.Fraction = fractions.Fraction(1, 2) + +@dataclasses.dataclass(frozen=True) +class Anchor: + id: str + earliest: fractions.Fraction + latest: fractions.Fraction + pitch: Pitch + eligible: frozenset[int] + register: tuple[int, int] + duration: fractions.Fraction + role: str + chord: str + onset_required: bool = False + hold_until: fractions.Fraction | None = None + +@dataclasses.dataclass(frozen=True) +class Gesture: + id: str + start: fractions.Fraction + end: fractions.Fraction + relationship: str + treatment: str + +@dataclasses.dataclass(frozen=True) +class RealizedMotif: + id: str + parent: str + notes: tuple[Note, ...] + duration: fractions.Fraction + relationship: str + treatment: str + chord_degrees: tuple[tuple[int, ...], ...] = () + source_start: fractions.Fraction = fractions.Fraction() + def contour(self): + return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:])) + +@dataclasses.dataclass(frozen=True) +class ContinuationState: + passage_id: str + notes: tuple[Note, ...] = () + motifs: tuple[RealizedMotif, ...] = () + treatments: tuple[HarmonicTreatment, ...] = () + anchors: tuple[str, ...] = () + direction: int = 0 + last: Note | None = None + backbone: int = 0 + fidelity: tuple[float, ...] = () + travel: int = 0 + +@dataclasses.dataclass(frozen=True) +class MelodicPassagePlan: + id: str + start: fractions.Fraction + end: fractions.Fraction + phrase_ids: tuple[str, ...] + seed: str + @dataclasses.dataclass(frozen=True) class Landmark: earliest: fractions.Fraction @@ -93,11 +160,17 @@ class PhrasePlan: register: tuple[int, int] activity: fractions.Fraction release: fractions.Fraction = fractions.Fraction(1, 2) - passages: tuple[Passage, ...] = () + passages: tuple[ActivitySpan, ...] = () landmarks: tuple[Landmark, ...] = () ending: str = 'breath' closure: str = 'closed' seed: str = '' + passage_id: str = '' + passage_start: fractions.Fraction | None = None + passage_end: fractions.Fraction | None = None + anchor_options: tuple[tuple[Anchor, ...], ...] = () + gestures: tuple[Gesture, ...] = () + treatments: tuple[HarmonicTreatment, ...] = () @dataclasses.dataclass(frozen=True) class Decision: @@ -107,12 +180,16 @@ class Decision: treatment: str notes: tuple[Note, ...] fidelity: float = 0 + gesture_id: str = '' + parent_motif: str = '' @dataclasses.dataclass(frozen=True) class Phrase: plan: PhrasePlan decisions: tuple[Decision, ...] score: float = 0 + state: ContinuationState | None = None + anchors: tuple[Anchor, ...] = () @dataclasses.dataclass(frozen=True) class Solo: @@ -122,6 +199,7 @@ class Solo: tempo: fractions.Fraction end: fractions.Fraction diagnostics: dict = dataclasses.field(default_factory=dict, compare=False) + passages: tuple[MelodicPassagePlan, ...] = () def notes(self) -> tuple[Note, ...]: return sounding_notes(n for p in self.phrases for d in p.decisions for n in d.notes) @@ -136,6 +214,15 @@ def scale_pitch(scale: generator.scales.library.Scale, index: int, octave: int = alter = generator.model.alteration(name['alter']) return Pitch(name['letter'], alter, int((number - generator.model.NATURAL[name['letter']] - alter) // 12 - 1)) +@functools.lru_cache(maxsize=4096) +def scale_index(scale: generator.scales.library.Scale, number: int, octave: int = 4) -> int: + """Exact inverse by sounding pitch; spelling never moves a held note.""" + root = scale_pitch(scale, 0, octave).number() + turns, chromatic = divmod(number - root, 12) + if chromatic not in scale.pattern.intervals: + raise ValueError(f'{number} is outside {scale.label()}') + return 7 * turns + scale.pattern.intervals.index(chromatic) + def permitted(choices: tuple[generator.scales.selection.Choice, ...], start: fractions.Fraction, end: fractions.Fraction) -> frozenset[int]: covered = start pitches = frozenset(range(12)) diff --git a/tests/test_solo.py b/tests/test_solo.py index d8befc1..afbf74b 100644 --- a/tests/test_solo.py +++ b/tests/test_solo.py @@ -119,7 +119,7 @@ class SoloTests(unittest.TestCase): self.assertEqual(result.phrases[0].plan.motif.id, result.phrases[1].plan.motif.id) 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) + explicit = dataclasses.replace(original, treatment='exact_quote', target=None, anchor_options=(), gestures=(), passage_id='', passage_start=None, passage_end=None) first = generator.solo.realization.compose(explicit, source['staves'][0]['measures'], result.choices, tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), result.tempo) quote = next(d for d in first.decisions if d.treatment.startswith('quote')) self.assertEqual(len(quote.notes), len(first.plan.motif.notes)) diff --git a/tests/test_solo_development.py b/tests/test_solo_development.py index 93f6f11..113091d 100644 --- a/tests/test_solo_development.py +++ b/tests/test_solo_development.py @@ -29,7 +29,7 @@ def choices(root='C', fifths=0, end=8): def plan(intent='active'): motif = generator.solo.planning.new_motif(tests.solo_fixtures.library(), fractions.Fraction(4)) - passage = generator.solo.types.Passage(fractions.Fraction(), fractions.Fraction(8), intent, fractions.Fraction(1) if intent == 'active' else fractions.Fraction(1, 8) if intent == 'sustained_arrival' else fractions.Fraction(), fractions.Fraction(3, 4) if intent == 'active' else fractions.Fraction(1) if intent == 'sustained_arrival' else fractions.Fraction()) + passage = generator.solo.types.ActivitySpan(fractions.Fraction(), fractions.Fraction(8), intent, fractions.Fraction(1) if intent == 'active' else fractions.Fraction(1, 8) if intent == 'sustained_arrival' else fractions.Fraction(), fractions.Fraction(3, 4) if intent == 'active' else fractions.Fraction(1) if intent == 'sustained_arrival' else fractions.Fraction()) return generator.solo.types.PhrasePlan('fixture', passage.start, passage.end, motif, 'statement', 'thematic', None, (59, 75), passage.activity, fractions.Fraction(), (passage,)) def note(start, end, letter='g', alter=0, octave=4, **kwargs): @@ -76,7 +76,7 @@ class ScoringTests(unittest.TestCase): class DevelopmentTests(unittest.TestCase): def test_connected_motion_can_cross_a_bar_and_arrive_on_a_sustain(self): source = tests.test_solo.song(count=4, chords=('CM7',)) - passage = dataclasses.replace(plan(), end=fractions.Fraction(16), treatment='contour_reference', passages=(generator.solo.types.Passage(0, 8, 'active', fractions.Fraction(2), fractions.Fraction(1)), generator.solo.types.Passage(8, 16, 'sustained_arrival', fractions.Fraction(1, 8), fractions.Fraction(1))), target=generator.solo.types.TargetEvent(fractions.Fraction(8), fractions.Fraction(8), frozenset({4}), 'CM7', 'fixture arrival')) + passage = dataclasses.replace(plan(), end=fractions.Fraction(16), treatment='contour_reference', passages=(generator.solo.types.ActivitySpan(0, 8, 'active', fractions.Fraction(2), fractions.Fraction(1)), generator.solo.types.ActivitySpan(8, 16, 'sustained_arrival', fractions.Fraction(1, 8), fractions.Fraction(1))), target=generator.solo.types.TargetEvent(fractions.Fraction(8), fractions.Fraction(8), frozenset({4}), 'CM7', 'fixture arrival')) phrase = generator.solo.realization.compose(passage, source['staves'][0]['measures'], choices(end=16), tests.solo_fixtures.library(), generator.rhythm.load_library(pathlib.Path('data/rhythms')), fractions.Fraction(120)) notes = generator.solo.composer.phrase_notes(phrase) self.assertGreaterEqual(sum(n.start < 8 for n in notes), 12) @@ -148,7 +148,7 @@ class DevelopmentTests(unittest.TestCase): passage = plan() left = (note(0, 2, 'c'), note(2, 4, 'e'), note(4, 8, 'g')) right = (note(0, 1, 'a'), note(1, 4, 'c'), note(4, 8, 'g')) - self.assertNotEqual(generator.solo.realization.state_identity((0, (), left[-1], 0, left), passage), generator.solo.realization.state_identity((0, (), right[-1], 0, right), passage)) + self.assertNotEqual(generator.solo.types.ContinuationState(passage.id, left, last=left[-1]), generator.solo.types.ContinuationState(passage.id, right, last=right[-1])) def test_lookahead_can_choose_a_lower_ranked_statement(self): first = dataclasses.replace(plan(), end=fractions.Fraction(4), ending='connected') second = dataclasses.replace(plan(), id='answer', start=fractions.Fraction(4), relationship='answer') diff --git a/tests/test_solo_passages.py b/tests/test_solo_passages.py new file mode 100644 index 0000000..5ee1aab --- /dev/null +++ b/tests/test_solo_passages.py @@ -0,0 +1,197 @@ +import dataclasses +import fractions +import unittest + +import generator.harmony +import generator.model +import generator.scales.selection +import generator.scales.timeline +import generator.solo.harmonic +import generator.solo.motifs +import generator.solo.scoring +import generator.solo.types +import tests.scale_fixtures +import tests.test_solo_development + +F = fractions.Fraction +note = tests.test_solo_development.note + +def choices(symbols, duration=4, scale=None): + scale = scale or tests.scale_fixtures.library().scale('major', 6, 'D') + return tuple(generator.scales.selection.Choice(generator.scales.timeline.ChordSpan(F(i * duration), F((i + 1) * duration), i + 1, str(i + 1), F(1), generator.model.key_from_fifths(-1), generator.harmony.parse_symbol(symbol)), scale, 'fixture') for i, symbol in enumerate(symbols)) + +def anchor(role='terminal', pitch='e', earliest=2, latest=2, duration=2, onset=False): + return generator.solo.types.Anchor('arrival', F(earliest), F(latest), note(0, 1, pitch).pitch, frozenset({4}), (55, 79), F(duration), role, 'C7', onset, F(4) if role == 'terminal' else None) + +class HarmonicPassageTests(unittest.TestCase): + def test_same_collection_distinguishes_chord_function(self): + harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7', 'FM7', 'BbM7'))) + self.assertEqual([s.roles.basic for s in harmony.spans], [frozenset(v) for v in ({7, 10, 2, 5}, {0, 4, 7, 10}, {5, 9, 0, 4}, {10, 2, 5, 9})]) + def test_semantic_degrees_keep_alterations_omissions_suspensions_and_slash_bass(self): + for symbol in ('C7b5', 'C9', 'Csus4', 'C7no5', 'C/E', 'C/D'): + chord = generator.harmony.parse_symbol(symbol) + roles = generator.solo.harmonic.roles(chord) + self.assertFalse(roles.basic & roles.specified) + self.assertNotIn(2, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C/D')).basic) + self.assertIn(2, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C9')).specified) + self.assertIn(5, generator.solo.harmonic.roles(generator.harmony.parse_symbol('Csus4')).basic) + self.assertNotIn(7, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C7no5')).basic) + def test_ordinary_long_destination_requires_chord_member(self): + harmony = generator.solo.harmonic.Harmony(choices(('C7',))) + self.assertTrue(harmony.evaluate((note(0, 2, 'e'),)).valid) + self.assertFalse(harmony.evaluate((note(0, 2, 'f'),)).valid) + self.assertLess(harmony.evaluate((note(0, 1, 'e'),)).exposure, harmony.evaluate((note(0, 1, 'f'),)).exposure) + def test_connected_passing_motion_is_permitted(self): + harmony = generator.solo.harmonic.Harmony(choices(('C7',))) + notes = (note(0, F(1, 2), 'g'), note(F(1, 2), 1, 'f'), note(1, 2, 'e')) + self.assertEqual(harmony.connected_motion(notes, 1), 'passing') + self.assertTrue(harmony.evaluate(notes).valid) + self.assertEqual(harmony.evaluate(notes).exposure, 0) + def test_common_tone_hold_needs_no_reattack(self): + harmony = generator.solo.harmonic.Harmony(choices(('Em7b5', 'A7'), scale=tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D'))) + self.assertTrue(harmony.evaluate((note(2, 6, 'g'),)).valid) + def test_held_tension_detected_without_scale_change(self): + harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7'))) + notes = (note(2, 6, 'f'),) + self.assertFalse(harmony.evaluate(notes).valid) + self.assertEqual([v['function'] for v in harmony.describe(notes)], ['basic', 'mapped_motion']) + def test_prepared_suspension_requires_actual_resolution(self): + harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7'))) + treatment = generator.solo.types.HarmonicTreatment('suspension', 'suspension', 65, F(4), F(5), 64, F(5)) + notes = (note(2, F(9, 2), 'f'), note(F(9, 2), 6, 'e')) + self.assertTrue(harmony.evaluate(notes, (treatment,), complete=True).valid) + partial = (note(2, F(9, 2), 'f'),) + result = harmony.evaluate(partial, (treatment,), until=F(9, 2)) + self.assertTrue(result.valid) + self.assertEqual(result.pending, (treatment,)) + self.assertFalse(harmony.evaluate(partial, (treatment,), until=6, complete=True).valid) + self.assertFalse(harmony.evaluate((note(4, F(9, 2), 'f'), note(F(9, 2), 6, 'e')), (treatment,), complete=True).valid) + self.assertFalse(harmony.evaluate((note(2, F(9, 2), 'f'), note(F(9, 2), 6, 'g')), (treatment,), complete=True).valid) + def test_symbol_specified_color_requires_a_plan(self): + harmony = generator.solo.harmonic.Harmony(choices(('C9',))) + notes = (note(0, 2, 'd'),) + color = generator.solo.types.HarmonicTreatment('ninth', 'color', 62, F(0), F(2)) + self.assertFalse(harmony.evaluate(notes).valid) + self.assertTrue(harmony.evaluate(notes, (color,)).valid) + ordinary = generator.solo.harmonic.Harmony(choices(('C7',))) + self.assertFalse(ordinary.evaluate(notes, (color,)).valid) + def test_quotation_tension_preserved_and_reported(self): + harmony = generator.solo.harmonic.Harmony(choices(('C7',))) + notes = (note(0, 2, 'f', role='quote'),) + self.assertTrue(harmony.evaluate(notes).valid) + report = harmony.describe(notes) + self.assertTrue(report[0]['protected']) + self.assertEqual(report[0]['function'], 'mapped_motion') + def test_partition_invariance_for_roles_duration_activity_and_score(self): + harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7'))) + whole = (note(2, 6, 'f'),) + split = (note(2, 3, 'f'), note(3, 4, 'f', continuation=True), note(4, 6, 'f', continuation=True)) + self.assertEqual(harmony.evaluate(whole), harmony.evaluate(split)) + self.assertEqual(harmony.describe(whole), harmony.describe(split)) + plan = tests.test_solo_development.plan() + self.assertEqual(generator.solo.scoring.features(plan, whole, harmony.choices), generator.solo.scoring.features(plan, split, harmony.choices)) + def test_intermediate_arrival_does_not_require_closure(self): + notes = (note(0, 1, 'g'), note(1, 2, 'e'), note(2, 3, 'f'), note(3, 4, 'g')) + middle = anchor('intermediate', earliest=1, latest=1, duration=1) + self.assertTrue(generator.solo.harmonic.anchors_valid((middle,), notes, F(4), complete=True)[0]) + def test_terminal_touch_then_departure_is_not_closure(self): + terminal = dataclasses.replace(anchor(), duration=F(1, 2), hold_until=None) + notes = (note(2, F(5, 2), 'e'), note(F(5, 2), 4, 'g')) + self.assertFalse(generator.solo.harmonic.anchors_valid((terminal,), notes, F(4), complete=True, closure='closed')[0]) + self.assertTrue(generator.solo.harmonic.anchors_valid((anchor(),), (note(2, 4, 'e'),), F(4), complete=True, closure='closed')[0]) + def test_existing_hold_can_satisfy_anchor_only_when_allowed(self): + notes = (note(0, 4, 'e'),) + self.assertIsNotNone(generator.solo.harmonic.anchor_note(anchor(), notes)) + self.assertIsNone(generator.solo.harmonic.anchor_note(anchor(onset=True), notes)) + def test_single_unrelated_note_is_not_perfect_motif_evidence(self): + motif = generator.solo.types.Motif('idea', (note(0, 1, 'c'), note(1, 2, 'd'), note(2, 3, 'g')), F(3), ()) + for treatment in ('contour_reference', 'rhythmic_reference', 'opening_reference', 'answer'): + self.assertGreater(generator.solo.scoring.motif_fidelity((note(0, 1, 'e'),), motif, treatment), 0) + def test_scale_index_round_trips_negative_and_octave_crossings(self): + for scale in (tests.scale_fixtures.library().scale('major', 6, 'D'), tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')): + for index in range(-21, 28): + self.assertEqual(generator.solo.types.scale_index(scale, generator.solo.types.scale_pitch(scale, index).number()), index) + scale = tests.scale_fixtures.library().scale('major', 6, 'D') + 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): + 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]) + def test_development_references_realization_instead_of_source_template(self): + plan = tests.test_solo_development.plan() + harmony = generator.solo.harmonic.Harmony(choices(('C7',))) + gesture = generator.solo.types.Gesture('heard', F(0), F(4), 'statement', 'opening_reference') + notes = (note(0, 1, 'e'), note(1, 2, 'f'), note(2, 4, 'g')) + valid, _, realized = generator.solo.motifs.complete(gesture, plan.motif, notes, harmony) + self.assertTrue(valid) + following = dataclasses.replace(gesture, id='answer', start=F(4), end=F(8), relationship='answer') + self.assertEqual(generator.solo.motifs.reference(plan, following, (realized,)), realized) + self.assertEqual(realized.contour(), (1, 2)) + self.assertNotEqual(realized.contour(), plan.motif.contour()) + def test_concrete_pool_includes_continuation_from_actual_incoming_register(self): + import generator.solo.realization + import tests.solo_fixtures + plan = tests.test_solo_development.plan() + incoming = note(0, 4, 'c', octave=5) + state = generator.solo.types.ContinuationState('passage', (incoming,), last=incoming) + cell = generator.solo.realization.Cell(F(4), F(6), (('moving', ((F(0), F(1, 2)), (F(1, 2), F(1)), (F(1), F(3, 2)), (F(3, 2), F(2)))),)) + pool = generator.solo.realization.candidates(cell, plan, choices(('C7', 'C7')), tests.solo_fixtures.library(), state) + self.assertTrue(pool) + self.assertTrue(any(c.notes[0].pitch.number() == incoming.pitch.number() for c in pool)) + self.assertTrue(any(c.notes[0].continuation for c in pool)) + def test_pending_suspension_guides_next_cells_candidate_pool(self): + import generator.solo.realization + import tests.solo_fixtures + harmony = choices(('Gm7', 'C7')) + held = note(2, F(9, 2), 'f') + contract = generator.solo.types.HarmonicTreatment('pending', 'suspension', 65, F(4), F(9, 2), 64, F(5)) + state = generator.solo.types.ContinuationState('passage', (held,), treatments=(contract,), last=held) + cell = generator.solo.realization.Cell(F(9, 2), F(6), (('answer', ((F(0), F(1, 2)), (F(1, 2), F(1)), (F(1), F(3, 2)))),)) + pool = generator.solo.realization.candidates(cell, tests.test_solo_development.plan(), harmony, tests.solo_fixtures.library(), state) + self.assertTrue(pool) + self.assertTrue(all(c.notes[0].pitch.number() == 64 for c in pool)) + for candidate in pool: + checked = generator.solo.harmonic.Harmony(harmony).evaluate((*state.notes, *candidate.notes), candidate.treatments, until=6, complete=True) + self.assertTrue(checked.valid) + self.assertFalse(checked.pending) + def test_incompatible_anchor_cannot_retime_a_protected_quote(self): + import generator.solo.realization + import tests.test_solo + import tests.solo_fixtures + import tests.rhythm_fixtures + plan = tests.test_solo_development.plan() + demanded = generator.solo.types.Anchor('incompatible', F(1, 4), F(1, 4), plan.motif.notes[1].pitch, frozenset({plan.motif.notes[1].pitch.number() % 12}), plan.register, F(1, 2), 'opening', 'CM7', True) + plan = dataclasses.replace(plan, treatment='exact_quote', anchor_options=((demanded,),)) + source = tests.test_solo.song(count=2, chords=('CM7',)) + with self.assertRaises(generator.solo.realization.SearchExhausted): + generator.solo.realization.compose(plan, source['staves'][0]['measures'], tests.test_solo_development.choices(), tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), F(120)) + def test_phrase_lookahead_does_not_double_count_shared_passage_prefix(self): + import generator.solo.composer + first = dataclasses.replace(tests.test_solo_development.plan(), passage_id='shared', start=F(0), end=F(4)) + second = dataclasses.replace(first, id='second', start=F(4), end=F(8)) + left = generator.solo.types.Phrase(first, (generator.solo.types.Decision(F(0), F(4), 'fixture', 'invented', (note(0, 4, 'c'),)),), 1.0) + right = generator.solo.types.Phrase(first, (generator.solo.types.Decision(F(0), F(4), 'fixture', 'invented', (note(0, 4, 'g'),)),), 0.5) + def realize(plan, previous): + if plan.id == first.id: + return (left, right) + score = 0.6 if previous.pitch.letter == 'c' else 0.7 + continuation = dataclasses.replace(previous, start=F(4), end=F(8)) + return tuple([generator.solo.types.Phrase(second, (generator.solo.types.Decision(F(4), F(8), 'fixture', 'answer', (continuation,)),), score)]) + chosen = generator.solo.composer.choose_phrases((first, second), realize) + self.assertEqual(chosen[0], left)