@@ -0,0 +1 @@
|
||||
"""Build-time phrase composition; runtime consumes ordinary score events."""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user