Soloing
ci / checks (push) Successful in 50m39s

This commit is contained in:
khodowany
2026-10-01 06:31:14 -05:00
parent ce695a626a
commit eae432aa83
29 changed files with 1402 additions and 158 deletions
+4 -1
View File
@@ -21,6 +21,7 @@ import generator.rhythm
import generator.scales.library
import generator.score
import generator.summary
import generator.solo.sequences
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
@@ -72,13 +73,15 @@ def generate_assets(args: argparse.Namespace) -> None:
rhythms = generator.rhythm.load_library(data_dir / 'rhythms')
report('loading solo scale patterns')
scales = generator.scales.library.load_library(data_dir / 'scales')
report('loading solo interval sequences')
sequences = generator.solo.sequences.load_library(data_dir / 'sequences')
output_dir.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.vamp_assets_', dir=output_dir.parent) as temporary:
staged = pathlib.Path(temporary) / 'assets'
staged.mkdir(parents=True, exist_ok=True)
copy_runtime_fonts(static_assets_dir, staged)
report('generating performance parts')
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales)
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences)
generator.score.validate_generated_tree(staged)
if args.check:
generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
+2 -2
View File
@@ -121,7 +121,7 @@ def cycle(snippets: tuple[generator.rhythm.Snippet, ...], contexts: list[Context
raise ValueError(f'measure {contexts[0].measure["number"]}: rhythm library cannot provide an audible electric response')
return result
def plan_section(measures: list[dict], library: generator.rhythm.Library, *, initial: dict | None = None, electric: bool = True) -> Plan:
def plan_section(measures: list[dict], library: generator.rhythm.Library, *, initial: dict | None = None, electric: bool = True, acoustic_companions: bool = True) -> Plan:
previous = carried_chords(measures, initial)
basses, uppers, responses = [], [], []
start = 0
@@ -139,7 +139,7 @@ def plan_section(measures: list[dict], library: generator.rhythm.Library, *, ini
bass = cycle(snippets, contexts, 'bass')
upper = cycle(snippets, contexts, 'upper')
companions = [tuple(sorted(set(sounding_attacks(bass[i % 4], item)) | set(sounding_attacks(upper[i % 4], item)))) for i, item in enumerate(contexts)]
response = cycle(snippets, contexts, 'electric', companions) if electric else upper
response = cycle(snippets, contexts, 'electric', companions if acoustic_companions else None) if electric else upper
basses.extend(bass[i % 4] for i in range(len(segment)))
uppers.extend(upper[i % 4] for i in range(len(segment)))
responses.extend(response[i % 4] for i in range(len(segment)))
+15 -7
View File
@@ -20,10 +20,13 @@ import generator.scales.library
import generator.scales.mapping
import generator.scales.timeline
import generator.score
import generator.solo.arrangement
import generator.solo.sequences
FINAL_SECTION_INDEX = 6
def arrange_document(document: dict, part_type: str, *, rhythms: generator.rhythm.Library, scales: generator.scales.library.Library | None = None) -> dict:
"""Place the regular form; the song pipeline inserts solos after all heads exist."""
document = generator.parts.prepare_document(document)
plans = plan_arrangement(document, rhythms) if part_type != 'vocal' else {}
return _arrange_document(document, part_type, plans, scales=scales)
@@ -127,7 +130,7 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
reports.append(report)
return result
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library, scales: generator.scales.library.Library | None = None) -> int:
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library, scales: generator.scales.library.Library, sequences: generator.solo.sequences.Library) -> int:
count = 0
for path in generator.parts.source_paths(source_dir):
try:
@@ -135,15 +138,20 @@ def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Pa
plans = plan_arrangement(document, rhythms)
except ValueError as error:
raise ValueError(f'{path}: {error}') from error
reports = []
parts = {}
for part in generator.parts.PART_TYPES:
try:
generator.score.write_json(output_dir / f'{generator.score.slugify(path.stem)}_{part}.json', _arrange_document(document, part, plans, scales=scales, reports=reports, source_name=path.name))
parts[part] = _arrange_document(document, part, plans)
except ValueError as error:
raise ValueError(f'{path}, part {part}: {error}') from error
try:
report = generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences)
except ValueError as error:
raise ValueError(f'{path}: {error}') from error
for part, result in parts.items():
generator.score.write_json(output_dir / f'{generator.score.slugify(path.stem)}_{part}.json', result)
count += 1
if reports:
report_path = output_dir.parent / 'analysis' / f'{generator.score.slugify(path.stem)}.txt'
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(document['metadata']['title'] + '\nSource: ' + path.name + '\n\n' + '\n'.join(reports), encoding='utf-8')
report_path = output_dir.parent / 'analysis' / f'{generator.score.slugify(path.stem)}.txt'
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(document['metadata']['title'] + '\nSource: ' + path.name + '\n\n' + report, encoding='utf-8')
return count
+4 -1
View File
@@ -56,7 +56,7 @@ def following_indices(timeline: tuple[generator.scales.timeline.ChordSpan, ...])
result[index] = result[index + 1] if same else index + 1
return result
def select(timeline: tuple[generator.scales.timeline.ChordSpan, ...], solo: generator.scales.timeline.Solo, library: generator.scales.library.Library) -> tuple[Choice, ...]:
def select(timeline: tuple[generator.scales.timeline.ChordSpan, ...], solo: generator.scales.timeline.Solo, library: generator.scales.library.Library, *, retained: tuple = ()) -> tuple[Choice, ...]:
collections = library.collections()
following = following_indices(timeline)
rows, layers = [], []
@@ -77,6 +77,9 @@ def select(timeline: tuple[generator.scales.timeline.ChordSpan, ...], solo: gene
if target.pitches == home.pitches:
target = home
required = set(generator.harmony.pitch_classes(span.chord)) if span.chord else set()
for start, end, pitches in retained:
if start < span.end and end > span.start:
required.update(pitches)
options = [target if scale.pitches == target.pitches else home if scale.pitches == home.pitches else scale for scale in collections if required <= scale.pitches] if span.chord else []
options = options or [None]
layer = []
+2 -2
View File
@@ -21,10 +21,10 @@ class Solo:
end: fractions.Fraction
label: str
def read(staff: dict) -> tuple[ChordSpan, ...]:
def read(staff: dict, *, initial: dict | None = None) -> tuple[ChordSpan, ...]:
result = []
absolute = fractions.Fraction()
active = None
active = initial
for number, measure in enumerate(staff['measures'], 1):
length = generator.model.fraction(measure['duration'])
harmonies = {generator.model.fraction(h['onset']): h for h in measure['harmonies']}
+1
View File
@@ -0,0 +1 @@
"""Build-time phrase composition; runtime consumes ordinary score events."""
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
import dataclasses
import fractions
import hashlib
import generator.harmony
import generator.model
import generator.score
import generator.solo.types
@dataclasses.dataclass(frozen=True)
class Analysis:
motifs: tuple[generator.solo.types.Motif, ...]
boundaries: tuple[fractions.Fraction, ...]
tempo: fractions.Fraction
melody: tuple[generator.solo.types.Note, ...]
def attacks(document: dict) -> tuple[generator.solo.types.Note, ...]:
"""Collapse tied tones before extracting monophonic melody fragments."""
result, pending = [], {}
absolute = fractions.Fraction()
for measure in generator.score.source_staff(document)['measures']:
for event in generator.score.measure_notes(measure):
if event.get('grace') or event.get('cue'):
continue
for tone in event['pitches']:
number = generator.model.pitch_number(tone)
if number.denominator != 1:
continue
start = absolute + generator.model.fraction(event['onset'])
end = start + generator.model.fraction(tone['duration'])
pitch = generator.solo.types.Pitch(tone['letter'], generator.model.alteration(tone['alter']), tone['octave'])
source = pending.pop(tone.get('tie_from'), None)
if source is not None:
result[source] = dataclasses.replace(result[source], end=end, sources=(*result[source].sources, event['id']))
else:
source = len(result)
result.append(generator.solo.types.Note(start, end, pitch, (event['id'],)))
if 'tie_to' in tone:
pending[tone['id']] = source
absolute += generator.model.fraction(measure['duration'])
return tuple(sorted(result, key=lambda n: (n.start, -n.pitch.number(), n.sources)))
def analyze(document: dict) -> Analysis:
measures = generator.score.source_staff(document)['measures']
melody = attacks(document)
boundaries = [fractions.Fraction()]
for measure in measures:
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
tempo = next((generator.model.fraction(t['bpm']) * generator.model.written_length(t['written']) for m in measures for t in m.get('tempos', [])), fractions.Fraction(120))
candidates = []
for index, measure in enumerate(measures):
start = boundaries[index]
# One- and two-bar ideas retain leading silence and the complete held ending.
for count in (1, 2):
if index + count >= len(boundaries):
continue
end = boundaries[index + count]
notes = tuple(n for n in melody if start <= n.start < end)
if not notes or any(n.end > end for n in notes) or any(a.end > b.start for a, b in zip(notes, notes[1:])):
continue
if len(notes) > 8:
continue
normalized = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in notes)
identity = repr(tuple((n.start, n.end, n.pitch) for n in normalized)).encode('ascii')
identifier = 'head_' + hashlib.sha256(identity).hexdigest()[:12]
context = tuple(generator.harmony.symbol(h) for m in measures[index:index + count] for h in m['harmonies'])
candidates.append(generator.solo.types.Motif(identifier, normalized, end - start, context))
unique = {m.id: m for m in reversed(candidates)}
# Preserve the opening idea, then favor a contrasting rhythm/contour with space.
ordered = list(unique.values())
ordered.sort(key=lambda m: (next(i for i, n in enumerate(melody) if n.sources[0] == m.notes[0].sources[0]), m.duration))
return Analysis(tuple(ordered), tuple(boundaries), tempo, melody)
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
import copy
import fractions
import generator.accompaniment
import generator.acoustic
import generator.acoustic_voicing
import generator.arrange.form
import generator.electric
import generator.guitar
import generator.model
import generator.parts
import generator.score
import generator.solo.analysis
import generator.solo.composer
import generator.solo.notation
import generator.solo.report
import generator.solo.types
def regions(document):
result, first, start = {}, 0, fractions.Fraction()
source = generator.score.source_staff(document)['measures']
for section in generator.arrange.form.full_arrangement_sections(source, 'acoustic'):
length = sum((generator.model.fraction(m['duration']) for m in source[section.source_measure_index:section.source_measure_index + section.measure_count]), fractions.Fraction())
result[section.section_index] = (first, section.measure_count, start, length)
first += section.measure_count
start += length
return result
def annotate(document, solo, offset):
spans = []
for choice in solo.choices:
if choice.scale is None:
continue
label = choice.scale.label()
if spans and spans[-1]['label'] == label and generator.model.fraction(spans[-1]['end']) == offset + choice.span.start:
spans[-1]['end'] = generator.model.rational(offset + choice.span.end)
else:
spans.append({'id': f'{solo.part}_scale_{len(spans) + 1}', 'start': generator.model.rational(offset + choice.span.start), 'end': generator.model.rational(offset + choice.span.end), 'label': label, 'matched': True})
document['staves'][0]['scale_spans'] = spans
def reuse_head_grips(document, references, choices):
previous = generator.accompaniment.carried_chords(references)
target_previous = generator.accompaniment.carried_chords(document['staves'][0]['measures'])
links, absolute = [], fractions.Fraction()
for index, measure in enumerate(document['staves'][0]['measures']):
for note in generator.score.measure_notes(measure):
onset = generator.model.fraction(note['onset'])
chord = generator.accompaniment.active_chord(measure, onset, target_previous[index])
if not note['pitches'] or chord is None:
continue
allowed = generator.solo.types.permitted(choices, absolute + onset, absolute + onset + generator.model.fraction(note['duration']))
matches = []
for candidate in generator.score.measure_notes(references[index]):
if len(candidate['pitches']) < 3:
continue
time = generator.model.fraction(candidate['onset'])
head_chord = generator.accompaniment.active_chord(references[index], time, previous[index])
if generator.accompaniment.same_chord(chord, head_chord) and {int(generator.model.pitch_number(p)) % 12 for p in candidate['pitches']} <= allowed:
matches.append((abs(time - onset), candidate['id'], candidate))
links.append(generator.electric.VoicingLink(note, tuple(item[2] for item in sorted(matches))))
absolute += generator.model.fraction(measure['duration'])
generator.electric.reuse_voicings(links)
def backing(baseline, foreground, part, rhythms, references, choices, initial=None):
plan = generator.accompaniment.plan_section(foreground, rhythms, initial=initial, electric=part == 'electric', acoustic_companions=False)
previous = generator.accompaniment.carried_chords(foreground, initial)
measures = []
for index, measure in enumerate(foreground):
rhythm = (plan.acoustic_bass[index], plan.acoustic_upper[index]) if part == 'acoustic' else plan.electric[index]
result = generator.parts.build_measure(measure, measure['id'], 'accompaniment', part, previous[index], measure.get('section_label', ''), rhythm)
measures.append(result)
generator.score.reidentify(measures, [])
staff = baseline['staves'][0]
document = generator.score.make_arranged_document(baseline, staff, staff_id=staff['id'], staff_name=staff['name'], role='guitar', arrangement_part_type=part, measures=measures)
if part == 'acoustic':
generator.acoustic.prepare(document)
generator.acoustic_voicing.place(document)
generator.acoustic.combine(document)
else:
generator.guitar.place_guitar(document)
reuse_head_grips(document, references, choices)
generator.model.validate_document(document)
return document['staves'][0]['measures']
def apply(source, parts, *, rhythms, scales, sequences):
"""Compose once per song, replacing only already-placed solo section windows."""
analysis = generator.solo.analysis.analyze(source)
sections = regions(source)
source_measures = generator.score.source_staff(source)['measures']
head_first, head_count, _, _ = sections[2]
head = parts['electric']['staves'][0]['measures'][head_first:head_first + head_count]
reports = []
for section_index, part, other in ((3, 'acoustic', 'electric'), (4, 'electric', 'acoustic')):
first, count, offset, length = sections[section_index]
initial = generator.accompaniment.carried_chords(parts[part]['staves'][0]['measures'])[first]
template = copy.deepcopy(parts[part]['staves'][0]['measures'][first:first + count])
cutoff = length
if part == 'electric' and generator.score.body_start_measure_index(source_measures):
pickup = source_measures[0]
sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(pickup) if n['pitches']]
if sounding:
cutoff -= generator.model.fraction(pickup['duration']) - min(sounding)
solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial)
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
generator.solo.notation.replace_region(parts[part], first, foreground)
annotate(parts[part], solo, offset)
# The other guitarist responds to the actual foreground, including vocal return.
if cutoff < length:
vocal = parts['vocal']['staves'][0]['measures'][first:first + count]
time = fractions.Fraction()
for measure, voice_measure in zip(foreground, vocal):
returning = [copy.deepcopy(event) | {'voice': 'vocal_return'} for event in generator.score.measure_notes(voice_measure) if event['pitches'] and time + generator.model.fraction(event['onset']) >= cutoff]
if returning:
measure['voices'].append({'id': 'vocal_return', 'notes': returning})
time += generator.model.fraction(measure['duration'])
accompaniment = backing(parts[other], foreground, other, rhythms, head, solo.choices, initial)
generator.solo.notation.replace_region(parts[other], first, accompaniment)
reports.append(generator.solo.report.describe(solo, scales, first, offset))
for document in parts.values():
generator.model.validate_document(document)
return '\n'.join(reports)
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import dataclasses
import fractions
import generator.fretboard
import generator.harmony
import generator.model
import generator.scales.selection
import generator.scales.timeline
import generator.solo.analysis
import generator.solo.planning
import generator.solo.realization
import generator.solo.types
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None):
timeline = generator.scales.timeline.read({'measures': measures}, initial=initial)
region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo')
choices = generator.scales.selection.select(timeline, region, scales)
for choice in choices:
if choice.span.chord and choice.scale is None:
raise ValueError(f'{part} solo, source measure {choice.span.source_measure}, beat {choice.span.beat}, {generator.harmony.symbol(choice.span.chord)}: No exact match within the permitted catalog')
plans = generator.solo.planning.plans(analysis, measures, choices, end, part, sequences)
# A protected quote may change the local collection, never its individual pitches.
for plan in plans:
if plan.treatment != 'quote' or plan.motif.duration > plan.end - plan.start:
continue
retained = tuple((plan.start + n.start, plan.start + n.end, {n.pitch.number() % 12}) for n in plan.motif.notes)
if all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
continue
alternative = generator.scales.selection.select(timeline, region, scales, retained=retained)
if all(pc <= generator.solo.types.permitted(alternative, start, finish) for start, finish, pc in retained) and all(c.scale or c.span.chord is None for c in alternative):
choices = tuple(dataclasses.replace(c, reason=c.reason + '; retained quotation') if any(a < c.span.end and b > c.span.start for a, b, _ in retained) else c for c in alternative)
phrases, previous = [], None
for plan in plans:
phrase = generator.solo.realization.compose(plan, measures, choices, sequences, rhythms, analysis.tempo, previous)
phrases.append(phrase)
notes = tuple(n for d in phrase.decisions for n in d.notes)
if notes:
previous = notes[-1]
result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end)
validate(result)
return result
def validate(solo):
previous = None
for note in solo.notes():
if note.start < 0 or note.end > solo.end or note.end <= note.start or previous and previous.end > note.start:
raise ValueError(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}')
if note.pitch.number() % 12 not in generator.solo.types.permitted(solo.choices, note.start, note.end):
raise ValueError(f'{solo.part} solo: held pitch {note.pitch.label()} violates a scale span at {note.start}-{note.end}')
if note.position not in generator.fretboard.positions_for_pitch(fractions.Fraction(note.pitch.number())):
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
previous = note
for phrase in solo.phrases:
notes = tuple(n for d in phrase.decisions for n in d.notes)
if not generator.solo.realization.target_ok(notes, phrase.plan.target):
raise ValueError(f'{phrase.plan.id}: planned harmonic arrival was not realized')
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import copy
import fractions
import itertools
import generator.accompaniment
import generator.model
import generator.score
import generator.solo.types
def durations(length):
if length.denominator & (length.denominator - 1) == 0:
yield from generator.accompaniment.written_durations(length)
else:
kind, base = next((kind, value) for kind, value in reversed(generator.model.TYPE_LENGTHS.items()) if value >= length)
yield length, {'type': kind, 'dots': 0, 'ratio': generator.model.rational(length / base)}
def measures(sources: list[dict], notes: tuple[generator.solo.types.Note, ...], prefix: str) -> list[dict]:
"""Write attack spans as conventional durations; only continuations get ties."""
result, previous = [], {}
absolute = fractions.Fraction()
for index, source in enumerate(sources):
length = generator.model.fraction(source['duration'])
events, cursor = [], absolute
def emit(start, end, tone=None, identity=None):
for duration, written in durations(end - start):
identifier = f'{prefix}_{index + 1}_{len(events) + 1}'
pitches = [tone.pitch.written() | {'tablature': {'string': tone.position.string_number, 'fret': tone.position.fret}}] if tone else []
event = generator.model.make_event(identifier, start - absolute, duration, 'note' if tone else 'rest', pitches, written)
if tone:
pitch = event['pitches'][0]
if identity in previous:
prior = previous[identity]
prior['tie_to'] = pitch['id']
pitch['tie_from'] = prior['id']
previous[identity] = pitch
events.append(event)
start += duration
for identity, note in enumerate(notes):
start, end = max(absolute, note.start), min(absolute + length, note.end)
if start >= end:
continue
if start > cursor:
emit(cursor, start)
emit(start, end, note, identity)
cursor = end
if cursor < absolute + length:
emit(cursor, absolute + length)
measure = generator.score.make_measure(source['id'], source, events, section_label=source.get('section_label', ''))
measure['transpose'] = {'diatonic': 0, 'chromatic': 0, 'octave_change': -1, 'explicit': True}
measure['clef'] = {'sign': 'G', 'line': 2, 'octave_change': 0}
for change in measure.get('attributes', []):
change.pop('transpose', None)
change.pop('clef', None)
result.append(measure)
absolute += length
return result
def tuplets(measures):
spans = []
for measure in measures:
for ratio, values in itertools.groupby(generator.score.measure_notes(measure), key=lambda n: generator.model.fraction(n['written'].get('ratio', generator.model.rational(1)))):
notes = list(values)
if ratio == 1:
continue
for first in range(0, len(notes), ratio.denominator):
members = notes[first:first + ratio.denominator]
identifier = members[0]['id'] + '_tuplet'
spans.append({'id': identifier, 'voice': 'voice_arranged', 'start': members[0]['id'], 'end': members[-1]['id'], 'note_count': ratio.denominator, 'space_count': ratio.numerator, 'depth': 0, 'bracket': 'auto', 'show_number': 'actual'})
for note in members:
note['tuplet_ids'] = [identifier]
return spans
def replace_region(document: dict, first: int, replacement: list[dict]) -> None:
staff = document['staves'][0]
replacement = copy.deepcopy(replacement)
spans = tuplets(replacement)
staff['measures'][first:first + len(replacement)] = replacement
staff['tablature'] = True
existing = {n['id'] for m in staff['measures'] for n in generator.score.measure_notes(m)}
staff['tuplets'] = [t for t in staff.get('tuplets', []) if t['start'] in existing and t['end'] in existing] + spans
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
import dataclasses
import fractions
import functools
import generator.fretboard
import generator.solo.types
def travel(previous: generator.solo.types.Note | None, current: generator.solo.types.Note, tempo: fractions.Fraction) -> int:
if previous is None:
return 0
return transition(previous.position, current.position, current.start - previous.start, tempo)
@functools.lru_cache(maxsize=16384)
def transition(previous, current, duration, tempo):
distance = abs(previous.fret - current.fret) + abs(previous.string_number - current.string_number)
seconds = float(duration * 60 / tempo)
# Movement is a preference, not an unsupported claim about a player's limits.
return round(100 * distance / max(seconds, 0.05))
@functools.lru_cache(maxsize=4096)
def place(notes: tuple[generator.solo.types.Note, ...], tempo: fractions.Fraction, previous: generator.solo.types.Note | None = None) -> tuple[int, tuple[generator.solo.types.Note, ...]] | None:
states = [(0, ())]
for note in notes:
following = []
for position in positions(note.pitch.number()):
current = dataclasses.replace(note, position=position)
candidates = [(cost + travel(path[-1] if path else previous, current, tempo), (*path, current)) for cost, path in states]
following.append(min(candidates, key=lambda item: (item[0], tuple(n.position for n in item[1]))))
if not following:
return None
states = following
return min(states, key=lambda item: (item[0], tuple(n.position for n in item[1])))
@functools.lru_cache(maxsize=128)
def positions(number: int):
return generator.fretboard.positions_for_pitch(fractions.Fraction(number))
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
import fractions
import generator.harmony
import generator.model
import generator.scales.selection
import generator.solo.analysis
import generator.solo.sequences
import generator.solo.types
def target_pitches(chord: dict, final: bool) -> frozenset[int]:
degrees = generator.harmony.degrees(chord)
explicit = {d['value'] for d in chord.get('degrees', []) if d['type'] != 'subtract'}
preferred = {1, 3} if final else {3, 7}
preferred |= explicit | {d for d, a in degrees.items() if d == 5 and a}
if 3 not in degrees:
preferred |= {2, 4}
root = generator.scales.selection.root_number(chord)
return frozenset(int(root + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items() if d in preferred) or frozenset(generator.harmony.pitch_classes(chord))
def destination(choices, start, end, final=False, release=fractions.Fraction(1, 2)):
last = next((c for c in reversed(choices) if c.span.start < end and c.span.end > start), None)
if not last or not last.span.chord:
return None
latest = end - release - fractions.Fraction(1, 4)
if last.span.start > latest:
return None
return generator.solo.types.TargetEvent(max(start, last.span.start, end - 1 - release), latest, target_pitches(last.span.chord, final), generator.harmony.symbol(last.span.chord), 'phrase resolution' if final else 'defining chord-tone arrival')
def new_motif(sequences: generator.solo.sequences.Library, length: fractions.Fraction) -> generator.solo.types.Motif:
paths = sequences.connecting(3) or sequences.connecting(2) or sequences.connecting(4)
if not paths:
raise ValueError('solo composition requires a two-, three-, or four-note sequence')
path = min(paths, key=lambda s: (sum(a * b >= 0 for a, b in zip(s.steps, s.steps[1:])), s.rank()))
count = len(path.steps) + 1
unit = min(fractions.Fraction(1, 2), length / (count + 1))
notes = []
for i, offset in enumerate(path.offsets()):
octave, degree = divmod(28 + offset, 7)
pitch = generator.solo.types.Pitch(generator.model.LETTERS[degree], fractions.Fraction(), octave)
notes.append(generator.solo.types.Note(unit * i, unit * (i + 1), pitch))
return generator.solo.types.Motif('new_' + path.id.replace('/', '_').removesuffix('.xml'), tuple(notes), length, (), 'sequence ' + path.id)
def choose_motifs(analysis, sequences, length):
eligible = [m for m in analysis.motifs if m.duration <= 2 * length]
head = next((m for m in eligible if len(m.notes) >= 2), eligible[0] if eligible else new_motif(sequences, length))
others = [m for m in eligible if m.id != head.id]
contrast = min(others, key=lambda m: (m.contour() == head.contour(), abs(len(m.notes) - 3), m.duration, m.id)) if others else head
return head, contrast, new_motif(sequences, length)
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])
result, index = [], 0
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))
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
stop = min(possible, key=boundary_rank)
finish = boundaries[stop]
final = finish == end
phase = (start + finish) / (2 * end)
motif = head if part == 'acoustic' or final else invented
if len(result) > 1 and not final and len(result) % 3 == 2:
motif = contrast if part == 'acoustic' else invented
relationship = 'statement' if not result else 'callback' if final else 'repeat' if len(result) == 1 else 'answer' if len(result) % 2 else 'variation'
treatment = 'quote' if (part == 'acoustic' and not result) or (part == 'electric' and final and motif.origin == 'melody') else 'thematic'
low, high = (55, 74) if part == 'acoustic' else (59, 79)
# Reserve the upper register for a prepared peak, not every active passage.
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(3, 4) if part == 'acoustic' else fractions.Fraction(1)
if final:
activity *= fractions.Fraction(2, 3)
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', start, finish, motif, relationship, treatment, destination(choices, start, finish, final), register, activity))
index = stop
return tuple(result)
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
import dataclasses
import fractions
import functools
import heapq
import generator.fretboard
import generator.model
import generator.rhythm
import generator.scales.selection
import generator.solo.placement
import generator.solo.planning
import generator.solo.types
BEAM_WIDTH = 64
CELL_LIMIT = 32
class SearchExhausted(ValueError):
pass
@dataclasses.dataclass(frozen=True)
class Cell:
start: fractions.Fraction
end: fractions.Fraction
patterns: tuple[tuple[str, tuple[tuple[fractions.Fraction, fractions.Fraction], ...]], ...]
motif: generator.solo.types.Motif | None = None
@dataclasses.dataclass(frozen=True)
class Candidate:
notes: tuple[generator.solo.types.Note, ...]
source: str
treatment: str
rank: tuple
def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fractions.Fraction, activity: fractions.Fraction):
result = {}
for snippet in library.for_meter(meter):
for index, bar in enumerate(snippet.bars):
spans = []
for event in bar.events:
if not event.sounding:
continue
if event.continuation:
if spans and spans[-1][1] == event.onset:
spans[-1] = (spans[-1][0], event.onset + event.duration)
else:
# A continuation without its attack is not an independent cell.
spans = []
break
else:
spans.append((event.onset, event.onset + event.duration))
pattern = tuple(spans)
if all(end <= length for _, end in pattern) and len(pattern) <= 4:
result.setdefault(pattern, f'rhythm {snippet.id}:{index + 1}')
ranked = sorted(result.items(), key=lambda item: (abs(len(item[0]) - length * activity), -sum(end - start for start, end in item[0]), item[1]))
return tuple((identifier, pattern) for pattern, identifier in ranked[:8])
def cells(plan, measures, rhythms):
result, start, index = [], fractions.Fraction(), 0
boundaries = [fractions.Fraction()]
for measure in measures:
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
start = plan.start
music_end = plan.end - plan.release
arrival_fits = not plan.target or plan.motif.duration < music_end - start or any(plan.target.earliest <= start + n.start <= plan.target.latest for n in plan.motif.notes)
if plan.motif.duration <= music_end - start and arrival_fits:
motif = plan.motif
spans = tuple((n.start, n.end) for n in motif.notes)
patterns = [(motif.id, spans)]
if plan.relationship == 'variation' and spans[-1][1] + fractions.Fraction(1, 2) <= motif.duration:
patterns.insert(0, (motif.id + ':displaced', tuple((a + fractions.Fraction(1, 2), b + fractions.Fraction(1, 2)) for a, b in spans)))
result.append(Cell(start, start + motif.duration, tuple(patterns), motif))
start += motif.duration
elif plan.motif.notes and plan.motif.duration > music_end - start:
# Fragmentation is an explicit transformation, never a truncated quotation.
available = (plan.target.earliest if plan.target else music_end) - start
notes = tuple(n for n in plan.motif.notes if n.end <= available)
if notes:
motif = dataclasses.replace(plan.motif, notes=notes, duration=notes[-1].end)
result.append(Cell(start, start + motif.duration, ((motif.id + ':fragment', tuple((n.start, n.end) for n in notes)),), motif))
start += motif.duration
while start < music_end:
index = next(i for i in range(len(measures)) if boundaries[i] <= start < boundaries[i + 1])
end = min(music_end, boundaries[index + 1])
patterns = rhythm_patterns(rhythms, measures[index]['time_signature'], end - start, plan.activity)
# These primitives are deliberate alternatives, independent of catalog counts.
patterns += (('hold', ((fractions.Fraction(), end - start),)), ('space', ()))
if plan.target and start <= plan.target.latest < end:
onset = max(start, plan.target.earliest) - start
patterns = (('arrival_and_release', ((onset, min(end - start, onset + 1)),)), *patterns)
result.append(Cell(start, end, patterns))
start = end
result.append(Cell(music_end, plan.end, (('planned_release', ()),)))
return tuple(result)
@functools.lru_cache(maxsize=2048)
def scale_options(scale, allowed, low, high):
return tuple(generator.solo.types.scale_pitch(scale, index) for index in range(-21, 28) if low <= generator.solo.types.scale_pitch(scale, index).number() <= high and generator.solo.types.scale_pitch(scale, index).number() % 12 in allowed and generator.fretboard.positions_for_pitch(fractions.Fraction(generator.solo.types.scale_pitch(scale, index).number())))
def valid_notes(notes, choices):
return all(n.pitch.number() % 12 in generator.solo.types.permitted(choices, n.start, n.end) for n in notes)
def options(choices, start, end, register):
scale = next((c.scale for c in choices if c.span.start <= start < c.span.end), None)
allowed = generator.solo.types.permitted(choices, start, end)
return scale_options(scale, allowed, max(41, register[0] - 7), min(84, register[1] + 5)) if scale and allowed else ()
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, ())]
for index, (start, end) in enumerate(spans):
following = []
for cost, path in states:
available = options(choices, start, end, plan.register)
changing_ending = plan.relationship == 'answer' and index == len(spans) - 1
if changing_ending:
chord = next((c.span.chord for c in choices if c.span.start <= start < c.span.end), None)
if chord:
available = tuple(p for p in available if p.number() % 12 in generator.solo.planning.target_pitches(chord, False))
for pitch in available:
reference = cell.motif.notes[index]
expected = cell.motif.contour()[index - 1] if index else 0
motion = pitch.number() - path[-1].pitch.number() if path else 0
# Repeated notes are identity, not missing sequence transitions.
mismatch = int(bool(index) and ((expected == 0) != (motion == 0) or expected * motion < 0))
note = generator.solo.types.Note(start, end, pitch, reference.sources, 'motif')
following.append((cost + (0 if changing_ending else 8 * mismatch + abs(motion - expected)), (*path, note)))
states = sorted(following, key=lambda item: (item[0], tuple(n.pitch for n in item[1])))[:BEAM_WIDTH]
if not states:
break
return states
def adapt_path(pitches, spans, choices, register):
"""Connect actual pitches across collections without carrying a stale degree index."""
states = [(0, ())]
for index, ((start, end), intended) in enumerate(zip(spans, pitches)):
available = options(choices, start, end, register)
following = []
for cost, path in states:
desired = path[-1].pitch.number() + intended.number() - pitches[index - 1].number() if path else intended.number()
for pitch in sorted(available, key=lambda p: (abs(p.number() - desired), p))[:2]:
following.append((cost + abs(pitch.number() - desired), (*path, generator.solo.types.Note(start, end, pitch, role='adapted_path'))))
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 []
paths = []
allowed = tuple(generator.solo.types.permitted(choices, start, end) for start, end in spans)
changing = any(classes != allowed[0] for classes in allowed)
sequence_options = sequences.connecting(len(spans))[:16]
offsets = [(s.offsets(), 'sequence ' + s.id) for s in sequence_options]
# Single notes and explicit repetitions do not pretend to be catalog paths.
offsets.append(((0,) * len(spans), 'single' if len(spans) == 1 else 'repeated_note'))
for steps, identifier in offsets:
for target_index in range(-14, 21):
start_index = target_index - steps[-1]
pitches = tuple(generator.solo.types.scale_pitch(first_scale, start_index + step) for step in steps)
if not all(max(41, plan.register[0] - 7) <= p.number() <= min(84, plan.register[1] + 5) for p in pitches):
continue
if all(pitch.number() % 12 in classes for pitch, classes in zip(pitches, allowed)):
notes = tuple(generator.solo.types.Note(start, end, pitch, role='path') for (start, end), pitch in zip(spans, pitches))
paths.append((notes, identifier))
elif changing and not paths and steps == offsets[0][0]:
paths.extend((notes, 'harmonic_adaptation of ' + identifier) for notes in adapt_path(pitches, spans, choices, plan.register))
return paths
def candidates(cell, plan, choices, sequences):
result = []
target = plan.target if plan.target and cell.start <= plan.target.latest < cell.end else None
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):
spans = tuple(spans[i] for i in active)
identifier += ':harmonic_fragment'
if cell.motif:
local_cell = dataclasses.replace(cell, motif=dataclasses.replace(cell.motif, notes=tuple(cell.motif.notes[i] for i in active)))
paths = []
if local_cell.motif and spans:
if plan.treatment == '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))
for mismatch, notes in thematic_paths(local_cell, spans, choices, plan):
treatment = 'fragment' if 'fragment' in identifier else 'rhythmic_displacement' if ':displaced' in identifier else 'changed_ending' if plan.relationship == 'answer' else 'harmonic_adaptation'
paths.append((notes, treatment, 1 + mismatch))
else:
paths = [(notes, name, 0) for notes, name in sequence_paths(spans, choices, plan, sequences)]
for notes, treatment, identity in paths:
if not target_ok(notes, target):
continue
notes = tuple(dataclasses.replace(n, role='target' if target and target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches else n.role) for n in notes)
register = 16 * sum(max(0, plan.register[0] - n.pitch.number(), n.pitch.number() - plan.register[1]) for n in notes)
if notes:
register += abs(max(n.pitch.number() for n in notes) - sum(plan.register) // 2)
density = abs(len(notes) - (cell.end - cell.start) * plan.activity)
contour = sum(max(0, abs(b.pitch.number() - a.pitch.number()) - 5) for a, b in zip(notes, notes[1:]))
if not cell.motif and len({n.pitch.number() for n in notes}) == 1:
contour += max(0, len(notes) - 2)
rank = (identity, register, density if not cell.motif else 0, contour)
result.append(Candidate(notes, identifier, treatment, rank))
# A no-harmony passage is intentional silence, not a failed pitch search.
if 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, 0, 0, 0))]
return sorted(result, key=lambda c: (c.rank, c.source, c.treatment, tuple(n.pitch for n in c.notes)))
def compose(plan, measures, choices, sequences, rhythms, tempo, previous=None):
states = [((0, 0, 0, 0, 0, 0, 0), (), previous)]
for cell in cells(plan, measures, rhythms):
pool = candidates(cell, plan, choices, sequences)
following, extensions = [], {}
for cost, decisions, last in states:
def extension_rank(candidate):
jump = abs(candidate.notes[0].pitch.number() - last.pitch.number()) if candidate.notes and last else 0
return candidate.rank, jump, candidate.source, candidate.treatment, tuple(n.pitch for n in candidate.notes)
key = last.pitch.number() if last else None
if key not in extensions:
extensions[key] = heapq.nsmallest(CELL_LIMIT, pool, key=extension_rank)
for candidate in extensions[key]:
placed = generator.solo.placement.place(candidate.notes, tempo, last)
if placed is None:
continue
comfort, notes = placed
jump = abs(notes[0].pitch.number() - last.pitch.number()) if notes and last else 0
monotony = int(bool(notes and decisions and decisions[-1].notes) and tuple(n.pitch for n in notes) == tuple(n.pitch for n in decisions[-1].notes) and cell.motif is None)
local = (*candidate.rank, jump, comfort, monotony)
total = tuple(a + b for a, b in zip(cost, local))
decision = generator.solo.types.Decision(cell.start, cell.end, candidate.source, candidate.treatment, notes)
following.append((total, (*decisions, decision), notes[-1] if notes else last))
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)')
following.sort(key=lambda item: (item[0], tuple((d.source, d.treatment, tuple((n.pitch, n.position) for n in d.notes)) for d in item[1])))
# Retain distinct endings for lookahead instead of 64 copies of one path.
unique = {}
for item in following:
last = item[2]
key = (last.pitch, last.position, last.end) if last else None
unique.setdefault(key, item)
states = list(unique.values())[:BEAM_WIDTH]
_, decisions, _ = states[0]
return generator.solo.types.Phrase(plan, decisions)
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import dataclasses
import generator.harmony
import generator.model
import generator.scales.report
import generator.scales.selection
import generator.scales.timeline
import generator.solo.types
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0) -> str:
region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo')
positioned = tuple(dataclasses.replace(c, span=dataclasses.replace(c.span, start=c.span.start + time_offset, end=c.span.end + time_offset, measure=c.span.measure + measure_offset)) for c in solo.choices)
lines = [generator.scales.report.passage(solo.part, region, positioned, scales), f'Phrase composition: {solo.part}', f'Placement reference tempo: {solo.tempo} quarter notes/minute. Runtime tempo changes are not a comfort guarantee.', 'Times below are quarter-note offsets from the start of this solo; pitches are sounding.']
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)))
if plan.target:
lines.append(f' Destination: {plan.target.chord}; {plan.target.reason}; arrival {plan.target.earliest}-{plan.target.latest}')
for decision in phrase.decisions:
lines.append(f' {decision.start}-{decision.end}: {decision.source}; {decision.treatment}')
if not decision.notes:
lines.append(' Silence')
for note in decision.notes:
choice = next(c for c in solo.choices if c.span.start <= note.start < c.span.end)
root = generator.scales.selection.root_number(choice.span.chord)
degrees = [str(d) for d, a in generator.harmony.degrees(choice.span.chord).items() if (root + generator.harmony.degree_semitones(d, a)) % 12 == note.pitch.number() % 12]
function = 'chord degree ' + '/'.join(degrees) if degrees else 'in-collection connection'
lines.append(f' {note.start}-{note.end}: {note.pitch.label()}; {note.role}; {generator.harmony.symbol(choice.span.chord)}, {function}; {choice.scale.label()}; string {note.position.string_number}, fret {note.position.fret}')
return '\n'.join(lines) + '\n'
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
import dataclasses
import pathlib
import types
import generator.model
import generator.musicxml
@dataclasses.dataclass(frozen=True)
class Sequence:
id: str
steps: tuple[int, ...]
def offsets(self) -> tuple[int, ...]:
result = [0]
for step in self.steps:
result.append(result[-1] + step)
return tuple(result)
def rank(self) -> tuple:
offsets = self.offsets()
return max(map(abs, self.steps)), max(offsets) - min(offsets), sum(a * b < 0 for a, b in zip(self.steps, self.steps[1:])), self.id
@dataclasses.dataclass(frozen=True, init=False)
class Library:
paths: types.MappingProxyType
def __init__(self, sequences: tuple[Sequence, ...]):
paths, identities = {}, set()
for sequence in sequences:
if sequence.id in identities or not 1 <= len(sequence.steps) <= 3 or any(not 1 <= abs(step) <= 7 for step in sequence.steps):
raise ValueError(f'{sequence.id}: invalid or duplicate sequence')
identities.add(sequence.id)
paths.setdefault(len(sequence.steps) + 1, []).append(sequence)
object.__setattr__(self, 'paths', types.MappingProxyType({count: tuple(sorted(items, key=lambda s: s.rank())) for count, items in paths.items()}))
def connecting(self, count: int, displacement: int | None = None) -> tuple[Sequence, ...]:
return tuple(s for s in self.paths.get(count, ()) if displacement is None or s.offsets()[-1] == displacement)
def read(path: pathlib.Path, identifier: str, count: int) -> Sequence:
document = generator.musicxml.load_musicxml(path)
measures = document['staves'][0]['measures']
if len(measures) != 1 or len(measures[0]['voices']) != 1 or measures[0]['time_signature'] != f'{count}/4':
raise ValueError(f'{path}: expected one {count}/4 sequence measure and one voice')
notes = measures[0]['voices'][0]['notes']
if len(notes) != count:
raise ValueError(f'{path}: expected {count} sequence notes')
degrees = []
for index, event in enumerate(notes):
if event.get('grace') or len(event['pitches']) != 1 or generator.model.fraction(event['onset']) != index or generator.model.fraction(event['duration']) != 1:
raise ValueError(f'{path}: sequence requires consecutive untied quarter notes')
pitch = event['pitches'][0]
if generator.model.alteration(pitch['alter']) or 'tie_from' in pitch or 'tie_to' in pitch:
raise ValueError(f'{path}: sequences must use untied C-major scale degrees')
degrees.append(7 * pitch['octave'] + generator.model.LETTERS.index(pitch['letter']))
if degrees[0] != 28:
raise ValueError(f'{path}: sequence must begin on C4')
return Sequence(identifier, tuple(b - a for a, b in zip(degrees, degrees[1:])))
def load_library(root: pathlib.Path) -> Library:
sequences = []
for path in sorted(root.rglob('*.xml')):
relative = path.relative_to(root)
if len(relative.parts) != 2 or relative.parts[0] not in ('2_4', '3_4', '4_4'):
raise ValueError(f'{path}: sequence sources belong under 2_4, 3_4, or 4_4')
sequences.append(read(path, relative.as_posix(), int(relative.parts[0][0])))
if not sequences:
raise ValueError(f'{root}: sequence library is empty or missing')
return Library(tuple(sequences))
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
import dataclasses
import fractions
import functools
import generator.fretboard
import generator.model
import generator.scales.library
import generator.scales.selection
@dataclasses.dataclass(frozen=True, order=True)
class Pitch:
letter: str
alter: fractions.Fraction
octave: int
def __post_init__(self):
if self.letter not in generator.model.NATURAL or self.alter.denominator != 1:
raise ValueError('solo pitches require spelled semitones from the permitted scales')
def number(self) -> int:
return generator.model.NATURAL[self.letter] + int(self.alter) + 12 * (self.octave + 1)
def written(self) -> dict:
return {'letter': self.letter, 'alter': generator.model.rational(self.alter), 'octave': self.octave + 1}
def label(self) -> str:
return generator.model.pitch_name(self.written()) + str(self.octave)
@dataclasses.dataclass(frozen=True)
class Note:
start: fractions.Fraction
end: fractions.Fraction
pitch: Pitch
sources: tuple[str, ...] = ()
role: str = 'continuation'
position: generator.fretboard.TabPosition | None = None
@dataclasses.dataclass(frozen=True)
class Motif:
id: str
notes: tuple[Note, ...]
duration: fractions.Fraction
context: tuple[str, ...]
origin: str = 'melody'
def contour(self) -> tuple[int, ...]:
return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:]))
@dataclasses.dataclass(frozen=True)
class TargetEvent:
earliest: fractions.Fraction
latest: fractions.Fraction
pitches: frozenset[int]
chord: str
reason: str
@dataclasses.dataclass(frozen=True)
class PhrasePlan:
id: str
start: fractions.Fraction
end: fractions.Fraction
motif: Motif
relationship: str
treatment: str
target: TargetEvent | None
register: tuple[int, int]
activity: fractions.Fraction
release: fractions.Fraction = fractions.Fraction(1, 2)
@dataclasses.dataclass(frozen=True)
class Decision:
start: fractions.Fraction
end: fractions.Fraction
source: str
treatment: str
notes: tuple[Note, ...]
@dataclasses.dataclass(frozen=True)
class Phrase:
plan: PhrasePlan
decisions: tuple[Decision, ...]
@dataclasses.dataclass(frozen=True)
class Solo:
part: str
choices: tuple[generator.scales.selection.Choice, ...]
phrases: tuple[Phrase, ...]
tempo: fractions.Fraction
end: fractions.Fraction
def notes(self) -> tuple[Note, ...]:
return tuple(n for p in self.phrases for d in p.decisions for n in d.notes)
@functools.lru_cache(maxsize=4096)
def scale_pitch(scale: generator.scales.library.Scale, index: int, octave: int = 4) -> Pitch:
"""Index an extended scale, including negative degrees and octave crossings."""
root = generator.model.parse_pitch_name(scale.root)
root_number = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter']) + 12 * (octave + 1)
turns, degree = divmod(index, 7)
name = generator.model.parse_pitch_name(scale.notes[degree])
number = root_number + 12 * turns + scale.pattern.intervals[degree]
alter = generator.model.alteration(name['alter'])
return Pitch(name['letter'], alter, int((number - generator.model.NATURAL[name['letter']] - alter) // 12 - 1))
def permitted(choices: tuple[generator.scales.selection.Choice, ...], start: fractions.Fraction, end: fractions.Fraction) -> frozenset[int]:
covered = start
pitches = frozenset(range(12))
for choice in choices:
if choice.span.end <= start or choice.span.start >= end:
continue
if choice.span.start > covered or choice.scale is None:
return frozenset()
pitches &= choice.scale.pitches
covered = max(covered, choice.span.end)
return pitches if covered >= end else frozenset()