Training Model
This commit is contained in:
@@ -18,11 +18,8 @@ import generator.engraving.types
|
||||
import generator.parts
|
||||
import generator.pico_artwork
|
||||
import generator.playback
|
||||
import generator.rhythm
|
||||
import generator.scales.library
|
||||
import generator.score
|
||||
import generator.summary
|
||||
import generator.solo.sequences
|
||||
|
||||
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
|
||||
|
||||
@@ -78,11 +75,6 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
print(f'Assets [{time.monotonic() - started:.0f}s]: {message}', flush=True)
|
||||
|
||||
report('checking source paths')
|
||||
for source in sorted(data_dir.rglob('*.xml')):
|
||||
if any(source.is_relative_to(data_dir / name) for name in ('rhythms', 'scales', 'sequences')):
|
||||
continue
|
||||
if source.parent != data_dir / 'songs':
|
||||
raise ValueError(f'{source}: MusicXML sources belong directly under data/songs')
|
||||
generator.parts.source_paths(data_dir / 'songs')
|
||||
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix='.vamp_assets_', dir=output_dir.parent) as temporary:
|
||||
|
||||
@@ -1,28 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import functools
|
||||
|
||||
import generator.model
|
||||
import generator.rhythm
|
||||
|
||||
ATTACKS_PER_BEAT = {'bass': fractions.Fraction(1, 2), 'upper': fractions.Fraction(3, 4), 'electric': fractions.Fraction(1, 2)}
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Context:
|
||||
measure: dict
|
||||
previous: dict | None
|
||||
offset: fractions.Fraction
|
||||
intervals: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
|
||||
attacks: frozenset[fractions.Fraction]
|
||||
occupied: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Plan:
|
||||
acoustic_bass: tuple[generator.rhythm.Bar, ...]
|
||||
acoustic_upper: tuple[generator.rhythm.Bar, ...]
|
||||
electric: tuple[generator.rhythm.Bar, ...]
|
||||
|
||||
def active_chord(measure: dict, onset: fractions.Fraction, previous: dict | None) -> dict | None:
|
||||
active = previous
|
||||
@@ -40,112 +20,6 @@ def carried_chords(measures: list[dict], initial: dict | None = None) -> list[di
|
||||
initial = measure['harmonies'][-1]
|
||||
return result
|
||||
|
||||
def context(measure: dict, previous: dict | None) -> Context:
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
nominal = generator.model.meter_length(measure['meter'])
|
||||
offset = nominal - length if measure.get('implicit') and length < nominal else fractions.Fraction()
|
||||
boundaries = sorted({fractions.Fraction(), length} | {generator.model.fraction(h['onset']) for h in measure['harmonies']})
|
||||
intervals = tuple((a, b) for a, b in zip(boundaries, boundaries[1:]) if b > a and active_chord(measure, a, previous))
|
||||
attacks, occupied = set(), []
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
pitches = [p for p in note['pitches'] if not p.get('cue')]
|
||||
if not pitches or note.get('cue'):
|
||||
continue
|
||||
start = generator.model.fraction(note['onset'])
|
||||
end = start + generator.model.fraction(note['duration'])
|
||||
if not all('tie_from' in p for p in pitches):
|
||||
attacks.add(start)
|
||||
occupied.append((start, end))
|
||||
return Context(measure, previous, offset, intervals, frozenset(attacks), tuple(occupied))
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def sounding_rhythm(bar, offset, intervals):
|
||||
attacks = tuple(t - offset for t in bar.attacks() if any(a <= t - offset < b for a, b in intervals))
|
||||
spans = tuple((max(event.onset - offset, a), min(event.onset + event.duration - offset, b)) for event in bar.events if event.sounding and not event.continuation and event.onset + event.duration > offset for a, b in intervals if a <= max(event.onset - offset, 0) < b)
|
||||
return attacks, spans
|
||||
|
||||
def sounding_attacks(bar: generator.rhythm.Bar, item: Context) -> tuple[fractions.Fraction, ...]:
|
||||
return sounding_rhythm(bar, item.offset, item.intervals)[0]
|
||||
|
||||
def sounding_spans(bar: generator.rhythm.Bar, item: Context) -> tuple[tuple[fractions.Fraction, fractions.Fraction], ...]:
|
||||
return sounding_rhythm(bar, item.offset, item.intervals)[1]
|
||||
|
||||
@functools.lru_cache(maxsize=16384)
|
||||
def measure_rank(bar, offset, intervals, melody_attacks, melody_spans, companions, role):
|
||||
attacks, spans = sounding_rhythm(bar, offset, intervals)
|
||||
missing_bars = int(bool(intervals) and not spans and role != 'electric')
|
||||
uncovered = sum(not any(start < b and end > a for start, end in spans) for a, b in intervals)
|
||||
duration = sum((b - a for a, b in intervals), fractions.Fraction())
|
||||
density = abs(len(attacks) - duration * ATTACKS_PER_BEAT[role])
|
||||
collisions = sum((t in melody_attacks) + (t in companions) for t in attacks)
|
||||
occupied = sum(any(a <= t < b for a, b in melody_spans) for t in attacks)
|
||||
pulse = -sum(2 if t == 0 else 1 if t.denominator == 1 else 0 for t in attacks)
|
||||
if role == 'electric':
|
||||
return missing_bars, density, collisions, occupied, uncovered, pulse
|
||||
return missing_bars, uncovered, density, pulse if role == 'bass' else collisions, collisions if role == 'bass' else pulse, occupied
|
||||
|
||||
def rank(bar: generator.rhythm.Bar, contexts: list[tuple[Context, tuple[fractions.Fraction, ...]]], role: str) -> tuple:
|
||||
result = (0, 0, 0, 0, 0, 0)
|
||||
for item, companions in contexts:
|
||||
result = add_ranks(result, measure_rank(bar, item.offset, item.intervals, item.attacks, item.occupied, companions, role))
|
||||
return result
|
||||
|
||||
def add_ranks(left: tuple, right: tuple) -> tuple:
|
||||
return tuple(a + b for a, b in zip(left, right))
|
||||
|
||||
def cycle(snippets: tuple[generator.rhythm.Snippet, ...], contexts: list[Context], role: str, companions: list[tuple[fractions.Fraction, ...]] | None = None) -> tuple[generator.rhythm.Bar, ...]:
|
||||
bars = {}
|
||||
for snippet in snippets:
|
||||
for index, bar in enumerate(snippet.bars):
|
||||
bars.setdefault(bar, f'{snippet.id}:{index}')
|
||||
phases = [[(item, companions[i] if companions else ()) for i, item in enumerate(contexts) if i % 2 == phase] for phase in range(2)]
|
||||
scores = [{bar: rank(bar, phase, role) for bar in bars} for phase in phases]
|
||||
candidates = [(add_ranks(scores[0][s.bars[0]], scores[1][s.bars[1]]), s.id, s.bars) for s in snippets if len(s.bars) == 2]
|
||||
single = [s for s in snippets if len(s.bars) == 1]
|
||||
if single:
|
||||
pair = tuple(min(single, key=lambda s: (scores[phase][s.bars[0]], s.id)) for phase in range(2))
|
||||
candidates.append((add_ranks(scores[0][pair[0].bars[0]], scores[1][pair[1].bars[0]]), '|'.join(s.id for s in pair), tuple(s.bars[0] for s in pair)))
|
||||
score, _, pair = min(candidates, key=lambda candidate: candidate[:2])
|
||||
if score[0]:
|
||||
failing = next(item.measure for i, item in enumerate(contexts) if item.intervals and not sounding_spans(pair[i % 2], item))
|
||||
raise ValueError(f'measure {failing["number"]}: no rhythm motif provides acoustic accompaniment in every harmonically active bar')
|
||||
fourth = [(item, companions[i] if companions else ()) for i, item in enumerate(contexts) if i % 4 == 3]
|
||||
second = pair[1]
|
||||
alternatives = [bar for bar in bars if sum(bar.beat(n) != second.beat(n) for n in range(int(second.length))) == 1]
|
||||
variation = min(alternatives, key=lambda bar: (rank(bar, fourth, role), bars[bar])) if alternatives and fourth else second
|
||||
if rank(variation, fourth, role)[0]:
|
||||
variation = second
|
||||
result = (pair[0], second, pair[0], variation)
|
||||
if role == 'electric' and any(item.intervals for item in contexts) and not any(sounding_spans(result[i % 4], item) for i, item in enumerate(contexts)):
|
||||
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, acoustic_companions: bool = True) -> Plan:
|
||||
previous = carried_chords(measures, initial)
|
||||
basses, uppers, responses = [], [], []
|
||||
start = 0
|
||||
while start < len(measures):
|
||||
meter = measures[start]['time_signature']
|
||||
snippets = library.for_meter(meter)
|
||||
end = start + 1
|
||||
while end < len(measures) and measures[end]['time_signature'] == meter and not measures[end].get('section_label'):
|
||||
end += 1
|
||||
segment = measures[start:end]
|
||||
for measure in segment:
|
||||
if any('meter' in change and generator.model.fraction(change['onset']) != 0 for change in measure.get('attributes', [])):
|
||||
raise ValueError(f'measure {measure["number"]}: accompaniment does not support mid-measure meter changes')
|
||||
contexts = [context(m, previous[start + i]) for i, m in enumerate(segment)]
|
||||
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') 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)))
|
||||
start = end
|
||||
return Plan(tuple(basses), tuple(uppers), tuple(responses))
|
||||
|
||||
def same_chord(left: dict | None, right: dict | None) -> bool:
|
||||
return left is not None and right is not None and all(left.get(k) == right.get(k) for k in ('root', 'kind', 'degrees', 'bass', 'inversion'))
|
||||
|
||||
@@ -185,45 +59,6 @@ def written_durations(length: fractions.Fraction):
|
||||
yield duration, written
|
||||
length -= duration
|
||||
|
||||
def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None, *, lane: str = '') -> list[dict]:
|
||||
item = context(measure, previous)
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
events = []
|
||||
for source in bar.events:
|
||||
start, end = max(fractions.Fraction(), source.onset - item.offset), min(length, source.onset + source.duration - item.offset)
|
||||
if start >= end:
|
||||
continue
|
||||
chord = active_chord(measure, start, previous) if source.sounding else None
|
||||
continuation = source.continuation and source.onset - item.offset == start
|
||||
if continuation:
|
||||
chord = None
|
||||
sounding_end = end
|
||||
for change in measure['harmonies']:
|
||||
time = generator.model.fraction(change['onset'])
|
||||
if chord and start < time < sounding_end and not same_chord(chord, active_chord(measure, time, previous)):
|
||||
sounding_end = time
|
||||
unchanged = end - start == source.duration and sounding_end == end
|
||||
duration = sounding_end - start
|
||||
written = {'type': source.written_type, 'dots': source.dots} if unchanged else generator.model.written_for(duration)
|
||||
if unchanged and source.ratio != 1:
|
||||
written['ratio'] = generator.model.rational(source.ratio)
|
||||
if not unchanged and (lane == 'electric' or not chord):
|
||||
duration, written = next(written_durations(duration))
|
||||
event = generator.model.make_event(f'{measure["id"]}_rhythm_{lane}_{len(events)}', start, duration, 'note' if chord else 'rest', written=written)
|
||||
if chord:
|
||||
event['_harmony'] = chord
|
||||
if lane:
|
||||
event.update(_guitar_profile='electric' if lane == 'electric' else 'acoustic', _guitar_lane=lane)
|
||||
if source.beams and unchanged and chord:
|
||||
event['beams'] = [{'level': level, 'type': kind} for level, kind in source.beams]
|
||||
events.append(event)
|
||||
cursor = start + duration
|
||||
for duration, written in written_durations(end - cursor):
|
||||
events.append(generator.model.make_event(f'{measure["id"]}_rhythm_{lane}_{len(events)}', cursor, duration, 'rest', written=written))
|
||||
cursor += duration
|
||||
normalize_beams(events)
|
||||
return events
|
||||
|
||||
def connect_continuations(document: dict) -> bool:
|
||||
previous = {}
|
||||
changed = False
|
||||
|
||||
@@ -25,98 +25,6 @@ def harmonic_key(measure, previous):
|
||||
if generator.model.fraction(h['onset']) > 0), measure['duration'], measure['time_signature'])
|
||||
|
||||
|
||||
def voice(source, document, measures, part, initial=None, source_offset=0):
|
||||
staff = document['staves'][0]
|
||||
result = generator.score.make_arranged_document(source, staff, staff_id=staff['id'],
|
||||
staff_name=staff['name'], role='guitar', arrangement_part_type=part, measures=measures)
|
||||
if part == 'acoustic':
|
||||
generator.acoustic.prepare(result)
|
||||
generator.acoustic_voicing.place(result)
|
||||
generator.acoustic.combine(result)
|
||||
else:
|
||||
generator.guitar.place_guitar(result)
|
||||
generator.model.validate_document(result)
|
||||
generator.fretboard.validate_document(result)
|
||||
return result['staves'][0]['measures']
|
||||
|
||||
|
||||
def install(source, document, plans):
|
||||
"""Freeze complete canonical grips; copies are never placed a second time."""
|
||||
from generator.arrange.generate import section_source
|
||||
part = document['staves'][0]['arrangement_part_type']
|
||||
if part == 'vocal': return
|
||||
authored = generator.score.source_staff(source)['measures']
|
||||
sections = form.full_arrangement_sections(authored, part)
|
||||
if 1 not in plans or not any(s.section_index == 1 for s in sections):
|
||||
return [] # A standalone section already is its own canonical part.
|
||||
body = generator.score.body_start_measure_index(authored)
|
||||
body_measures = copy.deepcopy(authored[body:])
|
||||
for m in body_measures:
|
||||
for h in m['harmonies']: h.pop('chord_optional', None)
|
||||
previous = generator.accompaniment.carried_chords(authored)
|
||||
plan = plans[1]
|
||||
|
||||
def build(measures, prior, offset=0):
|
||||
carried = generator.accompaniment.carried_chords(measures, prior)
|
||||
result = []
|
||||
for i, m in enumerate(measures):
|
||||
j = offset + i
|
||||
rhythm = (plan.acoustic_bass[j], plan.acoustic_upper[j]) if part == 'acoustic' else plan.electric[j]
|
||||
result.append(generator.parts.build_measure(m, f'canonical_{part}_{j}', 'accompaniment', part, carried[i], rhythm=rhythm))
|
||||
return result
|
||||
|
||||
canonical = voice(source, document, build(body_measures, previous[body]), part, previous[body])
|
||||
canonical_previous = generator.accompaniment.carried_chords(body_measures, previous[body])
|
||||
keys = [harmonic_key(m, p) for m, p in zip(body_measures, canonical_previous)]
|
||||
cache, exceptions = {}, []
|
||||
destination = 0
|
||||
for si, section in enumerate(sections):
|
||||
if section.role != 'accompaniment':
|
||||
destination += section.measure_count
|
||||
continue
|
||||
effective = section_source(authored, section, sections[si + 1] if si + 1 < len(sections) else None)
|
||||
carried = generator.accompaniment.carried_chords(effective)
|
||||
for j in range(section.measure_count):
|
||||
source_index = section.source_measure_index + j
|
||||
index = source_index - body
|
||||
old = document['staves'][0]['measures'][destination + j]
|
||||
key = harmonic_key(effective[source_index], carried[source_index])
|
||||
if key == keys[index]:
|
||||
template = canonical[index]
|
||||
else:
|
||||
# Changed pickups/ending harmony reuse the rhythm and only revoice the
|
||||
# affected measure. These are chart differences, never solo responses.
|
||||
identity = (index, repr(key))
|
||||
if identity not in cache:
|
||||
cache[identity] = voice(source, document,
|
||||
build([effective[source_index]], carried[source_index], index), part, carried[source_index], index)[0]
|
||||
template = cache[identity]
|
||||
exceptions.append({'measure': old['number'], 'source_measure': authored[source_index]['number'], 'reason': 'changed harmony or arrangement boundary'})
|
||||
replacement = copy.deepcopy(old)
|
||||
replacement['voices'] = copy.deepcopy(template['voices'])
|
||||
replacement['id'] = 'canonical_' + old['id']
|
||||
generator.score.reidentify([replacement], [])
|
||||
replacement['id'] = old['id']
|
||||
following = sections[si + 1] if si + 1 < len(sections) else None
|
||||
if j == section.measure_count-1 and following and following.role == 'melody' and generator.arrange.pickup.enters(following, authored):
|
||||
sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(authored[0]) if n['pitches']]
|
||||
if sounding:
|
||||
cutoff = generator.arrange.pickup.offset(old, authored[0]) + min(sounding)
|
||||
prefix = [n for n in generator.score.measure_notes(replacement) if generator.model.fraction(n['onset']) < cutoff]
|
||||
for n in prefix: generator.arrange.pickup.truncate(n, cutoff)
|
||||
# Preserve original event IDs: incoming melody ties and tuplets refer
|
||||
# to these exact pickup events, not to a freshly voiced copy.
|
||||
tail = copy.deepcopy([n for n in generator.score.measure_notes(old) if generator.model.fraction(n['onset']) >= cutoff])
|
||||
identifier = old['voices'][0]['id']
|
||||
for n in prefix: n['voice'] = identifier
|
||||
replacement['voices'] = [{'id': identifier, 'notes': prefix + tail}]
|
||||
exceptions.append({'measure': old['number'], 'source_measure': authored[source_index]['number'], 'reason': 'protected melody pickup'})
|
||||
document['staves'][0]['measures'][destination + j] = replacement
|
||||
destination += section.measure_count
|
||||
# Build-only diagnostics live outside the published musical schema.
|
||||
return exceptions
|
||||
|
||||
|
||||
def audit(source, documents):
|
||||
"""Compare music, not generated IDs, at every reused form position."""
|
||||
authored = generator.score.source_staff(source)['measures']
|
||||
|
||||
@@ -17,21 +17,18 @@ import generator.melody
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.rhythm
|
||||
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)
|
||||
def arrange_document(document: dict, part_type: str) -> dict:
|
||||
"""Canonical source-only template; solos are inserted by production roles."""
|
||||
from generator import comping, vocabulary
|
||||
document=generator.parts.prepare_document(document)
|
||||
with vocabulary.using(vocabulary.load(),part_type+'_accompaniment'):
|
||||
backing=comping.Canonical(document,part_type,'canonical') if part_type!='vocal' else None
|
||||
return _arrange_document(document,part_type,accompaniment=backing)
|
||||
|
||||
def section_source(source: list[dict], section, next_section=None) -> list[dict]:
|
||||
result = copy.deepcopy(source)
|
||||
@@ -44,27 +41,13 @@ def section_source(source: list[dict], section, next_section=None) -> list[dict]
|
||||
generator.arrange.pickup.harmonies(destination, result[0])
|
||||
return result
|
||||
|
||||
def plan_arrangement(document: dict, rhythms: generator.rhythm.Library) -> dict[int, generator.accompaniment.Plan]:
|
||||
source = generator.score.source_staff(document)['measures']
|
||||
body = generator.score.body_start_measure_index(source)
|
||||
previous = generator.accompaniment.carried_chords(source)
|
||||
canonical = generator.accompaniment.plan_section(source[body:], rhythms, initial=previous[body], electric=True)
|
||||
result = {}
|
||||
for section in generator.arrange.form.full_arrangement_sections(source, 'acoustic'):
|
||||
start = section.source_measure_index - body
|
||||
end = start + section.measure_count
|
||||
result[section.section_index] = generator.accompaniment.Plan(canonical.acoustic_bass[start:end],
|
||||
canonical.acoustic_upper[start:end], canonical.electric[start:end])
|
||||
return result
|
||||
|
||||
def _arrange_document(document: dict, part_type: str, plans: dict[int, generator.accompaniment.Plan], *, scales: generator.scales.library.Library | None = None, reports: list[str] | None = None, source_name: str = '', owned=None, accompaniment=None) -> dict:
|
||||
def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None, accompaniment=None) -> dict:
|
||||
if part_type not in generator.parts.PART_TYPES:
|
||||
raise ValueError(f'unknown arrangement part: {part_type}')
|
||||
staff = generator.score.source_staff(document)
|
||||
source = staff['measures']
|
||||
sections = generator.arrange.form.full_arrangement_sections(source, part_type)
|
||||
output, tuplets, octave_shifts, voicing_origins = [], [], [], []
|
||||
solos = []
|
||||
absolute = fractions.Fraction()
|
||||
source_starts = []
|
||||
source_time = fractions.Fraction()
|
||||
@@ -78,22 +61,16 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
|
||||
segment = []
|
||||
for si in range(section.source_measure_index, section.source_measure_index + section.measure_count):
|
||||
m = section_measures[si]
|
||||
rhythm = None
|
||||
if role == 'accompaniment' and accompaniment is None:
|
||||
plan = plans[section.section_index]
|
||||
rhythm = (plan.acoustic_bass[len(segment)], plan.acoustic_upper[len(segment)]) if part_type == 'acoustic' else plan.electric[len(segment)]
|
||||
if role == 'accompaniment' and accompaniment is not None:
|
||||
result = accompaniment(m, previous[si], si)
|
||||
result['id'] = f'measure_{len(output) + len(segment) + 1}'
|
||||
result['section_label'] = section.label if not segment else ''
|
||||
else:
|
||||
result = generator.parts.build_measure(m, f'measure_{len(output) + len(segment) + 1}', role, part_type, previous[si], section.label if not segment else '', rhythm)
|
||||
result = generator.parts.build_measure(m, f'measure_{len(output) + len(segment) + 1}', role, part_type, previous[si], section.label if not segment else '')
|
||||
segment.append(result)
|
||||
if part_type == 'electric' and section.role in ('melody', 'accompaniment'):
|
||||
voicing_origins.append(generator.electric.MeasureOrigin(source_starts[si], section.role, result))
|
||||
segment_length = sum((generator.model.fraction(m['duration']) for m in segment), fractions.Fraction())
|
||||
if section.role == 'slash':
|
||||
solos.append(generator.scales.timeline.Solo(absolute, absolute + segment_length, section.label))
|
||||
if role in ('vocal', 'melody'):
|
||||
start = source_starts[section.source_measure_index]
|
||||
for shift in staff.get('octave_shifts', []):
|
||||
@@ -125,7 +102,7 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
|
||||
generator.acoustic.prepare(result)
|
||||
generator.acoustic_voicing.place(result)
|
||||
generator.acoustic.combine(result)
|
||||
elif part_type == 'electric' and accompaniment is None and (owned is None or 'melody' in owned or 'accompaniment' in owned):
|
||||
elif part_type == 'electric' and (owned is None or 'melody' in owned):
|
||||
generator.melody.harmonize(result)
|
||||
generator.guitar.place_guitar(result)
|
||||
if generator.accompaniment.connect_continuations(result):
|
||||
@@ -134,15 +111,9 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
|
||||
# the complete performance, including its lead-in and repeated choruses.
|
||||
for number, measure in enumerate(output, 1):
|
||||
measure['number'] = str(number)
|
||||
if part_type != 'vocal' and owned is None:
|
||||
generator.arrange.accompaniment.install(document, result, plans)
|
||||
generator.fretboard.validate_document(result)
|
||||
if scales and solos:
|
||||
report = generator.scales.mapping.annotate(result, solos, scales, source_name or document['metadata']['title'])
|
||||
if reports is not None:
|
||||
reports.append(report)
|
||||
return result
|
||||
|
||||
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms=None, scales=None, sequences=None, composition_jobs=2, cache_dir=None, progress=False, timings=None) -> int:
|
||||
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, composition_jobs=2, cache_dir=None, progress=False, timings=None) -> int:
|
||||
from generator.production import generate_tree
|
||||
return generate_tree(source_dir, output_dir, jobs=composition_jobs, cache_dir=cache_dir, progress=progress,timings=timings)
|
||||
|
||||
@@ -82,7 +82,7 @@ class Canonical:
|
||||
+ .1 * guitar.compactness(v), stable_rank(self.seed, (index, start, v))))
|
||||
self.previous = selected
|
||||
patterns = []
|
||||
for figure in vocabulary.data('rhythms')['comping']:
|
||||
for figure in vocabulary.data('accompaniment')['comping']:
|
||||
events = tuple((start+F(a), min(end, start+F(a)+F(d))) for a, d in figure['attacks'] if F(a) < duration)
|
||||
# Space is available on held harmony, never an accidental empty winner.
|
||||
if not events and (not prior or not accompaniment.same_chord(chord, prior) or start):
|
||||
|
||||
@@ -15,12 +15,6 @@ class ChordSpan:
|
||||
key: dict
|
||||
chord: dict | None
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Solo:
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
label: str
|
||||
|
||||
def read(staff: dict, *, initial: dict | None = None) -> tuple[ChordSpan, ...]:
|
||||
result = []
|
||||
absolute = fractions.Fraction()
|
||||
@@ -17,13 +17,10 @@ def _read(event,args):
|
||||
# r+ and w+ can read; they must obey the same boundary as a plain read.
|
||||
if flags & os.O_WRONLY:return
|
||||
path=Path(os.fsdecode(name)).resolve()
|
||||
data,code,cache,compiled,reads=scope
|
||||
data,code,cache,reads=scope
|
||||
python_runtime=any(path.is_relative_to(Path(root).resolve()) for root in (sys.base_prefix,sys.prefix))
|
||||
allowed=path.is_relative_to(data) or path.is_relative_to(code) or python_runtime
|
||||
if cache and path.is_relative_to(cache):allowed=True
|
||||
# Only the content-addressed generic catalog directory is shared by roles.
|
||||
# Concrete musical caches remain confined to the requested role.
|
||||
if compiled and path.is_relative_to(compiled):allowed=True
|
||||
if not allowed:raise ValueError('Forbidden composition input: '+str(path))
|
||||
reads.add(str(path))
|
||||
|
||||
@@ -35,7 +32,6 @@ sys.addaudithook(_read)
|
||||
def composition(data,role,cache=None):
|
||||
reads=set()
|
||||
code=Path(__file__).resolve().parent
|
||||
token=ACTIVE.set((Path(data).resolve(),code,Path(cache).resolve()/role if cache else None,
|
||||
Path(cache).resolve()/'_resources' if cache else None,reads))
|
||||
token=ACTIVE.set((Path(data).resolve(),code,Path(cache).resolve()/role if cache else None,reads))
|
||||
try:yield reads
|
||||
finally:ACTIVE.reset(token)
|
||||
|
||||
+1
-9
@@ -7,7 +7,6 @@ import generator.accompaniment
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
import generator.rhythm
|
||||
import generator.score
|
||||
|
||||
PART_TYPES = ('acoustic', 'electric', 'vocal')
|
||||
@@ -64,17 +63,10 @@ def validate_source(document: dict) -> None:
|
||||
if any('tablature' in p or 'unpitched' in p for p in note['pitches']):
|
||||
raise ValueError(f'{measure["id"]}: authored TAB and percussion are unsupported')
|
||||
|
||||
def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '', rhythm: generator.rhythm.Bar | tuple[generator.rhythm.Bar, generator.rhythm.Bar] | None = None) -> dict:
|
||||
def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '') -> dict:
|
||||
voices = generator.score.copy_source_voices(source, strip_lyrics=role == 'melody') if role in ('vocal', 'melody') else None
|
||||
if role == 'accompaniment' and part == 'acoustic':
|
||||
voices = [{'id': lane, 'notes': generator.accompaniment.realize(source, bar, previous, lane=lane)} for lane, bar in zip(('upper', 'bass'), (rhythm[1], rhythm[0]))]
|
||||
for voice in voices:
|
||||
for note in voice['notes']:
|
||||
note.update(voice=voice['id'], stem='down' if voice['id'] == 'bass' else 'up')
|
||||
if voices:
|
||||
notes = [note for voice in voices for note in voice['notes']]
|
||||
elif rhythm is not None:
|
||||
notes = generator.accompaniment.realize(source, rhythm, previous, lane='electric')
|
||||
else:
|
||||
notes = generated_voice(source, role)
|
||||
if role in ('melody', 'vocal'):
|
||||
|
||||
+23
-21
@@ -17,10 +17,10 @@ import tempfile
|
||||
import time
|
||||
from fractions import Fraction as F
|
||||
|
||||
from generator import accompaniment, comping, fretboard, model, musicxml, parts, rhythm, score, vocabulary, resources as catalogs
|
||||
from generator import accompaniment, comping, fretboard, model, musicxml, parts, score, vocabulary
|
||||
from generator.arrange import form, generate, pickup
|
||||
from generator.solo import analysis, composer, notation, report, sequences
|
||||
from generator.scales import library as scales
|
||||
from generator.solo import analysis, composer, notation, report, learned
|
||||
from generator.solo import authored as repertoire
|
||||
|
||||
VERSION = 'independent-roles-1'
|
||||
ROLES = ('acoustic_accompaniment', 'electric_accompaniment', 'electric_melody', 'vocal_melody', 'acoustic_solo', 'electric_solo')
|
||||
@@ -78,7 +78,7 @@ def windows(document, part):
|
||||
|
||||
|
||||
def dependencies(role, resources):
|
||||
names = ('solo', 'harmony', 'lead_ins', 'rhythms') if role.endswith('_solo') else ('rhythms',) if role.endswith('_accompaniment') else ()
|
||||
names = ('harmony', 'lead_ins') if role.endswith('_solo') else ('accompaniment',) if role.endswith('_accompaniment') else ()
|
||||
return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']},
|
||||
**{name: resources[name] for name in names}}
|
||||
|
||||
@@ -88,8 +88,8 @@ def _code_key(role):
|
||||
paths = [root / n for n in ('production.py', 'model.py', 'harmony.py', 'pitch.py', 'fretboard.py', 'parts.py', 'score.py', 'vocabulary.py')]
|
||||
paths += list((root/'arrange').glob('*.py'))
|
||||
if role.endswith('_solo'):
|
||||
paths += [p for p in (root/'solo').glob('*.py') if 'audition' not in p.name and p.name != 'references.py']
|
||||
paths += list((root/'scales').glob('*.py')) + [root/'rhythm.py', root/'resources.py', root/'musicxml.py']
|
||||
paths += list((root/'solo').glob('*.py'))
|
||||
paths += [root/'harmonic_time.py', root/'musicxml.py',root/'training_io.py']
|
||||
elif role.endswith('_accompaniment'):
|
||||
paths += [root/n for n in ('comping.py', 'accompaniment.py', 'guitar.py')]
|
||||
elif role == 'electric_melody':
|
||||
@@ -98,12 +98,11 @@ def _code_key(role):
|
||||
|
||||
|
||||
def _resource_key(directory, role):
|
||||
names = ('scales', 'sequences', 'rhythms') if role.endswith('_solo') else ()
|
||||
return {name: vocabulary.fingerprint([(p.relative_to(directory/name).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted((directory/name).rglob('*.xml'))]) for name in names}
|
||||
return {'model': hashlib.sha256((directory/'models'/'solo.json').read_bytes()).hexdigest()} if role.endswith('_solo') else {}
|
||||
|
||||
@functools.lru_cache(maxsize=4)
|
||||
def _solo_libraries(directory, fingerprints, cache_dir=None):
|
||||
return catalogs.load(directory,dict(fingerprints),cache_dir)
|
||||
def _solo_model(directory, fingerprint):
|
||||
return learned.Model.load(Path(directory)/'models'/'solo.json')
|
||||
|
||||
|
||||
def _mask(document, allowed):
|
||||
@@ -153,7 +152,7 @@ def generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
with composition(data_dir,role,cache_dir) as reads:
|
||||
result=_generate_role(source,role,data_dir,settings,cache_dir)
|
||||
# Observed reads are diagnostics, never cache identity or musical seeds.
|
||||
result['trace']={**result['trace'],'input_boundary':'data, generator code, Python runtime, own role cache, content-addressed immutable catalogs'}
|
||||
result['trace']={**result['trace'],'input_boundary':'production data (including explicit authored repertoire), generator code, Python runtime, own role cache'}
|
||||
result['observed_reads']=sorted(reads)
|
||||
result['seconds']=time.monotonic()-started
|
||||
return result
|
||||
@@ -163,10 +162,10 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
if role not in ROLES: raise ValueError('Unknown generation role: '+role)
|
||||
data_dir=Path(data_dir); resources=vocabulary.load(data_dir/'composition')
|
||||
settings=settings or {}
|
||||
from generator.solo.policy import DEFAULT
|
||||
algorithm=VERSION+'/'+DEFAULT.composition_version if role.endswith('_solo') else VERSION
|
||||
registered=repertoire.lookup(source,role.removesuffix('_solo'),data_dir) if role.endswith('_solo') and not settings.get('predict',False) else None
|
||||
algorithm=VERSION+'/'+learned.VERSION if role.endswith('_solo') else VERSION
|
||||
inputs={'version':algorithm,'source':source.musical_key,'role':role,'settings':settings,
|
||||
'vocabulary':dependencies(role,resources),'catalogs':_resource_key(data_dir,role),'code':_code_key(role)}
|
||||
'vocabulary':dependencies(role,resources),'catalogs':({'authored':registered['sha256'],'id':registered['id'],'pitch_convention':registered['pitch_convention']} if registered else _resource_key(data_dir,role)),'code':_code_key(role)}
|
||||
# Source notation/lyrics also invalidate cached score representation, never the musical seed.
|
||||
key=vocabulary.fingerprint({'inputs':inputs,'notation':source.document_json})
|
||||
path=Path(cache_dir)/role/(key+'.json') if cache_dir else None
|
||||
@@ -185,7 +184,7 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
with vocabulary.using(resources,role,settings|{'choice_seed':seed}):
|
||||
if role.endswith('_solo'):
|
||||
result=generate._arrange_document(document,part,{},owned={'slash'})
|
||||
from generator.solo.arrangement import regions,annotate
|
||||
from generator.solo.arrangement import regions
|
||||
si=3 if part=='acoustic' else 4
|
||||
first,count,offset,length=regions(document)[si]
|
||||
# The template/harmony comes directly from source form, not another generated part.
|
||||
@@ -202,17 +201,20 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
template.insert(0,copy.deepcopy(result['staves'][0]['measures'][first-1]))
|
||||
notation_first-=1
|
||||
initial=accompaniment.carried_chords(result['staves'][0]['measures'])[first-1]
|
||||
scale_library,sequence_library,rhythm_library=_solo_libraries(data_dir,tuple(sorted(inputs['catalogs'].items())),cache_dir)
|
||||
solo=composer.compose(analysis.analyze(document),template,part,end,
|
||||
initial=initial,scales=scale_library,sequences=sequence_library,rhythms=rhythm_library,
|
||||
expressions_enabled=vocabulary.role().get('expressions',True),start=start)
|
||||
if registered:
|
||||
solo=repertoire.realize(registered,analysis.analyze(document),template,part,start,end,initial)
|
||||
else:
|
||||
solo_model=_solo_model(str(data_dir),inputs['catalogs']['model'])
|
||||
solo=composer.compose(analysis.analyze(document),template,part,end,
|
||||
initial=initial,learned_model=solo_model,
|
||||
expressions_enabled=vocabulary.role().get('expressions',True),start=start)
|
||||
composition_timings={k:v for k,v in solo.diagnostics.items() if k.endswith('_seconds')}
|
||||
relative=tuple(dataclasses.replace(n,start=n.start-start,end=n.end-start) for n in solo.notes())
|
||||
output=notation.measures(result['staves'][0]['measures'][notation_first:first+count],relative,'solo_'+part)
|
||||
notation.replace_region(result,notation_first,output);annotate(result,solo,offset)
|
||||
notation.replace_region(result,notation_first,output)
|
||||
details=report.structure(solo)
|
||||
details['allocation']={'start':str(start),'end':str(end),'lead_in_measures':first-notation_first}
|
||||
solo_report=report.describe(solo,scale_library,first,offset,detail=details)
|
||||
solo_report=report.describe(solo,first,offset,detail=details)
|
||||
elif role.endswith('_accompaniment'):
|
||||
canonical=comping.Canonical(document,part,seed)
|
||||
result=generate._arrange_document(document,part,{},owned={'accompaniment'},accompaniment=canonical)
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
"""Content-addressed, non-executable catalog compilation.
|
||||
|
||||
Only validated production XML is compiled here. The cache contains no song,
|
||||
role, candidate, reference, or fitted musical result. A checksum and decoder
|
||||
version protect stale/corrupt payloads; no pickle or executable serialization.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
from generator import rhythm, vocabulary
|
||||
from generator.scales import library as scales
|
||||
from generator.solo import sequences
|
||||
|
||||
VERSION = 'catalog-json-1'
|
||||
|
||||
|
||||
def identity(directory):
|
||||
directory=Path(directory)
|
||||
return {name:vocabulary.fingerprint([(p.relative_to(directory/name).as_posix(),
|
||||
hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted((directory/name).rglob('*.xml'))])
|
||||
for name in ('scales','sequences','rhythms')}
|
||||
|
||||
|
||||
def encode(libraries):
|
||||
scale,sequence,rhythms=libraries
|
||||
return {'scales':[dataclasses.asdict(p) for p in scale.patterns],
|
||||
'sequences':[dataclasses.asdict(p) for values in sequence.paths.values() for p in values],
|
||||
'rhythms':[dataclasses.asdict(p) for values in rhythms.snippets.values() for p in values]}
|
||||
|
||||
|
||||
def decode(value):
|
||||
scale=scales.Library(tuple(scales.Pattern(p['family'],p['mode'],tuple(p['intervals'])) for p in value['scales']))
|
||||
sequence=sequences.Library(tuple(sequences.Sequence(p['id'],tuple(p['steps'])) for p in value['sequences']))
|
||||
snippets=[]
|
||||
for p in value['rhythms']:
|
||||
bars=[]
|
||||
for b in p['bars']:
|
||||
events=tuple(rhythm.Event(F(e['onset']),F(e['duration']),e['sounding'],e['written_type'],e['dots'],
|
||||
tuple(tuple(v) for v in e['beams']),e['continuation'],F(e['ratio'])) for e in b['events'])
|
||||
bars.append(rhythm.Bar(events,F(b['length'])))
|
||||
snippets.append(rhythm.Snippet(p['id'],p['meter'],tuple(bars)))
|
||||
return scale,sequence,rhythm.Library(tuple(snippets))
|
||||
|
||||
|
||||
def load(directory, fingerprints=None, cache_dir=None):
|
||||
directory=Path(directory)
|
||||
fingerprints=fingerprints or identity(directory)
|
||||
# Code edits invalidate compiled importer semantics, but do not reseed music.
|
||||
root=Path(__file__).parent
|
||||
code=[root/n for n in ('resources.py','rhythm.py','musicxml.py','model.py','pitch.py','harmony.py','solo/sequences.py','scales/library.py')]
|
||||
key=vocabulary.fingerprint({'version':VERSION,'inputs':fingerprints,
|
||||
'decoder':[(p.relative_to(root).as_posix(),hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted(code)]})
|
||||
return _load(str(directory.resolve()),key,str(Path(cache_dir).resolve()) if cache_dir else '')
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4)
|
||||
def _load(directory,key,cache_dir):
|
||||
path=Path(cache_dir)/'_resources'/(key+'.json') if cache_dir else None
|
||||
if path and path.is_file() and not path.is_symlink():
|
||||
try:
|
||||
entry=json.loads(path.read_text())
|
||||
if entry['key']==key and vocabulary.fingerprint(entry['value'])==entry['digest']:
|
||||
return decode(entry['value'])
|
||||
except (ValueError,KeyError,TypeError,OverflowError):
|
||||
pass
|
||||
root=Path(directory)
|
||||
libraries=(scales.load_library(root/'scales'),sequences.load_library(root/'sequences'),rhythm.load_library(root/'rhythms'))
|
||||
if path:
|
||||
value=json.loads(json.dumps(encode(libraries),default=str))
|
||||
path.parent.mkdir(parents=True,exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile('w',dir=path.parent,delete=False) as out:
|
||||
json.dump({'key':key,'digest':vocabulary.fingerprint(value),'value':value},out,separators=(',',':'))
|
||||
temporary=out.name
|
||||
os.replace(temporary,path)
|
||||
return libraries
|
||||
@@ -1,94 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import pathlib
|
||||
import types
|
||||
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Event:
|
||||
onset: fractions.Fraction
|
||||
duration: fractions.Fraction
|
||||
sounding: bool
|
||||
written_type: str
|
||||
dots: int = 0
|
||||
beams: tuple[tuple[int, str], ...] = ()
|
||||
continuation: bool = False
|
||||
ratio: fractions.Fraction = fractions.Fraction(1)
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Bar:
|
||||
events: tuple[Event, ...]
|
||||
length: fractions.Fraction
|
||||
def attacks(self) -> tuple[fractions.Fraction, ...]:
|
||||
return tuple(e.onset for e in self.events if e.sounding and not e.continuation)
|
||||
def beat(self, number: int) -> tuple:
|
||||
return tuple((max(e.onset, number) - number, min(e.onset + e.duration, number + 1) - max(e.onset, number), e.sounding, e.continuation or e.onset < number) for e in self.events if e.onset < number + 1 and e.onset + e.duration > number)
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Snippet:
|
||||
id: str
|
||||
meter: str
|
||||
bars: tuple[Bar, ...]
|
||||
|
||||
@dataclasses.dataclass(frozen=True, init=False)
|
||||
class Library:
|
||||
snippets: types.MappingProxyType
|
||||
def __init__(self, snippets: tuple[Snippet, ...]):
|
||||
grouped = {}
|
||||
identities = set()
|
||||
for snippet in sorted(snippets, key=lambda s: s.id):
|
||||
if snippet.id in identities:
|
||||
raise ValueError(f'duplicate rhythm identity: {snippet.id}')
|
||||
identities.add(snippet.id)
|
||||
grouped.setdefault(snippet.meter, []).append(snippet)
|
||||
object.__setattr__(self, 'snippets', types.MappingProxyType({meter: tuple(values) for meter, values in grouped.items()}))
|
||||
def for_meter(self, meter: str) -> tuple[Snippet, ...]:
|
||||
if meter not in ('2/4', '3/4', '4/4', '5/4') or meter not in self.snippets:
|
||||
raise ValueError(f'accompaniment requires rhythm snippets for {meter}; supported meters are 2/4, 3/4, 4/4, and 5/4')
|
||||
return self.snippets[meter]
|
||||
|
||||
def read_snippet(path: pathlib.Path, identifier: str) -> Snippet:
|
||||
document = generator.musicxml.load_musicxml(path)
|
||||
staff = document['staves'][0]
|
||||
if len(staff['measures']) not in (1, 2):
|
||||
raise ValueError(f'{path}: rhythm snippets require one or two bars')
|
||||
meter = staff['measures'][0]['time_signature']
|
||||
bars = []
|
||||
for measure in staff['measures']:
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
if measure['time_signature'] != meter or length != generator.model.meter_length(measure['meter']):
|
||||
raise ValueError(f'{path}, measure {measure["number"]}: rhythm bars must be complete and use one meter')
|
||||
if len(measure['voices']) != 1 or any('meter' in a for a in measure.get('attributes', [])):
|
||||
raise ValueError(f'{path}: rhythm snippets require one voice without meter changes')
|
||||
events = []
|
||||
cursor = fractions.Fraction()
|
||||
for note in measure['voices'][0]['notes']:
|
||||
onset, duration = generator.model.fraction(note['onset']), generator.model.fraction(note['duration'])
|
||||
if onset != cursor or duration <= 0 or note.get('grace') or note['kind'] not in ('note', 'rest') or note.get('tremolo'):
|
||||
raise ValueError(f'{path}, measure {measure["number"]}: rhythm events must fill the bar sequentially')
|
||||
events.append(Event(onset, duration, note['kind'] == 'note', note['written']['type'], note['written'].get('dots', 0), tuple((b['level'], b['type']) for b in note.get('beams', [])), any('tie_from' in p for p in note['pitches']), generator.model.fraction(note['written'].get('ratio',generator.model.rational(1)))))
|
||||
cursor += duration
|
||||
if cursor != length:
|
||||
raise ValueError(f'{path}: rhythm events do not fill the bar')
|
||||
bars.append(Bar(tuple(events), length))
|
||||
return Snippet(identifier, meter, tuple(bars))
|
||||
|
||||
def load_library(root: pathlib.Path) -> Library:
|
||||
if not root.is_dir():
|
||||
raise ValueError(f'{root}: rhythm library not found')
|
||||
snippets = []
|
||||
for path in sorted(root.rglob('*.xml')):
|
||||
relative = path.relative_to(root)
|
||||
if len(relative.parts) != 3 or relative.parts[0] not in ('2_4', '3_4', '4_4', '5_4') or relative.parts[1] not in ('1', '2', '3'):
|
||||
raise ValueError(f'{path}: rhythm sources belong under meter/series')
|
||||
snippet = read_snippet(path, relative.as_posix())
|
||||
if snippet.meter != relative.parts[0].replace('_', '/'):
|
||||
raise ValueError(f'{path}: rhythm meter disagrees with its directory')
|
||||
snippets.append(snippet)
|
||||
if not snippets:
|
||||
raise ValueError(f'{root}: rhythm library is empty')
|
||||
return Library(tuple(snippets))
|
||||
@@ -1,92 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import pathlib
|
||||
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.pitch
|
||||
|
||||
NAMES = {
|
||||
'major': ('Ionian', 'Dorian', 'Phrygian', 'Lydian', 'Mixolydian', 'Aeolian', 'Locrian'),
|
||||
'melodic_minor': ('Melodic Minor', 'Dorian b2', 'Lydian Augmented', 'Lydian Dominant', 'Mixolydian b6', 'Locrian natural 2', 'Altered'),
|
||||
'harmonic_minor': ('Harmonic Minor', 'Locrian natural 6', 'Ionian #5', 'Dorian #4', 'Phrygian Dominant', 'Lydian #2', 'Ultra Locrian'),
|
||||
}
|
||||
ROOTS = ('C', 'Db', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B')
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Pattern:
|
||||
family: str
|
||||
mode: int
|
||||
intervals: tuple[int, ...]
|
||||
def name(self) -> str:
|
||||
return NAMES[self.family][self.mode - 1]
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Scale:
|
||||
pattern: Pattern
|
||||
root: str
|
||||
notes: tuple[str, ...]
|
||||
pitches: frozenset[int]
|
||||
def label(self) -> str:
|
||||
return self.root + ' ' + self.pattern.name()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Library:
|
||||
patterns: tuple[Pattern, ...]
|
||||
def scale(self, family: str, mode: int, root: str) -> Scale:
|
||||
pattern = next(p for p in self.patterns if (p.family, p.mode) == (family, mode))
|
||||
tonic = generator.model.parse_pitch_name(root)
|
||||
number = generator.model.NATURAL[tonic['letter']] + generator.model.alteration(tonic['alter'])
|
||||
notes = tuple(generator.model.pitch_name(generator.pitch.shift_pitch({**tonic, 'octave': 4}, degree, interval)) for degree, interval in enumerate(pattern.intervals))
|
||||
return Scale(pattern, root, notes, frozenset(int(number + n) % 12 for n in pattern.intervals))
|
||||
def collections(self) -> tuple[Scale, ...]:
|
||||
# Each mode is a rotation of its family, so only the three parents need searching.
|
||||
return tuple(self.scale(family, 1, root) for family in NAMES for root in ROOTS)
|
||||
def for_key(self, key: dict) -> Scale:
|
||||
if not key.get('tonic') or key.get('mode') not in generator.model.MODE_STEPS:
|
||||
raise ValueError('solo scale analysis requires a declared tonic and mode in the MusicXML key')
|
||||
return self.scale('major', generator.model.MODE_STEPS[key['mode']] + 1, generator.model.pitch_name(key['tonic']))
|
||||
def chord_mode(self, scale: Scale, root: str) -> tuple[int, str]:
|
||||
pitch = generator.model.parse_pitch_name(root)
|
||||
number = (generator.model.NATURAL[pitch['letter']] + generator.model.alteration(pitch['alter'])) % 12
|
||||
values = [generator.model.parse_pitch_name(note) for note in scale.notes]
|
||||
degree = next(i for i, note in enumerate(values) if (generator.model.NATURAL[note['letter']] + generator.model.alteration(note['alter'])) % 12 == number)
|
||||
mode = (scale.pattern.mode - 1 + degree) % 7 + 1
|
||||
return degree + 1, root + ' ' + NAMES[scale.pattern.family][mode - 1]
|
||||
|
||||
def read_pattern(path: pathlib.Path, family: str, mode: int) -> Pattern:
|
||||
document = generator.musicxml.load_musicxml(path)
|
||||
if len(document['staves']) != 1:
|
||||
raise ValueError(f'{path}: scale patterns require one staff')
|
||||
notes = []
|
||||
for measure in document['staves'][0]['measures']:
|
||||
if len(measure['voices']) != 1:
|
||||
raise ValueError(f'{path}: scale patterns require one melodic voice')
|
||||
for event in measure['voices'][0]['notes']:
|
||||
if event['kind'] != 'note' or len(event['pitches']) != 1 or event.get('grace'):
|
||||
raise ValueError(f'{path}: scale patterns require eight ascending notes, including the octave')
|
||||
notes.append(event['pitches'][0])
|
||||
if len(notes) != 8 or ''.join(note.get('letter', '?') for note in notes) != 'cdefgabc':
|
||||
raise ValueError(f'{path}: scale patterns must spell C through B followed by C')
|
||||
numbers = [generator.model.pitch_number(note) for note in notes]
|
||||
intervals = tuple(number - numbers[0] for number in numbers)
|
||||
if generator.model.alteration(notes[0]['alter']) or intervals[-1] != 12 or any(n.denominator != 1 for n in intervals) or any(a >= b for a, b in zip(intervals, intervals[1:])):
|
||||
raise ValueError(f'{path}: expected seven distinct ascending semitones rooted on C and octave closure')
|
||||
return Pattern(family, mode, tuple(int(n) for n in intervals[:-1]))
|
||||
|
||||
def load_library(root: pathlib.Path) -> Library:
|
||||
expected = {root / f'{family}_scale_modes' / f'{mode:04}.xml' for family in NAMES for mode in range(1, 8)}
|
||||
actual = set(root.rglob('*.xml'))
|
||||
if actual != expected:
|
||||
missing, unexpected = sorted(expected - actual), sorted(actual - expected)
|
||||
raise ValueError(f'{root}: expected all 21 scale patterns; missing: {missing}; unexpected: {unexpected}')
|
||||
patterns = tuple(read_pattern(root / f'{family}_scale_modes' / f'{mode:04}.xml', family, mode) for family in NAMES for mode in range(1, 8))
|
||||
for family in NAMES:
|
||||
modes = [pattern for pattern in patterns if pattern.family == family]
|
||||
parent = modes[0].intervals
|
||||
for index, pattern in enumerate(modes):
|
||||
rotation = tuple((parent[(index + n) % 7] - parent[index]) % 12 for n in range(7))
|
||||
if pattern.intervals != rotation:
|
||||
raise ValueError(f'{root / (family + "_scale_modes") / f"{pattern.mode:04}.xml"}: pattern is not mode {pattern.mode} of its family parent')
|
||||
return Library(patterns)
|
||||
@@ -1,32 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.scales.library
|
||||
import generator.scales.report
|
||||
import generator.scales.selection
|
||||
import generator.scales.timeline
|
||||
|
||||
def annotate(document: dict, solos: list[generator.scales.timeline.Solo], library: generator.scales.library.Library, source: str) -> str:
|
||||
staff = document['staves'][0]
|
||||
timeline = generator.scales.timeline.read(staff)
|
||||
spans, reports = [], []
|
||||
for solo in solos:
|
||||
choices = generator.scales.selection.select(timeline, solo, library)
|
||||
previous = None
|
||||
for choice in choices:
|
||||
if choice.span.chord is None:
|
||||
previous = None
|
||||
continue
|
||||
label = choice.scale.label() if choice.scale else 'No exact scale match'
|
||||
if previous is not None and previous['label'] == label and generator.model.fraction(previous['end']) == choice.span.start:
|
||||
previous['end'] = generator.model.rational(choice.span.end)
|
||||
else:
|
||||
previous = {'id': f'scale_{len(spans) + 1}', 'start': generator.model.rational(choice.span.start), 'end': generator.model.rational(choice.span.end), 'label': label, 'matched': choice.scale is not None}
|
||||
spans.append(previous)
|
||||
if choice.scale is None:
|
||||
print(f'{source}, {staff["name"]}, source measure {choice.span.source_measure}, beat {choice.span.beat}: {choice.reason} Chord: {generator.harmony.symbol(choice.span.chord)}', flush=True)
|
||||
reports.append(generator.scales.report.passage(staff['arrangement_part_type'], solo, choices, library))
|
||||
if spans:
|
||||
staff['scale_spans'] = spans
|
||||
return '\n'.join(reports)
|
||||
@@ -1,44 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
|
||||
def chord_notes(chord: dict) -> tuple[tuple[int, str], ...]:
|
||||
return tuple((degree, generator.model.pitch_name(generator.pitch.shift_pitch({**chord['root'], 'octave': 4}, degree - 1, generator.harmony.degree_semitones(degree, alter)))) for degree, alter in generator.harmony.degrees(chord).items())
|
||||
|
||||
def passage(part: str, solo, choices, library) -> str:
|
||||
lines = [f'{part.capitalize()}: {solo.label}', 'Positions use the score’s measure labels and quarter-note beats; sequential measure indices are also shown.', '']
|
||||
lines.append('Scale map:')
|
||||
previous = None
|
||||
for choice in choices:
|
||||
label = choice.scale.label() if choice.scale else 'No exact match within the permitted catalog.' if choice.span.chord else 'N.C.'
|
||||
if label != previous:
|
||||
lines.append(f' Measure {choice.span.source_measure}, beat {choice.span.beat}: {label}')
|
||||
lines.append(' ' + choice.reason)
|
||||
previous = label
|
||||
lines.extend(('', 'Chord detail:'))
|
||||
for choice in choices:
|
||||
span, scale = choice.span, choice.scale
|
||||
if span.chord is None:
|
||||
continue
|
||||
chord = span.chord
|
||||
notes = chord_notes(chord)
|
||||
required = [name for _, name in notes]
|
||||
if bass := chord.get('bass'):
|
||||
required.append(generator.model.pitch_name(bass))
|
||||
targets = [name for degree, name in notes if degree in (3, 7)] or [name for _, name in notes]
|
||||
lines.append(f'Measure {span.source_measure}, beat {span.beat}; sequential measure {span.measure}; offset {span.start} through {span.end}')
|
||||
lines.append(' Chord: ' + generator.harmony.symbol(chord) + '; required pitches: ' + ', '.join(dict.fromkeys(required)))
|
||||
if scale:
|
||||
root = generator.model.pitch_number({**chord['root'], 'octave': 4}) % 12
|
||||
degree, mode = library.chord_mode(scale, generator.model.pitch_name(chord['root'])) if root in scale.pitches else (None, None)
|
||||
lines.append(' Parent: ' + scale.label() + ' [' + ', '.join(scale.notes) + ']')
|
||||
lines.append(f' Parent degree: {degree or "omitted root"}; chord-root mode: {mode or "not assigned"}')
|
||||
lines.append(' Chord-tone targets: ' + ', '.join(targets))
|
||||
if generator.model.pitch_name(chord['root']) == scale.root:
|
||||
lines.append(' Tonic resolution target: ' + scale.root)
|
||||
else:
|
||||
lines.append(' No exact match within the permitted catalog.')
|
||||
lines.extend((' Reason: ' + choice.reason, ''))
|
||||
return '\n'.join(lines)
|
||||
@@ -1,112 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
import generator.scales.library
|
||||
import generator.scales.timeline
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Choice:
|
||||
span: generator.scales.timeline.ChordSpan
|
||||
scale: generator.scales.library.Scale | None
|
||||
reason: str
|
||||
|
||||
def root_number(chord: dict) -> fractions.Fraction:
|
||||
root = chord['root']
|
||||
return (generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])) % 12
|
||||
|
||||
def dominant(chord: dict) -> bool:
|
||||
degrees = generator.harmony.degrees(chord)
|
||||
return degrees.get(3) == 0 and degrees.get(7) == -1
|
||||
|
||||
def destination(chord: dict, following: dict | None, library: generator.scales.library.Library):
|
||||
if following and dominant(chord) and (root_number(chord) - root_number(following)) % 12 == 7:
|
||||
third = generator.harmony.degrees(following).get(3)
|
||||
if third in (-1, 0):
|
||||
return library.scale('harmonic_minor' if third == -1 else 'major', 1, generator.model.pitch_name(following['root']))
|
||||
return None
|
||||
|
||||
def preference(chord: dict, following: dict | None, after: dict | None, home: generator.scales.library.Scale, library: generator.scales.library.Library) -> tuple[generator.scales.library.Scale, str]:
|
||||
if any(root_number(item).denominator != 1 for item in (chord, following, after) if item):
|
||||
return home, 'non-semitone harmony cannot establish a destination in the permitted catalog'
|
||||
if target := destination(chord, following, library):
|
||||
return target, 'dominant approaching ' + generator.model.pitch_name(following['root']) + (' minor' if target.pattern.family == 'harmonic_minor' else ' major')
|
||||
degrees = generator.harmony.degrees(chord)
|
||||
if following and degrees.get(3) == -1 and degrees.get(7) == -1 and dominant(following) and (root_number(following) - root_number(chord)) % 12 == 5:
|
||||
tonic = generator.pitch.shift_pitch({**chord['root'], 'octave': 4}, -1, -2)
|
||||
target = destination(following, after, library) or library.scale('harmonic_minor' if degrees.get(5) == -1 else 'major', 1, generator.model.pitch_name(tonic))
|
||||
if set(generator.harmony.pitch_classes(chord)) | set(generator.harmony.pitch_classes(following)) <= target.pitches:
|
||||
return target, 'shared ' + ('minor ii-V' if target.pattern.family == 'harmonic_minor' else 'major ii-V') + ' destination ' + target.root
|
||||
if following and degrees.get(3) == -1 and degrees.get(5) == -1 and degrees.get(7) == -2 and (root_number(following) - root_number(chord)) % 12 == 1:
|
||||
return library.scale('harmonic_minor', 1, generator.model.pitch_name(following['root'])), 'leading-tone diminished resolution to ' + generator.model.pitch_name(following['root'])
|
||||
return home, 'declared tonal center ' + home.label()
|
||||
|
||||
def following_indices(timeline: tuple[generator.scales.timeline.ChordSpan, ...]) -> list[int | None]:
|
||||
result = [None] * len(timeline)
|
||||
for index in range(len(timeline) - 2, -1, -1):
|
||||
here, there = timeline[index], timeline[index + 1]
|
||||
if not there.chord or here.key != there.key:
|
||||
continue
|
||||
fields = ('root', 'kind', 'degrees', 'bass')
|
||||
same = here.chord and all(here.chord.get(k) == there.chord.get(k) for k in fields)
|
||||
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, *, retained: tuple = ()) -> tuple[Choice, ...]:
|
||||
collections = library.collections()
|
||||
following = following_indices(timeline)
|
||||
rows, layers = [], []
|
||||
for index, original in enumerate(timeline):
|
||||
if original.end <= solo.start or original.start >= solo.end:
|
||||
continue
|
||||
span = dataclasses.replace(original, start=max(original.start, solo.start), end=min(original.end, solo.end), beat=original.beat + max(0, solo.start - original.start))
|
||||
try:
|
||||
home = library.for_key(span.key)
|
||||
except ValueError as error:
|
||||
raise ValueError(f'measure {span.source_measure}, beat {span.beat}: {error}') from error
|
||||
next_index = following[index]
|
||||
after_index = following[next_index] if next_index is not None else None
|
||||
next_chord = timeline[next_index].chord if next_index is not None else None
|
||||
after = timeline[after_index].chord if after_index is not None else None
|
||||
target, reason = preference(span.chord, next_chord, after, home, library) if span.chord else (home, 'no harmony')
|
||||
# Name the home collection from the declared tonic, not its relative major.
|
||||
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 = []
|
||||
for scale in options:
|
||||
local = (int(scale is not None and scale.pitches != target.pitches), 0, len(scale.pitches ^ target.pitches) if scale else 0)
|
||||
predecessors = []
|
||||
for previous_index, (previous_scale, cost, _) in enumerate(layers[-1] if layers else [(None, (0, 0, 0), None)]):
|
||||
changed = int(scale is not None and previous_scale is not None and scale.pitches != previous_scale.pitches)
|
||||
predecessors.append((tuple(a + b for a, b in zip(cost, (local[0], changed, local[2]))), previous_index))
|
||||
cost, predecessor = min(predecessors)
|
||||
layer.append((scale, cost, predecessor))
|
||||
rows.append((span, target, reason))
|
||||
layers.append(layer)
|
||||
if not layers:
|
||||
return ()
|
||||
position = min(range(len(layers[-1])), key=lambda i: layers[-1][i][1])
|
||||
choices = []
|
||||
for (span, target, reason), layer in reversed(list(zip(rows, layers))):
|
||||
scale, _, position = layer[position]
|
||||
if span.chord and scale is None:
|
||||
reason = 'No exact match within the permitted catalog.'
|
||||
elif scale and scale.pitches != target.pitches:
|
||||
reason = 'compatible alternative to ' + target.label() + '; selected for collection continuity and closest contextual pitches (' + reason + ')'
|
||||
choices.append(Choice(span, scale, reason))
|
||||
result = []
|
||||
for choice in reversed(choices):
|
||||
if result and choice.scale and result[-1].scale and choice.scale.pitches == result[-1].scale.pitches:
|
||||
choice = dataclasses.replace(choice, scale=result[-1].scale)
|
||||
result.append(choice)
|
||||
return tuple(result)
|
||||
@@ -1,20 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import fractions
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.arrange.accompaniment
|
||||
import generator.arrange.form
|
||||
import generator.electric
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.score
|
||||
import generator.solo.analysis
|
||||
import generator.solo.composer
|
||||
import generator.solo.notation
|
||||
import generator.solo.report
|
||||
import generator.solo.types
|
||||
import generator.model
|
||||
import generator.arrange.form
|
||||
|
||||
def regions(document):
|
||||
result, first, start = {}, 0, fractions.Fraction()
|
||||
@@ -25,15 +12,3 @@ def regions(document):
|
||||
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
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Explicit registered repertoire. Authored playback is not model prediction.
|
||||
|
||||
Matching is by canonical source music and role, never title or filename. This
|
||||
path intentionally reads the registered solo, following the user's exact-reuse
|
||||
policy. Unknown song/role pairs go to the learned composer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fractions import Fraction as F
|
||||
from generator import musicxml,parts,training_io,harmonic_time
|
||||
from generator.solo import analysis,types,placement,profiles
|
||||
|
||||
|
||||
def lookup(source,role,data_dir):
|
||||
from generator.production import SourceAnalysis
|
||||
root=Path(data_dir);manifest=root/'training/manifest.json'
|
||||
if not manifest.exists():return None
|
||||
value=json.loads(manifest.read_text())
|
||||
if value.get('version')!=1:raise ValueError('Unsupported training manifest')
|
||||
matches=[]
|
||||
for entry in value['examples']:
|
||||
if entry['slot']!=(1 if role=='acoustic' else 2):continue
|
||||
path=(manifest.parent/entry['source']).resolve()
|
||||
if not path.is_relative_to((root/'songs').resolve()):raise ValueError('Registered sources must be in data/songs')
|
||||
doc=parts.prepare_document(musicxml.load_musicxml(path))
|
||||
if SourceAnalysis.read(doc).musical_key==source.musical_key:
|
||||
target=(manifest.parent/entry['solo']).resolve()
|
||||
if not target.is_relative_to(manifest.parent.resolve()):raise ValueError('Registered solo must be inside data/training')
|
||||
evidence=training_io.read(target,role,pitch_convention=entry['pitch_convention'])
|
||||
training_io.validate_harmony(doc,evidence,entry['id'])
|
||||
matches.append({**entry,'path':target,'sha256':hashlib.sha256(target.read_bytes()).hexdigest()})
|
||||
if len(matches)>1:raise ValueError('Multiple authored solos registered for the same source music and role')
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def realize(entry,source_analysis,measures,part,start,end,initial=None):
|
||||
evidence=training_io.read(entry['path'],part,pitch_convention=entry['pitch_convention'])
|
||||
if evidence['discrepancies']:raise ValueError(entry['id']+': invalid authored timing/ties: '+str(evidence['discrepancies']))
|
||||
expected=[(a,b-a) for a,b in zip(source_analysis.boundaries,source_analysis.boundaries[1:])]
|
||||
if [(F(m['start']),F(m['duration'])) for m in evidence['measures']]!=expected:raise ValueError(entry['id']+': source/solo forms differ; register and retrain the correct pair')
|
||||
notes=[]
|
||||
for i,event in enumerate(evidence['sounding_events']):
|
||||
a,b=F(event['start'])-source_analysis.body_offset,F(event['end'])-source_analysis.body_offset
|
||||
if a<start or b>end:raise ValueError(f'{entry["id"]}: authored event {a}–{b} conflicts with arrangement window {start}–{end}; no clipping applied')
|
||||
p=event['pitch'];number=F(event['sounding_midi'])
|
||||
if number.denominator!=1:raise ValueError('Microtonal guitar solo is unsupported')
|
||||
pitch=types.Pitch(p['step'].lower(),F(p['alter']),p['octave']+(int(number)-int(F(event['encoded_midi'])))//12)
|
||||
notes.append(types.Note(a,b,pitch,('authored:'+entry['id'],),'authored',event_id=f'authored_{i}'))
|
||||
profile=profiles.for_part(part,source_analysis)
|
||||
illegal=next((n for n in notes if not placement.positions(n.pitch.number(),profile)),None)
|
||||
if illegal:raise ValueError(f'{entry["id"]}: {illegal.pitch.label()} at {illegal.start} is outside strings 1–6/frets 1–12; authored solo left unchanged')
|
||||
placed=placement.place(tuple(notes),source_analysis.tempo,profile=profile)
|
||||
if placed is None:raise ValueError(entry['id']+': no legal complete guitar placement')
|
||||
spans=tuple(dataclasses.replace(s,start=s.start+start,end=s.end+start) for s in harmonic_time.read({'measures':measures},initial=initial))
|
||||
phrase=types.Phrase('authored',start,end,placed[1],'authored','authored','handoff',theme=entry['id'])
|
||||
return types.Solo(part,spans,(phrase,),source_analysis.tempo,end,
|
||||
{'construction':'authored_repertoire','authored_id':entry['id'],'authored_sha256':entry['sha256'],
|
||||
'pitch_convention':entry['pitch_convention'],'exact_sounding_events':True,
|
||||
'note':'Authored music is preserved, not reclassified as generated harmonic exceptions.',
|
||||
'annotations':evidence['annotations']},profile,start)
|
||||
+45
-38
@@ -1,50 +1,57 @@
|
||||
"""The single production entry point for independent phrase composition."""
|
||||
from fractions import Fraction as F
|
||||
import time
|
||||
"""The sole production solo composer: frozen learned model plus music validity."""
|
||||
import dataclasses
|
||||
from generator import harmony as chords, vocabulary
|
||||
from generator.scales import timeline, selection
|
||||
from generator.solo import types, profiles, harmonic, construction, placement, policy
|
||||
import time
|
||||
from fractions import Fraction as F
|
||||
from pathlib import Path
|
||||
from generator import harmonic_time, vocabulary
|
||||
from generator.solo import types, profiles, harmonic, placement, learned, decoder, groups
|
||||
|
||||
def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,expressions_enabled=True,start=F()):
|
||||
|
||||
def compose(analysis,measures,part,end,*,learned_model=None,initial=None,expressions_enabled=True,start=F()):
|
||||
began=time.monotonic()
|
||||
choices=selection.select(timeline.read({'measures':measures},initial=initial),timeline.Solo(F(),end-start,part.capitalize()+' Solo'),scales)
|
||||
choices=tuple(dataclasses.replace(c,span=dataclasses.replace(c.span,start=c.span.start+start,end=c.span.end+start)) for c in choices)
|
||||
for c in choices:
|
||||
if c.span.chord and c.scale is None:raise ValueError(f'{part} solo, source measure {c.span.source_measure}, beat {c.span.beat}, {chords.symbol(c.span.chord)}: No exact match within the permitted catalog')
|
||||
profile=profiles.for_part(part,analysis);harmony=harmonic.Harmony(choices,profile)
|
||||
source=tuple((n.start,n.end,n.pitch) for n in analysis.melody)
|
||||
seed=vocabulary.role().get('choice_seed') or policy.stable_rank(part,(source,tuple((c.span.start,c.span.end,c.span.chord) for c in choices),vocabulary.role(part+'_solo'),policy.DEFAULT.composition_version))
|
||||
builder=construction.Builder(analysis,measures,harmony,sequences,rhythms,end,seed,expressions_enabled,start=start)
|
||||
phrases=builder.compose()
|
||||
# Resolve hand-position transitions over the selected whole melody. This
|
||||
# cannot reselect pitches, rhythm or another independently generated part.
|
||||
raw=tuple(n for p in phrases for n in p.notes)
|
||||
placed=placement.place(raw,analysis.tempo,profile=profile) if raw else (0,())
|
||||
if placed is None:raise construction.SearchExhausted('selected melody has no complete legal guitar path')
|
||||
by_id={n.event_id:n for n in placed[1]}
|
||||
phrases=tuple(dataclasses.replace(p,notes=tuple(by_id[n.event_id] for n in p.notes)) for p in phrases)
|
||||
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'source_melodic_phrases','version':policy.DEFAULT.composition_version,
|
||||
'construction_seconds':time.monotonic()-began,**builder.timings,'operation_coverage':{k:dict(v) for k,v in builder.coverage.items()},
|
||||
'whole_stream_diagnostic':dataclasses.asdict(builder.winner_evaluation),
|
||||
'opening_candidates':builder.opening_candidates,
|
||||
'frontiers':builder.frontiers,'completed_candidates':[{'rank':i+1,'evaluation':{'selection_total': str(s.total / (end-start)), 'phrases': [dataclasses.asdict(p.evaluation) for p in s.phrases]},'events':[(str(n.start),str(n.end),n.pitch.number()) for n in s.notes]} for i,s in enumerate(builder.completed)],'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile,start)
|
||||
validate(result)
|
||||
return result
|
||||
spans=tuple(dataclasses.replace(s,start=s.start+start,end=s.end+start) for s in harmonic_time.read({'measures':measures},initial=initial))
|
||||
profile=profiles.for_part(part,analysis);harmony=harmonic.Harmony(spans,profile)
|
||||
model=learned_model or learned.Model.load(Path(__file__).resolve().parents[2]/'data/models/solo.json')
|
||||
settings=vocabulary.role(part+'_solo')
|
||||
builder=decoder.Decoder(analysis,harmony,model,part,start,end,expressions_enabled=expressions_enabled,
|
||||
attempts=settings.get('attempts',4),seed=settings.get('choice_seed',''))
|
||||
selected=builder.compose();decoded=time.monotonic()
|
||||
placed=placement.place(selected.notes,analysis.tempo,profile=profile)
|
||||
if placed is None:raise decoder.SearchExhausted('learned solo has no complete legal guitar path')
|
||||
# Phrase labels describe heard rests/arrivals. They do not impose a new
|
||||
# endpoint or reset the learned history at barlines or arbitrary windows.
|
||||
notes=placed[1];ends=[n.end for i,n in enumerate(notes[:-1]) if notes[i+1].start-n.end>=1]
|
||||
boundaries=[start,*[t for t in ends if start<t<end],end];phrases=[]
|
||||
home=tuple(n for n in notes if n.start<(ends[0] if ends else min(end,start+8)))
|
||||
for i,(a,b) in enumerate(zip(boundaries,boundaries[1:])):
|
||||
ns=tuple(n for n in notes if a<=n.start<b)
|
||||
cs=tuple(c for c in selected.contracts if a<=c.target.start<b)
|
||||
ending='handoff' if b==end else 'settle'
|
||||
phrases.append(types.Phrase(f'learned_{i+1}',a,b,ns,'learned_sequence','learned',ending,
|
||||
expressions=cs,groups=groups.describe(tuple((n.start,n.end) for n in ns)),
|
||||
theme='introduced' if i==0 else 'conditional_history',identity=home if i==0 else ()))
|
||||
result=types.Solo(part,spans,tuple(phrases),analysis.tempo,end,
|
||||
{'construction':'learned_joint_events','version':learned.VERSION,'decode_seconds':decoded-began,
|
||||
'placement_seconds':time.monotonic()-decoded,'selection_total':selected.cost,
|
||||
'objective':'seeded sampling from validated learned joint-event likelihoods; score is diagnostic, not a complete-solo ranking',
|
||||
'budget':{'attempts':builder.attempts},
|
||||
'counts':dict(builder.counts),'frontiers':builder.trace,'completed_candidates':[
|
||||
{'rank':i+1,'cost':s.cost,'events':[(str(a),str(b),p) for a,b,p in s.events]} for i,s in enumerate(builder.completed)],
|
||||
'expressions_enabled':expressions_enabled},profile,start)
|
||||
validate(result);return result
|
||||
|
||||
|
||||
def validate(solo):
|
||||
previous=None
|
||||
for note in solo.notes():
|
||||
if note.start<solo.start or note.end>solo.end or note.end<=note.start or previous and previous.end>note.start:raise ValueError('overlapping/out-of-bounds sounding event')
|
||||
if note.position not in placement.positions(note.pitch.number(),solo.profile):raise ValueError('invalid guitar placement')
|
||||
previous=note
|
||||
expressions=tuple(c for p in solo.phrases for c in p.expressions)
|
||||
checked=harmonic.Harmony(solo.choices).evaluate(solo.notes(),until=solo.end,complete=True,expressions=expressions)
|
||||
for n in solo.notes():
|
||||
if n.start<solo.start or n.end>solo.end or n.end<=n.start or previous and previous.end>n.start:raise ValueError('overlapping/out-of-bounds sounding event')
|
||||
if n.position not in placement.positions(n.pitch.number(),solo.profile):raise ValueError('invalid guitar placement')
|
||||
previous=n
|
||||
checked=harmonic.Harmony(solo.harmony).evaluate(solo.notes(),until=solo.end,complete=True,
|
||||
expressions=tuple(c for p in solo.phrases for c in p.expressions))
|
||||
if not checked.valid:raise ValueError(checked.error)
|
||||
cursor=solo.start
|
||||
for p in solo.phrases:
|
||||
if p.start!=cursor or p.end<=p.start:raise ValueError('phrase coverage gap or overlap')
|
||||
cursor=p.end
|
||||
valid,error=harmonic.anchors_valid(p.arrivals or ((p.destination,) if p.destination else ()),p.notes,p.end,complete=True,closure='closed' if p.ending in ('complete','handoff') else 'open')
|
||||
if not valid:raise ValueError(error)
|
||||
if cursor!=solo.end:raise ValueError('incomplete solo duration')
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
"""Bounded comparison of completed source-related phrases."""
|
||||
import collections
|
||||
import dataclasses
|
||||
import fractions
|
||||
import time
|
||||
import generator.solo.phrases
|
||||
import generator.solo.selection
|
||||
import generator.solo.sequences
|
||||
import generator.solo.policy
|
||||
import generator.vocabulary
|
||||
|
||||
|
||||
class SearchExhausted(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class State:
|
||||
phrases: tuple = ()
|
||||
home: tuple = ()
|
||||
total: fractions.Fraction = fractions.Fraction()
|
||||
|
||||
@property
|
||||
def notes(self):
|
||||
return tuple(n for p in self.phrases for n in p.notes)
|
||||
|
||||
|
||||
class Builder:
|
||||
def __init__(self,analysis,measures,harmony,sequences,rhythms,end,seed,enabled=True,*,start=fractions.Fraction()):
|
||||
self.analysis,self.harmony = analysis,harmony
|
||||
self.start,self.end,self.seed = start,end,seed
|
||||
self.phrases = generator.solo.phrases.Phrases(analysis,harmony,sequences,rhythms,start,end,seed,enabled)
|
||||
self.rules = generator.vocabulary.data('solo')['search']
|
||||
self.timings = {}; self.frontiers = []; self.coverage = collections.defaultdict(collections.Counter); self.decisions = []
|
||||
self.failures = self.phrases.rejections
|
||||
self.opening_candidates = []
|
||||
|
||||
def candidates(self,start,end,state):
|
||||
incoming = state.notes[-1] if state.notes else None
|
||||
values = {}
|
||||
for variant in range(self.rules['alternatives']):
|
||||
value = self.phrases.make(start,end,incoming,state.home,state.phrases,variant)
|
||||
if value is not None:
|
||||
phrase,introduced = value
|
||||
self.coverage[phrase.purpose]['valid'] += 1
|
||||
key = tuple((n.start,n.end,n.pitch.number()) for n in phrase.notes)
|
||||
values.setdefault(key,value)
|
||||
for phrase,_ in values.values():
|
||||
self.coverage[phrase.purpose]['distinct'] += 1
|
||||
return tuple(values.values())
|
||||
|
||||
def rank(self,state):
|
||||
return state.total,generator.solo.policy.stable_rank(self.seed,tuple((n.start,n.end,n.pitch.number()) for n in state.notes))
|
||||
|
||||
@staticmethod
|
||||
def character(state):
|
||||
phrase = state.phrases[-1]
|
||||
ns = phrase.notes
|
||||
# Diversity describes the realized phrase, not its constructor label.
|
||||
return (tuple((n.start-phrase.start,n.end-n.start) for n in ns[:4]),
|
||||
tuple((b.pitch.number()>a.pitch.number())-(b.pitch.number()<a.pitch.number()) for a,b in zip(ns[:3],ns[1:4])),
|
||||
tuple((a.earliest%1,a.effect) for a in phrase.arrivals))
|
||||
|
||||
def compose(self):
|
||||
began = time.monotonic(); self.frontiers = []; self.decisions = []
|
||||
openings = []
|
||||
states = (State(),); start = self.start
|
||||
while start<self.end:
|
||||
end = self.phrases.horizon(start)
|
||||
successors = []
|
||||
for state in states:
|
||||
for phrase,introduced in self.candidates(start,end,state):
|
||||
total = state.total+phrase.evaluation.total*(end-start)
|
||||
successors.append(State((*state.phrases,phrase),state.home or (introduced if start>=0 and len(introduced)>=3 else ()),total))
|
||||
if not successors:
|
||||
raise SearchExhausted(f'No valid complete phrase at {start}–{end}; failures: {dict(self.failures)}')
|
||||
if start>=0 and not openings:
|
||||
openings = [s.phrases[-1] for s in successors]
|
||||
successors.sort(key=self.rank)
|
||||
states = tuple(generator.solo.sequences.diverse(successors,self.rank,
|
||||
self.character,self.rules['histories']))
|
||||
for state in states:
|
||||
self.coverage[state.phrases[-1].purpose]['retained_histories'] += 1
|
||||
self.frontiers.append({'start':str(start),'end':str(end),'completed':len(successors),'retained':len(states),'best':str(min(s.total for s in states))})
|
||||
start = end
|
||||
self.completed = tuple(sorted(states,key=self.rank))
|
||||
winner = self.completed[0]
|
||||
selected_opening = next((p for p in winner.phrases if p.start>=0),None)
|
||||
if selected_opening is not None:
|
||||
remaining = [p for p in openings if p.notes!=selected_opening.notes]
|
||||
examples = (selected_opening,*generator.solo.sequences.diverse(remaining,
|
||||
lambda p:(p.evaluation.total,p.id),lambda p:self.character(State((p,))),3))
|
||||
self.opening_candidates = [{'selected_in_final_solo':p is selected_opening,'start':str(p.start),'end':str(p.end),
|
||||
'purpose':p.purpose,'ending':p.ending,'evaluation':dataclasses.asdict(p.evaluation),
|
||||
'events':[(str(n.start),str(n.end),n.pitch.number()) for n in p.notes]} for p in examples]
|
||||
self.timings['phrase_search_seconds'] = time.monotonic()-began
|
||||
self.winner_evaluation = generator.solo.selection.evaluate(winner.notes,self.start,self.end,self.harmony,
|
||||
home=winner.home,contracts=tuple(c for p in winner.phrases for c in p.expressions),center=self.phrases.center)
|
||||
for k,v in self.phrases.proposals.items():
|
||||
self.coverage[k]['proposed'] = v
|
||||
for p in winner.phrases:
|
||||
self.coverage[p.purpose]['selected'] += 1
|
||||
self.decisions = [{'start':str(p.start),'end':str(p.end),'purpose':p.purpose,'ending':p.ending,'evaluation':dataclasses.asdict(p.evaluation)} for p in winner.phrases]
|
||||
return winner.phrases
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Constrained, deterministically seeded autoregressive musical sampling.
|
||||
|
||||
The pooled model proposes joint note/rest, duration and pitch events. Complete
|
||||
expression actions include their real resolution. Guitar and chord/function
|
||||
validity precede sampling. No target access, hand-authored melody scorer or
|
||||
legacy fallback. A bounded restart handles an exhausted/entirely silent sample.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import collections
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import random
|
||||
import math
|
||||
from fractions import Fraction as F
|
||||
from generator import model as music, vocabulary
|
||||
from generator.solo import expressions, groups, learned, types
|
||||
|
||||
|
||||
class SearchExhausted(ValueError):pass
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class State:
|
||||
events:tuple=()
|
||||
notes:tuple=()
|
||||
contracts:tuple=()
|
||||
history:tuple=()
|
||||
previous:int|None=None
|
||||
cost:float=0.
|
||||
|
||||
|
||||
def event_key(events):return tuple((a,b,-1 if p is None else p) for a,b,p in events)
|
||||
|
||||
|
||||
class Decoder:
|
||||
def __init__(self,analysis,harmony,model,role,start,end,*,expressions_enabled=True,attempts=4,seed=''):
|
||||
if type(attempts) is not int or not 1<=attempts<=16:raise ValueError('attempts must be between 1 and 16')
|
||||
self.analysis,self.harmony,self.model,self.role=analysis,harmony,model,role
|
||||
self.start,self.end=start,end
|
||||
self.attempts=attempts
|
||||
source=tuple((str(n.start),str(n.end),n.pitch.number()) for n in analysis.melody)
|
||||
self.seed=seed or hashlib.sha256(repr((source,role,learned.VERSION)).encode()).hexdigest()
|
||||
self.expressions_enabled=expressions_enabled
|
||||
self.low,self.high=harmony.profile.register
|
||||
limits=vocabulary.data('roles')['difficulty']
|
||||
self.durations=tuple(d for d in model.durations if 0<d<=4 and d.denominator in (1,2,4) and
|
||||
(d.denominator!=4 or analysis.tempo<=limits['sixteenth_max_bpm']))
|
||||
self.allow_triplets=analysis.tempo<=limits['triplet_max_bpm']
|
||||
self._actions={};self._context={};self._pitches={};self.trace=[];self.counts=collections.Counter()
|
||||
|
||||
def context(self,time):
|
||||
if time not in self._context:
|
||||
harmonic,root,future=learned.chord_context(self.harmony,time)
|
||||
self._context[time]=(harmonic,root,learned.phase_at(self.analysis,time),learned.source_feature(self.analysis,time,root),future)
|
||||
return self._context[time]
|
||||
|
||||
def pitch(self,number,time):
|
||||
span=self.harmony.at(time)
|
||||
identity=(number,span.start if span else None)
|
||||
if identity not in self._pitches:self._pitches[identity]=self._spell(number,time)
|
||||
return self._pitches[identity]
|
||||
|
||||
def _spell(self,number,time):
|
||||
span=self.harmony.at(time)
|
||||
if span and span.chord!='N.C.':
|
||||
chord=expressions.parsed_chord(span.chord)
|
||||
from generator import harmony as chords
|
||||
for degree,alter in chords.degrees(chord).items():
|
||||
p=expressions.degree_pitch(chord,degree,alter,number)
|
||||
if p.number()==number:return p
|
||||
for letter,alter in zip(music.LETTERS,span.key['alterations']):
|
||||
accidental=music.fraction(alter)
|
||||
if (music.NATURAL[letter]+accidental)%12==number%12:
|
||||
return types.Pitch(letter,accidental,int((number-music.NATURAL[letter]-accidental)//12)-1)
|
||||
names=(('C',0),('D',-1),('D',0),('E',-1),('E',0),('F',0),('F',1),('G',0),('A',-1),('A',0),('B',-1),('B',0))
|
||||
letter,alter=names[number%12]
|
||||
return types.Pitch(letter.lower(),F(alter),number//12-1)
|
||||
|
||||
def note(self,a,b,p,role='learned'):
|
||||
pitch=p if isinstance(p,types.Pitch) else self.pitch(p,a)
|
||||
return types.Note(a,b,pitch,role=role,event_id=f'{a}:{b}:{pitch.number()}')
|
||||
|
||||
def permission(self,n):
|
||||
"""Find justified color/anticipation support, never authorize by label."""
|
||||
# Source spans are contiguous. These are precisely the ordinary-event
|
||||
# branches of Harmony.evaluate, without rebuilding an empty contract map.
|
||||
if self.harmony.at(n.start) and n.end<=self.harmony.spans[-1].end:
|
||||
if n.pitch.number()%12 in self.harmony.members(n.start,n.end):return ()
|
||||
if n.end-n.start<2 and n.pitch.number()%12 in self.harmony.permitted(n.start,n.end):return ()
|
||||
if not self.expressions_enabled:return None
|
||||
span=self.harmony.at(n.start)
|
||||
if not span or span.chord=='N.C.':return None
|
||||
exposed=tuple(region for region,_,_ in self.harmony.segments(n)
|
||||
if n.pitch.number()%12 not in region.roles.basic|region.roles.specified)
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
if rule.get('resolve') or n.end-n.start>F(rule['max_beats']):continue
|
||||
# A held chord member can become a color only after a chord change.
|
||||
# Test the regions needing permission, not just the attack's harmony.
|
||||
if any(region.chord=='N.C.' or not expressions.eligible_color(rule,region,self.harmony) or
|
||||
expressions.degree_pitch(expressions.parsed_chord(region.chord),rule['degree'],rule['alter'],n.pitch.number()).number()!=n.pitch.number()
|
||||
for region in exposed):continue
|
||||
c=types.ExpressionContract(n.event_id+':color','color',rule['id'],(n,),n,span.chord)
|
||||
if self.harmony.evaluate((n,),complete=True,expressions=(c,)).valid:return (c,)
|
||||
for arriving in self.harmony.spans:
|
||||
if n.start<arriving.start<n.end and arriving.start-n.start<=F(vocabulary.data('harmony')['anticipation_max']):
|
||||
c=types.ExpressionContract(n.event_id+':anticipation','anticipation','early_chord_tone',(n,),n,arriving.chord,arriving.start)
|
||||
if self.harmony.evaluate((n,),complete=True,expressions=(c,)).valid:return (c,)
|
||||
return None
|
||||
|
||||
def actions(self,time):
|
||||
if time in self._actions:return self._actions[time]
|
||||
result=[]
|
||||
def add(events,notes,contracts=()):result.append((events,notes,contracts))
|
||||
for duration in self.durations:
|
||||
end=time+duration
|
||||
if end>self.end:continue
|
||||
add(((time,end,None),),())
|
||||
for number in range(self.low,self.high+1):
|
||||
n=self.note(time,end,number);support=self.permission(n)
|
||||
if support is not None:add(((time,end,number),),(n,),support)
|
||||
if self.expressions_enabled:
|
||||
limits=vocabulary.data('roles')['difficulty']
|
||||
for rule in vocabulary.data('lead_ins')['patterns']:
|
||||
difficulty=rule.get('difficulty')
|
||||
if difficulty and self.analysis.tempo>limits[difficulty+'_max_bpm']:continue
|
||||
# Connections require a concrete preceding note, checked at expansion.
|
||||
durations=tuple(map(F,rule['durations']));gaps=tuple(map(F,rule.get('internal_gaps',['0']*(len(durations)-1))))
|
||||
target_time=time+sum(durations,F())+sum(gaps,F())+F(rule['gap'])
|
||||
span=self.harmony.at(target_time)
|
||||
if not span or span.chord=='N.C.':continue
|
||||
holds=(F(1,3),) if difficulty=='triplet' else (F(1,2),F(1),F(2))
|
||||
for duration in holds:
|
||||
end=target_time+duration
|
||||
if end>self.end:continue
|
||||
for number in range(self.low,self.high+1):
|
||||
if number%12 not in self.harmony.members(target_time,end):continue
|
||||
target=self.note(target_time,end,number,'destination');cursor=time;notes=[];events=[]
|
||||
for i,(delta,d) in enumerate(zip(rule['semitones'],durations)):
|
||||
n=self.note(cursor,cursor+d,expressions.chromatic(target.pitch,delta),rule['kind'])
|
||||
notes.append(n);events.append((n.start,n.end,n.pitch.number()));cursor=n.end
|
||||
gap=gaps[i] if i<len(gaps) else F(rule['gap'])
|
||||
if gap:events.append((cursor,cursor+gap,None));cursor+=gap
|
||||
if any(not self.low<=n.pitch.number()<=self.high for n in notes):continue
|
||||
if not groups.valid(tuple((a,b) for a,b,p in (*events,(target_time,end,number)))):continue
|
||||
contract=types.ExpressionContract(f'{time}:{number}:{rule["id"]}',rule['kind'],rule['id'],tuple(notes),target,span.chord)
|
||||
ns=(*notes,target)
|
||||
if rule['kind']=='connection':
|
||||
# Validation waits for the actual incoming event.
|
||||
add((*events,(target_time,end,number)),ns,(contract,))
|
||||
elif self.harmony.evaluate(ns,complete=True,expressions=(contract,)).valid:
|
||||
add((*events,(target_time,end,number)),ns,(contract,))
|
||||
# Complete ordinary triplet groups; do not leave a third-beat residual.
|
||||
if self.allow_triplets and time+1<=self.end:
|
||||
for number in range(self.low,self.high+1):
|
||||
for direction in (-1,0,1):
|
||||
numbers=[number];cursor=time
|
||||
for _ in range(2):
|
||||
nxt=next((p for p in range(numbers[-1]+direction,numbers[-1]+direction*4,direction or 1)
|
||||
if self.low<=p<=self.high and p%12 in self.harmony.permitted(cursor+F(1,3),cursor+F(2,3))),None) if direction else number
|
||||
if nxt is None:break
|
||||
numbers.append(nxt);cursor+=F(1,3)
|
||||
if len(numbers)!=3:continue
|
||||
ns=tuple(self.note(time+i*F(1,3),time+(i+1)*F(1,3),p) for i,p in enumerate(numbers))
|
||||
if self.harmony.evaluate(ns,complete=True).valid:add(tuple((n.start,n.end,n.pitch.number()) for n in ns),ns)
|
||||
self._actions[time]=tuple(result);self.counts['valid_structures']+=len(result)
|
||||
return self._actions[time]
|
||||
|
||||
def extend(self,state,action):
|
||||
events,notes,contracts=action
|
||||
# Training tokenization merges contiguous silence. Never manufacture a
|
||||
# rest-to-rest transition absent from that event representation.
|
||||
if state.events and state.events[-1][2] is None and events[0][2] is None:return None
|
||||
if notes and state.previous is not None and abs(notes[0].pitch.number()-state.previous)>12:return None
|
||||
if contracts and contracts[0].kind=='connection':
|
||||
if not state.notes:return None
|
||||
c=dataclasses.replace(contracts[0],preparation=state.notes[-1]);contracts=(c,)
|
||||
_,error=expressions.validate((state.notes[-1],*notes),contracts,self.harmony)
|
||||
if error:return None
|
||||
history=state.history;previous=state.previous;cost=state.cost
|
||||
for a,b,p in events:
|
||||
harmonic,root,phase,source,_=self.context(a)
|
||||
action=learned.token(b-a,p,previous,root)
|
||||
context=learned.contexts(harmonic,phase,source,history)
|
||||
cost+=self.model.cost(action,context)
|
||||
if p is not None:
|
||||
if previous is None:cost+=self.model.register_cost(p)
|
||||
previous=p
|
||||
history=(*history,action)[-4:]
|
||||
return State((*state.events,*events),(*state.notes,*notes),(*state.contracts,*contracts),history,previous,cost)
|
||||
|
||||
def compose(self):
|
||||
for attempt in range(self.attempts):
|
||||
rng=random.Random(self.seed+':'+str(attempt));state=State();time=self.start
|
||||
trace=[]
|
||||
while time<self.end:
|
||||
actions=self.actions(time)
|
||||
options=[s for a in actions if (s:=self.extend(state,a)) is not None]
|
||||
# Different operator names cannot multiply the probability of identical
|
||||
# sounded music. Prefer plain permission when no exception is needed.
|
||||
unique={}
|
||||
for s in options:
|
||||
signature=event_key(s.events[len(state.events):])
|
||||
if signature not in unique or len(s.contracts)<len(unique[signature].contracts):unique[signature]=s
|
||||
options=sorted(unique.values(),key=lambda s:event_key(s.events[len(state.events):]))
|
||||
self.counts['scored_continuations']+=len(options)
|
||||
if not options:break
|
||||
low=min(s.cost-state.cost for s in options)
|
||||
weights=[math.exp(-(s.cost-state.cost-low)) for s in options]
|
||||
draw=rng.random()*sum(weights);selected=options[-1]
|
||||
for s,w in zip(options,weights):
|
||||
draw-=w
|
||||
if draw<=0:selected=s;break
|
||||
trace.append({'time':str(time),'valid_joint_actions':len(options),
|
||||
'selected_events':[(str(a),str(b),p) for a,b,p in selected.events[len(state.events):]],
|
||||
'negative_log_likelihood':selected.cost-state.cost})
|
||||
state=selected;time=state.events[-1][1]
|
||||
if time==self.end and state.notes:
|
||||
self.trace=trace;self.completed=(state,);self.counts['attempts']=attempt+1
|
||||
return state
|
||||
raise SearchExhausted(f'{self.role}: no complete non-silent sample within {self.attempts} deterministic attempts; end={self.end}')
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Typed, target-driven harmonic expression over a strictly mapped theme."""
|
||||
"""Typed, target-driven harmonic expression against written harmony and explicit musical relationships."""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import functools
|
||||
@@ -22,7 +22,10 @@ def degree_spelling(letter,accidental,degree,alter):
|
||||
def quality(chord):
|
||||
degrees=chord_model.degrees(chord)
|
||||
if degrees.get(3)==0 and degrees.get(7)==-1:return 'dominant'
|
||||
if degrees.get(3)==-1:return 'half_diminished' if degrees.get(5)==-1 else 'minor'
|
||||
if degrees.get(3)==-1:
|
||||
if degrees.get(5)==-1:return 'half_diminished' if degrees.get(7)==-1 else 'diminished'
|
||||
return 'minor'
|
||||
if chord['kind'] in ('suspended-second','suspended-fourth'):return chord['kind']
|
||||
if degrees.get(3)==0 and degrees.get(5)==0:return 'major'
|
||||
return 'other'
|
||||
|
||||
@@ -47,8 +50,8 @@ def eligible_color(rule,span,harmony):
|
||||
# An explicit no-chord span (or missing harmony) has no optional colors.
|
||||
# In particular, looking ahead for an anticipation must not try to parse
|
||||
# the display label N.C. as a written chord.
|
||||
if not span or not span.scale or not span.chord:return False
|
||||
key=(tuple(rule['qualities']),rule['degree'],rule['alter'],rule.get('function'),span)
|
||||
if not span or span.chord=='N.C.' or not span.chord:return False
|
||||
key=(tuple(rule['qualities']),rule['degree'],rule['alter'],rule.get('function'),span.start,span.end,span.chord)
|
||||
cache=harmony.color_eligibility
|
||||
if key not in cache:cache[key]=_eligible_color(rule,span,harmony)
|
||||
return cache[key]
|
||||
@@ -62,7 +65,7 @@ def _eligible_color(rule,span,harmony):
|
||||
# Explicit alterations and omissions take precedence over optional colors.
|
||||
if any((d['value']-1)%7+1==simple and (d['type']=='subtract' or model.alteration(d['alter'])!=rule['alter']) for d in chord.get('degrees',[])):return False
|
||||
if degree in degrees and degrees[degree]!=rule['alter']:return False
|
||||
following=next((s for s in harmony.spans if s.start>=span.end and s.chord!=span.chord and s.scale),None)
|
||||
following=next((s for s in harmony.spans if s.start>=span.end and s.chord!=span.chord and s.chord!='N.C.'),None)
|
||||
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))%12
|
||||
next_chord=parsed_chord(following.chord) if following else None
|
||||
next_root=int(model.NATURAL[next_chord['root']['letter']]+model.alteration(next_chord['root']['alter']))%12 if next_chord else None
|
||||
@@ -70,7 +73,8 @@ def _eligible_color(rule,span,harmony):
|
||||
if function=='predominant_to_dominant' and not (next_chord and quality(next_chord)=='dominant' and (next_root-root)%12==5):return False
|
||||
if function=='resolving_dominant' and (next_root is None or (root-next_root)%12!=7):return False
|
||||
if function=='tonic_or_predominant':
|
||||
tonic=model.parse_pitch_name(span.scale.root)
|
||||
tonic=span.key.get('tonic')
|
||||
if not tonic:return False
|
||||
tonic_pc=int(model.NATURAL[tonic['letter']]+model.alteration(tonic['alter']))%12
|
||||
if root!=tonic_pc and not (next_chord and quality(next_chord)=='dominant' and (next_root-root)%12==5):return False
|
||||
return True
|
||||
@@ -88,7 +92,7 @@ def validate(notes,contracts,harmony):
|
||||
if c.preparation and not same(c.preparation):return {},c.id+': changed preparation'
|
||||
if any(n.event_id in approved for n in c.events):return {},c.id+': overlapping expression permissions'
|
||||
target=c.target; span=harmony.at(c.boundary if c.boundary is not None else target.start)
|
||||
if not span or not span.scale or span.chord!=c.chord:return {},c.id+': wrong target harmony'
|
||||
if not span or span.chord=='N.C.' or span.chord!=c.chord:return {},c.id+': wrong target harmony'
|
||||
if c.kind in ('approach','enclosure','connection'):
|
||||
rule=next((r for r in vocabulary.data('lead_ins')['patterns'] if r['id']==c.rule and r['kind']==c.kind),None)
|
||||
if not rule or len(c.events)!=len(rule['semitones']):return {},c.id+': unknown approach formula'
|
||||
@@ -103,9 +107,9 @@ def validate(notes,contracts,harmony):
|
||||
for i,(n,step,duration) in enumerate(zip(c.events,rule['semitones'],rule['durations'])):
|
||||
if n.start!=cursor or n.end!=cursor+F(duration) or n.pitch.number()!=target.pitch.number()+step:return {},c.id+': wrong register, interval, or timing'
|
||||
cursor=n.end+(gaps[i] if i<len(gaps) else 0)
|
||||
for index in rule.get('mapped_indices',[]):
|
||||
for index in rule.get('diatonic_indices',[]):
|
||||
n=c.events[index]
|
||||
if n.pitch.number()%12 not in harmony.permitted(n.start,n.end):return {},c.id+': declared diatonic neighbor is outside the mapped collection'
|
||||
if n.pitch.number()%12 not in harmony.permitted(n.start,n.end):return {},c.id+': declared diatonic neighbor is outside source-key/chord motion'
|
||||
if any(n.start<c.target.start and n.end>c.events[0].start and n.event_id not in {e.event_id for e in c.events} for n in notes):return {},c.id+': interrupted approach'
|
||||
if c.kind=='connection' and (not c.preparation or c.preparation.end!=c.events[0].start or abs(c.preparation.pitch.number()-c.events[0].pitch.number())>2):return {},c.id+': chromatic connection lacks its incoming motion'
|
||||
elif c.kind=='delayed_approach':
|
||||
@@ -123,13 +127,12 @@ def validate(notes,contracts,harmony):
|
||||
if n.end-n.start>F(rule['max_beats']):return {},c.id+': excessive color duration'
|
||||
for region,a,b in harmony.segments(n):
|
||||
if n.pitch.number()%12 in region.roles.basic:continue
|
||||
if not region.scale or not eligible_color(rule,region,harmony):return {},c.id+': incompatible color harmony/function'
|
||||
if region.chord=='N.C.' or not eligible_color(rule,region,harmony):return {},c.id+': incompatible color harmony/function'
|
||||
p=degree_pitch(parsed_chord(region.chord),rule['degree'],rule['alter'],n.pitch.number())
|
||||
# The rule retains the functional degree/spelling. Encoded
|
||||
# enharmonic notation must not change sounding harmonic validity.
|
||||
if p.number()!=n.pitch.number():return {},c.id+': color degree/register mismatch'
|
||||
colored=True
|
||||
if not rule['outside'] and n.pitch.number()%12 not in region.scale.pitches:return {},c.id+': color requires mapped collection'
|
||||
if not colored:return {},c.id+': no interval expresses the declared color'
|
||||
last=c.events[-1]
|
||||
if rule.get('resolve'):
|
||||
@@ -144,7 +147,7 @@ def validate(notes,contracts,harmony):
|
||||
if held.end-c.boundary>F(rule['max_delay']) or target.start!=held.end or target.pitch.number()-held.pitch.number() not in rule['steps']:
|
||||
return {},c.id+': missed suspension resolution'
|
||||
if target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': invalid suspension target'
|
||||
if held.pitch.number()%12 not in harmony.permitted(held.start,held.end):return {},c.id+': suspension outside mapped collections'
|
||||
if held.pitch.number()%12 not in harmony.permitted(held.start,held.end):return {},c.id+': suspension outside source-key/chord motion'
|
||||
elif c.kind=='anticipated_color':
|
||||
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
|
||||
if not rule or len(c.events)!=1 or c.boundary!=span.start:return {},c.id+': unsupported anticipated color'
|
||||
@@ -177,7 +180,7 @@ def validate(notes,contracts,harmony):
|
||||
for n in c.events:
|
||||
covered=n.start
|
||||
for region,a,b in harmony.segments(n):
|
||||
if a!=covered or not region.scale:return {},c.id+': missing harmony'
|
||||
if a!=covered or region.chord=='N.C.':return {},c.id+': missing harmony'
|
||||
covered=b
|
||||
if covered!=n.end:return {},c.id+': incomplete harmonic coverage'
|
||||
approved[n.event_id]=c
|
||||
|
||||
+86
-235
@@ -1,257 +1,108 @@
|
||||
"""Chord function on exact sounding intervals, separate from scale permission."""
|
||||
"""Written chord, source-key motion, and bounded expressive treatments.
|
||||
|
||||
There is no preselected scale map. Optional colors never rewrite a chord;
|
||||
permission follows the actual sounded interval and validated resolution.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import functools
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.solo.policy
|
||||
import generator.solo.types
|
||||
from fractions import Fraction as F
|
||||
from generator import harmony as chords, model
|
||||
from generator.solo import types
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Roles:
|
||||
basic: frozenset[int]
|
||||
specified: frozenset[int]
|
||||
degrees: tuple[tuple[int, tuple[int, ...]], ...]
|
||||
basic:frozenset
|
||||
specified:frozenset
|
||||
degrees:tuple
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def degree_roles(kind, root, degrees):
|
||||
basic, specified, labels = set(), set(), {}
|
||||
for degree, alteration in degrees:
|
||||
pitch = int(root + generator.harmony.degree_semitones(degree, alteration)) % 12
|
||||
structural = degree in (1, 3, 5, 6, 7) or degree in (2, 4) and kind in ('suspended-second', 'suspended-fourth')
|
||||
(basic if structural else specified).add(pitch)
|
||||
labels.setdefault(pitch, []).append(degree)
|
||||
return Roles(frozenset(basic), frozenset(specified - basic), tuple((p, tuple(v)) for p, v in sorted(labels.items())))
|
||||
def degree_roles(kind,root,degrees):
|
||||
basic,specified,labels=set(),set(),{}
|
||||
for degree,alter in degrees:
|
||||
pc=int(root+chords.degree_semitones(degree,alter))%12
|
||||
structural=degree in (1,3,5,6,7) or degree in (2,4) and kind in ('suspended-second','suspended-fourth')
|
||||
(basic if structural else specified).add(pc);labels.setdefault(pc,[]).append(degree)
|
||||
return Roles(frozenset(basic),frozenset(specified-basic),tuple((p,tuple(v)) for p,v in sorted(labels.items())))
|
||||
|
||||
def roles(chord):
|
||||
if not chord or chord['kind'] == 'none':
|
||||
return Roles(frozenset(), frozenset(), ())
|
||||
root = chord['root']
|
||||
number = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
|
||||
return degree_roles(chord['kind'], number, tuple(sorted(generator.harmony.degrees(chord).items())))
|
||||
if not chord or chord['kind']=='none':return Roles(frozenset(),frozenset(),())
|
||||
root=chord['root'];pc=model.NATURAL[root['letter']]+model.alteration(root['alter'])
|
||||
return degree_roles(chord['kind'],pc,tuple(sorted(chords.degrees(chord).items())))
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Span:
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
roles: Roles
|
||||
scale: object
|
||||
chord: str
|
||||
start:F
|
||||
end:F
|
||||
roles:Roles
|
||||
chord:str
|
||||
key:dict
|
||||
pitches:frozenset
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Result:
|
||||
valid: bool
|
||||
exposure: float
|
||||
pending: tuple[generator.solo.types.HarmonicTreatment, ...] = ()
|
||||
error: str = ''
|
||||
valid:bool
|
||||
exposure:float
|
||||
pending:tuple=()
|
||||
error:str=''
|
||||
|
||||
class Harmony:
|
||||
def __init__(self, choices, profile=None):
|
||||
def __init__(self,spans,profile=None):
|
||||
from generator.solo.profiles import UNRESTRICTED
|
||||
self.profile = profile or UNRESTRICTED
|
||||
self.choices = choices
|
||||
self.spans = tuple(Span(c.span.start, c.span.end, roles(c.span.chord), c.scale, generator.harmony.symbol(c.span.chord) if c.span.chord else 'N.C.') for c in choices)
|
||||
self._at = {}
|
||||
self._segments = {}
|
||||
self._members = {}
|
||||
self._permitted = {}
|
||||
self.evaluations = {}
|
||||
self.color_eligibility = {}
|
||||
def at(self, time):
|
||||
if time not in self._at:
|
||||
self._at[time] = next((s for s in self.spans if s.start <= time < s.end), None)
|
||||
return self._at[time]
|
||||
def segments(self, note):
|
||||
key = (note.start, note.end)
|
||||
if key not in self._segments:
|
||||
self._segments[key] = tuple((s, max(note.start, s.start), min(note.end, s.end)) for s in self.spans if s.start < note.end and s.end > note.start)
|
||||
return self._segments[key]
|
||||
def members(self, start, end):
|
||||
key = (start, end)
|
||||
if key not in self._members:
|
||||
spans = [s for s in self.spans if s.start < end and s.end > start]
|
||||
self._members[key] = frozenset.intersection(*(s.roles.basic for s in spans)) if spans else frozenset()
|
||||
return self._members[key]
|
||||
self.profile=profile or UNRESTRICTED
|
||||
self.source_spans=spans;self.spans=[];self.color_eligibility={}
|
||||
for s in spans:
|
||||
r=roles(s.chord)
|
||||
key=frozenset((model.NATURAL[l]+int(model.fraction(a)))%12 for l,a in zip(model.LETTERS,s.key['alterations']))
|
||||
self.spans.append(Span(s.start,s.end,r,chords.symbol(s.chord) if s.chord else 'N.C.',s.key,key|r.basic|r.specified))
|
||||
self.spans=tuple(self.spans)
|
||||
@functools.lru_cache(maxsize=2048)
|
||||
def at(self,time):return next((s for s in self.spans if s.start<=time<s.end),None)
|
||||
def segments(self,n):return tuple((s,max(n.start,s.start),min(n.end,s.end)) for s in self.spans if s.start<n.end and s.end>n.start)
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def members(self,start,end):
|
||||
spans=[s for s in self.spans if s.start<end and s.end>start]
|
||||
return frozenset.intersection(*(s.roles.basic|s.roles.specified for s in spans)) if spans else frozenset()
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def permitted(self,start,end):
|
||||
key=(start,end)
|
||||
if key not in self._permitted:
|
||||
self._permitted[key]=generator.solo.types.permitted(self.choices,start,end)
|
||||
return self._permitted[key]
|
||||
def function(self, number, span):
|
||||
pitch = number % 12
|
||||
return 'basic' if pitch in span.roles.basic else 'specified_extension' if pitch in span.roles.specified else 'mapped_motion' if span.scale and pitch in span.scale.pitches else 'outside_collection'
|
||||
def step(self, first, second, time):
|
||||
span = self.at(time)
|
||||
if not span or not span.scale:
|
||||
return False
|
||||
try:
|
||||
return abs(generator.solo.types.scale_index(span.scale, first) - generator.solo.types.scale_index(span.scale, second)) == 1
|
||||
except ValueError:
|
||||
return False
|
||||
def connected_motion(self, notes, index):
|
||||
if not 0 < index < len(notes) - 1:
|
||||
return ''
|
||||
before, note, after = notes[index - 1:index + 2]
|
||||
if before.end != note.start or note.end != after.start:
|
||||
return ''
|
||||
a, b, c = (n.pitch.number() for n in (before, note, after))
|
||||
if self.step(a, b, note.start) and self.step(b, c, note.start):
|
||||
return 'neighbor' if a == c else 'passing' if (b - a) * (c - b) > 0 else ''
|
||||
return ''
|
||||
def enclosure_events(self, notes, enclosures, until=None):
|
||||
"""Validate whole gestures before granting any exception; never trust labels."""
|
||||
notes = generator.solo.types.sounding_notes(notes)
|
||||
events = {}
|
||||
for note in notes:
|
||||
if note.event_id:
|
||||
if note.event_id in events:
|
||||
return {}, 'duplicate sounding event ID in enclosure context'
|
||||
events[note.event_id] = note
|
||||
accepted, intervals = {}, []
|
||||
for contract in enclosures:
|
||||
upper, lower, target = contract.upper, contract.lower, contract.target
|
||||
if len({n.event_id for n in (upper, lower, target)}) != 3 or not all(n.event_id for n in (upper, lower, target)):
|
||||
return {}, contract.id + ': enclosure requires three distinct event IDs'
|
||||
for expected in (upper, lower, target):
|
||||
actual = events.get(expected.event_id)
|
||||
if not actual or (actual.start, actual.end, actual.pitch) != (expected.start, expected.end, expected.pitch):
|
||||
return {}, contract.id + ': missing or changed enclosure event ' + expected.event_id
|
||||
approach_start = contract.approach_start if contract.approach_start is not None else upper.start
|
||||
if not upper.start <= approach_start or upper.end-approach_start != fractions.Fraction(1,2) or lower.end-lower.start != fractions.Fraction(1,2) or upper.end != lower.start or lower.end != target.start:
|
||||
return {}, contract.id + ': enclosure must be two contiguous eighths immediately before its target'
|
||||
if not 0 < upper.pitch.number()-target.pitch.number() <= 3 or lower.pitch.number() != target.pitch.number()-1:
|
||||
return {}, contract.id + ': enclosure does not surround the exact target register'
|
||||
if any(n.start < target.start and n.end > upper.start and n.event_id not in (upper.event_id,lower.event_id) for n in notes):
|
||||
return {}, contract.id + ': interrupted or overlapping enclosure'
|
||||
if any(a < target.start and b > upper.start for a,b in intervals):
|
||||
return {}, contract.id + ': overlapping enclosure contracts'
|
||||
if until is not None and target.end > until:
|
||||
return {}, contract.id + ': enclosure resolution exceeds the available arrangement'
|
||||
span = self.at(target.start)
|
||||
tail = contract.protected_tail
|
||||
if not span or span.chord != contract.chord or target.pitch.number()%12 not in self.members(target.start,target.end):
|
||||
return {}, contract.id + ': target is not a chord member throughout its hold'
|
||||
if contract.target_role not in ('intermediate','terminal') or tail.pitch != target.pitch.number() or not target.start <= tail.start < tail.end <= target.end:
|
||||
return {}, contract.id + ': invalid protected destination tail'
|
||||
for note in (upper, lower, target):
|
||||
covered=note.start
|
||||
for region,start,end in self.segments(note):
|
||||
if start != covered or not region.scale:
|
||||
return {}, contract.id + ': missing harmony in enclosure'
|
||||
if note is not lower and note.pitch.number()%12 not in region.scale.pitches:
|
||||
return {}, contract.id + ': upper approach or target outside mapped collection'
|
||||
covered=end
|
||||
if covered != note.end:
|
||||
return {}, contract.id + ': incomplete harmony in enclosure'
|
||||
# The upper neighbor is the nearest common mapped pitch above T.
|
||||
allowed=generator.solo.types.permitted(self.choices,upper.start,upper.end)
|
||||
if any(p%12 in allowed for p in range(target.pitch.number()+1,upper.pitch.number())):
|
||||
return {}, contract.id + ': upper approach is not the nearest mapped neighbor'
|
||||
accepted[lower.event_id]=contract
|
||||
accepted[upper.event_id]=contract
|
||||
intervals.append((upper.start,target.start))
|
||||
return accepted, ''
|
||||
|
||||
def evaluate(self, notes, treatments=(), *, until=None, complete=False, enclosures=(), expressions=()):
|
||||
notes = generator.solo.types.sounding_notes(notes)
|
||||
spans=[s for s in self.spans if s.start<end and s.end>start]
|
||||
return frozenset.intersection(*(s.pitches if s.chord!='N.C.' else frozenset() for s in spans)) if spans else frozenset()
|
||||
def function(self,number,span):
|
||||
pc=number%12
|
||||
return 'basic' if pc in span.roles.basic else 'specified_extension' if pc in span.roles.specified else 'diatonic_motion' if pc in span.pitches else 'chromatic'
|
||||
def step(self,a,b,time):return 0<abs(a-b)<=2
|
||||
def connected_motion(self,notes,index):
|
||||
if not 0<index<len(notes)-1:return ''
|
||||
a,n,b=notes[index-1:index+2]
|
||||
if a.end!=n.start or n.end!=b.start:return ''
|
||||
left=n.pitch.number()-a.pitch.number();right=b.pitch.number()-n.pitch.number()
|
||||
return 'neighbor' if 0<abs(left)<=2 and left==-right else 'passing' if 0<abs(left)<=2 and 0<abs(right)<=2 and left*right>0 else ''
|
||||
def evaluate(self,notes,*,until=None,complete=False,expressions=()):
|
||||
from generator.solo.expressions import validate_frontier
|
||||
explicit_frontier=until is not None
|
||||
until = until if until is not None else max((n.end for n in notes), default=fractions.Fraction())
|
||||
permissions, expression_pending, expression_error = validate_frontier(notes,expressions,self,until,complete or not explicit_frontier)
|
||||
if expression_error:return Result(False,0,error=expression_error)
|
||||
ornaments, error = self.enclosure_events(notes, enclosures, until) if enclosures else ({}, '')
|
||||
if error:
|
||||
return Result(False, 0, error=error)
|
||||
pending, valid_contracts = list(expression_pending), []
|
||||
for treatment in treatments:
|
||||
if treatment.start >= until:
|
||||
continue
|
||||
if treatment.kind == 'color':
|
||||
spans = [s for s in self.spans if s.start < treatment.end and s.end > treatment.start]
|
||||
if not spans or any(treatment.pitch % 12 not in s.roles.basic | s.roles.specified for s in spans):
|
||||
return Result(False, 0, error=f'{treatment.id}: color is not specified by the overlapped harmony')
|
||||
valid_contracts.append(treatment)
|
||||
continue
|
||||
if treatment.kind != 'suspension' or treatment.resolution is None or treatment.deadline is None:
|
||||
return Result(False, 0, error=f'{treatment.id}: incomplete harmonic treatment')
|
||||
held = next((n for n in notes if n.pitch.number() == treatment.pitch and n.start <= treatment.start - treatment.preparation and n.end > treatment.start), None)
|
||||
if held is None:
|
||||
return Result(False, 0, error=f'{treatment.id}: suspension lacks sounding preparation')
|
||||
if treatment.pitch % 12 not in self.members(treatment.start - treatment.preparation, treatment.start):
|
||||
return Result(False, 0, error=f'{treatment.id}: preparation is not a chord member')
|
||||
resolution = next((n for n in notes if n.start == held.end and n.pitch.number() == treatment.resolution and n.start <= treatment.deadline), None)
|
||||
if resolution:
|
||||
span = self.at(resolution.start)
|
||||
if not span or treatment.resolution % 12 not in span.roles.basic or not self.step(treatment.pitch, treatment.resolution, resolution.start):
|
||||
return Result(False, 0, error=f'{treatment.id}: resolution is not an adjacent chord member')
|
||||
elif complete or until > treatment.deadline or any(n.start >= held.end for n in notes if n is not held):
|
||||
return Result(False, 0, error=f'{treatment.id}: unresolved suspension at deadline {treatment.deadline}')
|
||||
else:
|
||||
pending.append(treatment)
|
||||
valid_contracts.append(treatment)
|
||||
exposed, sounding = 0.0, 0.0
|
||||
for index, note in enumerate(notes):
|
||||
number = note.pitch.number()
|
||||
duration = note.end - note.start
|
||||
sounding += float(duration)
|
||||
covered = note.start
|
||||
for span, start, end in self.segments(note):
|
||||
contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end), None)
|
||||
enclosure = ornaments.get(note.event_id)
|
||||
expression = permissions.get(note.event_id)
|
||||
exception = enclosure is not None and note.event_id == enclosure.lower.event_id or expression is not None and (expression.kind != 'anticipation' or end <= expression.boundary)
|
||||
if start > covered or not span.scale or number % 12 not in span.scale.pitches and not exception:
|
||||
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony')
|
||||
covered = end
|
||||
function = self.function(number, span)
|
||||
brief_enclosure = enclosure and (exception or enclosure.approach_start is None or enclosure.approach_start == note.start)
|
||||
if function == 'basic' or note.role == 'quote' or brief_enclosure or expression:
|
||||
continue
|
||||
if duration >= generator.solo.policy.DEFAULT.long_note and contract is None:
|
||||
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end} over {span.chord}: sustained {function} without a planned treatment')
|
||||
connected = duration < generator.solo.policy.DEFAULT.stopping_note and self.connected_motion(notes, index)
|
||||
cost = 0 if contract or connected else 1 if duration >= generator.solo.policy.DEFAULT.stopping_note else 0.25
|
||||
exposed += float(end - start) * cost
|
||||
if covered < note.end:
|
||||
return Result(False, 0, error=f'{note.pitch.label()} at {covered}: missing harmonic coverage')
|
||||
return Result(True, exposed / max(sounding, 0.001), tuple(pending))
|
||||
def describe(self, notes, treatments=(), enclosures=(), expressions=()):
|
||||
notes = generator.solo.types.sounding_notes(notes)
|
||||
notes=types.sounding_notes(notes)
|
||||
frontier=until if until is not None else max((n.end for n in notes),default=F())
|
||||
permissions,pending,error=validate_frontier(notes,expressions,self,frontier,complete or until is None)
|
||||
if error:return Result(False,0,error=error)
|
||||
exposed=sounded=F()
|
||||
for n in notes:
|
||||
covered=n.start;duration=n.end-n.start;sounded+=duration
|
||||
for s,a,b in self.segments(n):
|
||||
if a!=covered or s.chord=='N.C.':return Result(False,0,error=f'{n.event_id}: missing written/carried harmony')
|
||||
covered=b;permission=permissions.get(n.event_id)
|
||||
if permission and permission.kind=='anticipation' and a>=permission.boundary:permission=None
|
||||
pc=n.pitch.number()%12
|
||||
if pc in s.roles.basic|s.roles.specified or permission:continue
|
||||
if pc not in s.pitches:return Result(False,0,error=f'{n.event_id}: unsupported chromatic event')
|
||||
if duration>=2 and n.role!='quote':return Result(False,0,error=f'{n.event_id}: sustained nonchord tone needs an explicit treatment')
|
||||
exposed+=(b-a)/4
|
||||
if covered!=n.end:return Result(False,0,error=f'{n.event_id}: incomplete harmonic coverage')
|
||||
return Result(True,float(exposed/max(F(1,1000),sounded)),pending)
|
||||
def describe(self,notes,expressions=()):
|
||||
from generator.solo.expressions import validate
|
||||
permissions, error = validate(notes, expressions, self)
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
ornaments, error = self.enclosure_events(notes,enclosures) if enclosures else ({}, '')
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
result = []
|
||||
for index, note in enumerate(notes):
|
||||
for span, start, end in self.segments(note):
|
||||
contracts = [t for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end]
|
||||
expression = permissions.get(note.event_id)
|
||||
result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': self.connected_motion(notes, index), 'enclosure': ornaments[note.event_id].id if note.event_id in ornaments else '', 'expression': expression.kind if expression else '', 'expression_rule': expression.rule if expression else '', 'scale_exception': bool(note.event_id in ornaments or expression) and self.function(note.pitch.number(), span) == 'outside_collection', 'protected': note.role == 'quote', 'treatments': [t.id for t in contracts]})
|
||||
return result
|
||||
|
||||
def anchor_note(anchor, notes, *, until=None):
|
||||
for note in notes:
|
||||
if note.pitch.number() != anchor.pitch.number():
|
||||
continue
|
||||
arrival = note.start if anchor.onset_required else max(note.start, anchor.earliest)
|
||||
needed = max(arrival + anchor.duration, anchor.hold_until or arrival)
|
||||
if anchor.earliest <= arrival <= anchor.latest and note.start <= arrival < note.end and note.end >= min(needed, until if until is not None else needed):
|
||||
return note
|
||||
return None
|
||||
|
||||
def anchors_valid(anchors, notes, until, *, complete=False, closure='open'):
|
||||
for anchor in anchors:
|
||||
if anchor.earliest >= until and not complete:
|
||||
continue
|
||||
note = anchor_note(anchor, notes, until=None if complete else until)
|
||||
if note is None and (complete or until > anchor.latest):
|
||||
return False, f'{anchor.id}: required {anchor.role} arrival {anchor.earliest}-{anchor.latest} on {anchor.pitch.label()} not completed'
|
||||
if note and anchor.role == 'terminal' and closure == 'closed' and any(n.start > note.start for n in notes):
|
||||
return False, f'{anchor.id}: terminal anchor touched then abandoned'
|
||||
return True, ''
|
||||
permissions,error=validate(notes,expressions,self)
|
||||
if error:raise ValueError(error)
|
||||
return [{'start':str(a),'end':str(b),'attack':str(n.start),'sounding_duration':str(n.end-n.start),
|
||||
'pitch':n.pitch.label(),'chord':s.chord,'function':self.function(n.pitch.number(),s),
|
||||
'expression':permissions[n.event_id].kind if n.event_id in permissions else '',
|
||||
'expression_rule':permissions[n.event_id].rule if n.event_id in permissions else '',
|
||||
'protected':n.role=='quote'} for n in notes for s,a,b in self.segments(n)]
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Smoothed joint musical-event model. No examples or song lookup at inference.
|
||||
|
||||
Counts are sufficient statistics of short, relative contexts, not stored scores.
|
||||
The hierarchical Dirichlet estimator uses eight parent pseudo-observations.
|
||||
All paths through the decoder score the same sounding event stream identically.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import collections
|
||||
import json
|
||||
import math
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
from pathlib import Path
|
||||
from generator import model
|
||||
from generator.solo import expressions
|
||||
|
||||
VERSION = 'pooled-events-1'
|
||||
PRIOR = 8
|
||||
DURATIONS = ('1/4','1/3','1/2','2/3','3/4','1','3/2','2','5/2','3','4')
|
||||
|
||||
|
||||
def key(value):
|
||||
return json.dumps(value,separators=(',',':'))
|
||||
|
||||
|
||||
def chord_context(harmony,time):
|
||||
span=harmony.at(time)
|
||||
current=expressions.parsed_chord(span.chord) if span and span.chord!='N.C.' else None
|
||||
future=next((s for s in harmony.spans if s.start>time and s.chord!='N.C.' and (not span or s.chord!=span.chord)),None)
|
||||
following=expressions.parsed_chord(future.chord) if future else None
|
||||
def root(c):return int(model.NATURAL[c['root']['letter']]+model.alteration(c['root']['alter']))%12 if c else 0
|
||||
return (expressions.quality(current) if current else 'unknown',
|
||||
expressions.quality(following) if following else 'end',
|
||||
(root(following)-root(current))%12 if current and following else -1),root(current),future
|
||||
|
||||
|
||||
def token(duration,pitch,previous,root):
|
||||
return ('rest',str(duration),0,0) if pitch is None else ('note',str(duration),
|
||||
pitch-previous if previous is not None else 'start',(pitch-root)%12)
|
||||
|
||||
|
||||
def source_feature(analysis,time,root=0):
|
||||
time+=analysis.body_offset
|
||||
before=[n for n in analysis.melody if n.start<=time][-2:]
|
||||
following=next((n for n in analysis.melody if n.start>time),None)
|
||||
direction=0 if len(before)<2 else (before[-1].pitch.number()>before[-2].pitch.number())-(before[-1].pitch.number()<before[-2].pitch.number())
|
||||
held=bool(before and before[-1].end>time)
|
||||
upcoming=0 if not before or not following else (following.pitch.number()>before[-1].pitch.number())-(following.pitch.number()<before[-1].pitch.number())
|
||||
return (direction,held,upcoming,str(before[-1].end-before[-1].start) if before else 'none',
|
||||
(before[-1].pitch.number()-root)%12 if before else -1,
|
||||
before[-1].pitch.number()-before[-2].pitch.number() if len(before)>1 else 0)
|
||||
|
||||
|
||||
def phase_at(analysis,time):
|
||||
absolute=time+analysis.body_offset
|
||||
start=next((a for a,b in zip(analysis.boundaries,analysis.boundaries[1:]) if a<=absolute<b),analysis.boundaries[-2])
|
||||
return absolute-start
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=65536)
|
||||
def contexts(harmonic,phase,source,history):
|
||||
# No title, path, absolute bar number, progression ID, or target-derived ID.
|
||||
base=(*harmonic,str(phase))
|
||||
result=[(),base,(*base,source)]
|
||||
# A complete four-event rhythmic/melodic relationship can influence the next
|
||||
# event, while unseen histories smoothly return to broader musical evidence.
|
||||
for count in range(1,min(4,len(history))+1):
|
||||
result.append((*base,source,tuple(history[-count:])))
|
||||
return tuple(key(c) for c in result)
|
||||
|
||||
|
||||
class Model:
|
||||
def __init__(self,value):
|
||||
if value.get('version')!=VERSION:raise ValueError('Missing/incompatible solo model; run python -m generator.train')
|
||||
self.value=value
|
||||
self.tables={k:(v['total'],v['counts']) for k,v in value['contexts'].items()}
|
||||
self.durations=tuple(sorted(map(F,value['durations'])))
|
||||
self.rhythms=value['rhythms'];self.steps=value['steps'];self.degrees=value['degrees']
|
||||
self._prob={}
|
||||
self._totals={id(t):sum(t.values()) for t in (self.rhythms,self.steps,self.degrees)}
|
||||
|
||||
@classmethod
|
||||
def load(cls,path):
|
||||
try:return cls(json.loads(Path(path).read_text()))
|
||||
except FileNotFoundError as e:raise ValueError('Solo model not found; run python -m generator.train') from e
|
||||
|
||||
def probability(self,action,context):
|
||||
cache=(action,context)
|
||||
if cache in self._prob:return self._prob[cache]
|
||||
kind,duration,step,degree=action
|
||||
def p(table,item,size):return (table.get(str(item),0)+1)/(self._totals[id(table)]+size)
|
||||
probability=p(self.rhythms,kind+':'+duration,2*len(self.durations))
|
||||
if kind=='note':probability*=p(self.steps,step,50)*p(self.degrees,degree,12)
|
||||
encoded=key(action)
|
||||
for c in context:
|
||||
if c in self.tables:
|
||||
total,counts=self.tables[c]
|
||||
probability=(counts.get(encoded,0)+PRIOR*probability)/(total+PRIOR)
|
||||
if len(self._prob)>100000:self._prob.clear()
|
||||
self._prob[cache]=probability
|
||||
return probability
|
||||
|
||||
def cost(self,action,context):return -math.log(self.probability(action,context))
|
||||
|
||||
def register_cost(self,number):
|
||||
counts=self.value['registers']
|
||||
return -math.log((counts.get(str(number//12),0)+PRIOR)/(sum(counts.values())+PRIOR*4))
|
||||
|
||||
|
||||
def fit(examples,provenance):
|
||||
tables=collections.defaultdict(collections.Counter)
|
||||
rhythms=collections.Counter();steps=collections.Counter();degrees=collections.Counter()
|
||||
registers=collections.Counter();durations=set(DURATIONS)
|
||||
for example in examples:
|
||||
history=[];previous=None
|
||||
for start,end,pitch in example['events']:
|
||||
harmonic,root,_=chord_context(example['harmony'],start)
|
||||
action=token(end-start,pitch,previous,root)
|
||||
context=contexts(harmonic,phase_at(example['analysis'],start-example['analysis'].body_offset),
|
||||
source_feature(example['analysis'],start-example['analysis'].body_offset,root),tuple(history[-4:]))
|
||||
for c in context:tables[c][key(action)]+=1
|
||||
durations.add(str(end-start));rhythms[action[0]+':'+action[1]]+=1
|
||||
if pitch is not None:
|
||||
steps[str(action[2])]+=1;degrees[str(action[3])]+=1
|
||||
registers[str(pitch//12)]+=1;previous=pitch
|
||||
history.append(action)
|
||||
return {'version':VERSION,'provenance':provenance,'prior':PRIOR,
|
||||
'durations':sorted(durations,key=F),'rhythms':dict(rhythms),'steps':dict(steps),'degrees':dict(degrees),
|
||||
'registers':dict(registers),
|
||||
'contexts':{k:{'total':sum(v.values()),'counts':dict(sorted(v.items()))} for k,v in sorted(tables.items())}}
|
||||
@@ -1,656 +0,0 @@
|
||||
"""Source-related melodic sentences realized as whole pitch/rhythm routes.
|
||||
|
||||
Original song analysis and general vocabulary are the only musical inputs.
|
||||
"""
|
||||
import collections
|
||||
import dataclasses
|
||||
import fractions
|
||||
import functools
|
||||
import generator.model
|
||||
import generator.solo.expressions
|
||||
import generator.solo.groups
|
||||
import generator.solo.policy
|
||||
import generator.solo.rhythm
|
||||
import generator.solo.sequences
|
||||
import generator.solo.selection
|
||||
import generator.solo.types
|
||||
import generator.vocabulary
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Idea:
|
||||
rhythm: tuple
|
||||
intervals: tuple
|
||||
source: str
|
||||
|
||||
@property
|
||||
def length(self):
|
||||
return self.rhythm[-1][1]
|
||||
|
||||
|
||||
def idea(notes, source):
|
||||
first = notes[0]
|
||||
return Idea(tuple((n.start-first.start,n.end-first.start) for n in notes),
|
||||
tuple(n.pitch.number()-first.pitch.number() for n in notes),source)
|
||||
|
||||
|
||||
def source_ideas(analysis):
|
||||
found = {}
|
||||
for i in range(len(analysis.melody)):
|
||||
for count in (3,4,5,6):
|
||||
notes = analysis.melody[i:i+count]
|
||||
if len(notes)!=count or not 2<=notes[-1].end-notes[0].start<=6:
|
||||
continue
|
||||
value = idea(notes,'head:'+str(notes[0].start))
|
||||
if not generator.solo.groups.valid(value.rhythm):
|
||||
continue
|
||||
key = (value.rhythm,value.intervals)
|
||||
old,number = found.get(key,(value,0)); found[key] = (old,number+1)
|
||||
def rank(row):
|
||||
value,count = row
|
||||
intervals = [b-a for a,b in zip(value.intervals,value.intervals[1:])]
|
||||
turns = sum(a*b<0 for a,b in zip(intervals,intervals[1:]))
|
||||
durations = len({b-a for a,b in value.rhythm})
|
||||
return (-min(count,3),-durations,-min(turns,2),-min(7,max(value.intervals)-min(value.intervals)),value.source)
|
||||
return tuple(v for v,_ in sorted(found.values(),key=rank)[:12])
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Arrival:
|
||||
note: generator.solo.types.Note
|
||||
anchor: generator.solo.types.Anchor
|
||||
contracts: tuple = ()
|
||||
resume: fractions.Fraction = fractions.Fraction()
|
||||
|
||||
|
||||
class Phrases:
|
||||
def __init__(self,analysis,harmony,sequences,rhythms,start,end,seed,enabled):
|
||||
self.analysis,self.harmony = analysis,harmony
|
||||
self.start,self.end,self.seed,self.enabled = start,end,seed,enabled
|
||||
self.sequences = sequences
|
||||
self.ideas = source_ideas(analysis)
|
||||
library = generator.solo.rhythm.Rhythms(rhythms,analysis.tempo)
|
||||
self.patterns = tuple(library.groups)
|
||||
self.authored_ids = frozenset(v[0] for v in self.patterns)
|
||||
self.arrivals = tuple(sorted({(cell.pattern[-1][1]-cell.pattern[-1][0],cell.duration-cell.pattern[-1][1])
|
||||
for cell in generator.vocabulary.rhythm_cells(analysis.tempo,'arrival') if cell.pattern and cell.pattern[-1][1]-cell.pattern[-1][0]>=1}))
|
||||
# Catalog families contribute one peer per rhythmic function, not one
|
||||
# vote per file. In particular the large 5/4 family cannot dominate 4/4.
|
||||
representatives = {}
|
||||
lengths = {b-a for a,b in zip(analysis.boundaries,analysis.boundaries[1:])}
|
||||
for values in library.catalog.values():
|
||||
for name,pattern,length,purposes in values:
|
||||
if length not in lengths or not 2<=len(pattern)<=6:
|
||||
continue
|
||||
descriptor = generator.solo.groups.descriptor(pattern,length)
|
||||
representatives.setdefault(descriptor,(name,pattern,length,purposes))
|
||||
self.patterns += tuple(representatives.values())
|
||||
self.patterns = tuple(dict((generator.solo.groups.descriptor(p,l),(name,p,l,roles))
|
||||
for name,p,l,roles in self.patterns).values())
|
||||
self.patterns_by_length = {}
|
||||
for value in self.patterns:
|
||||
self.patterns_by_length.setdefault(value[2],[]).append(value)
|
||||
self.center = harmony.profile.reference or 60
|
||||
self.rejections = collections.Counter()
|
||||
self.proposals = collections.Counter()
|
||||
|
||||
def choose(self,values,domain,key):
|
||||
return min(values,key=lambda x:generator.solo.policy.stable_rank(self.seed+domain,(key,x)))
|
||||
|
||||
def horizon(self,start):
|
||||
region = self.harmony.at(start)
|
||||
if not region or not region.scale:
|
||||
return min(self.end,region.end if region else self.end)
|
||||
gap = next((s.start for s in self.harmony.spans if start<s.start<self.end and not s.scale),self.end)
|
||||
boundaries = [t-self.analysis.body_offset for t in self.analysis.boundaries if start<t-self.analysis.body_offset<=self.end]
|
||||
if not boundaries:
|
||||
return self.end
|
||||
if start<0:
|
||||
return min(self.end,fractions.Fraction())
|
||||
candidates = boundaries[1:8] or boundaries
|
||||
def cost(end):
|
||||
previous = self.harmony.at(end-fractions.Fraction(1,8))
|
||||
before = self.harmony.at(previous.start-fractions.Fraction(1,8)) if previous else None
|
||||
cadence = False
|
||||
if previous and before and previous.scale and before.scale:
|
||||
a = generator.solo.expressions.parsed_chord(before.chord)
|
||||
b = generator.solo.expressions.parsed_chord(previous.chord)
|
||||
root = lambda c:int(generator.model.NATURAL[c['root']['letter']]+generator.model.alteration(c['root']['alter']))%12
|
||||
cadence = generator.solo.expressions.quality(a)=='dominant' and (root(a)-root(b))%12==7
|
||||
breath = any(n.end==end+self.analysis.body_offset and (i+1==len(self.analysis.melody) or self.analysis.melody[i+1].start>n.end) for i,n in enumerate(self.analysis.melody))
|
||||
return (abs(boundaries.index(end)+1-4)-2*int(cadence)-int(breath),end)
|
||||
return min(gap,min(candidates,key=cost))
|
||||
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def pitches(self,start,end):
|
||||
span = self.harmony.at(start)
|
||||
if not span or not span.scale:
|
||||
return ()
|
||||
permitted = self.harmony.permitted(start,end)
|
||||
return tuple(p for index in range(-28,29) if self.harmony.profile.permits((p:=generator.solo.types.scale_pitch(span.scale,index)).number()) and p.number()%12 in permitted)
|
||||
|
||||
@staticmethod
|
||||
def note(start,end,pitch,role):
|
||||
return generator.solo.types.Note(start,end,pitch,role=role,event_id=f'{start}:{end}:{pitch.number()}')
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def event_options(self,start,end,structural,enabled):
|
||||
"""Validate the immutable harmonic interval once, not at every near pitch."""
|
||||
result = []
|
||||
span = self.harmony.at(start)
|
||||
if not span or not span.scale:
|
||||
return ()
|
||||
for pitch in self.pitches(start,end):
|
||||
note = self.note(start,end,pitch,'arrival' if structural else 'motion')
|
||||
if self.harmony.evaluate((note,),complete=True).valid:
|
||||
if not structural or pitch.number()%12 in self.harmony.members(start,end):
|
||||
result.append((note,()))
|
||||
if enabled and end-start>=1:
|
||||
for rule in generator.vocabulary.data('harmony')['colors']:
|
||||
if rule.get('resolve') or not generator.solo.expressions.eligible_color(rule,span,self.harmony):
|
||||
continue
|
||||
low,high = self.harmony.profile.register
|
||||
for near in range(low,high+12,12):
|
||||
pitch = generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],near)
|
||||
if not self.harmony.profile.permits(pitch.number()):
|
||||
continue
|
||||
note = self.note(start,end,pitch,'color')
|
||||
color = generator.solo.types.ExpressionContract(note.event_id+':'+rule['id'],'color',rule['id'],(note,),note,span.chord)
|
||||
if self.harmony.evaluate((note,),expressions=(color,),complete=True).valid:
|
||||
result.append((note,(color,)))
|
||||
return tuple(dict.fromkeys(result))
|
||||
|
||||
def options(self,start,end,near,structural=False):
|
||||
return tuple(v for v in self.event_options(start,end,structural,self.enabled) if abs(v[0].pitch.number()-near)<=12)
|
||||
|
||||
def arrival_options(self,onset,release,harmonic_time,center,effect,resume=None):
|
||||
"""Choose the sounding target before realizing its incoming gesture."""
|
||||
region = self.harmony.at(harmonic_time)
|
||||
if not region or not region.scale or not onset<release:
|
||||
return ()
|
||||
result = []
|
||||
if onset<harmonic_time:
|
||||
if not self.enabled or region.start!=harmonic_time or not onset<harmonic_time<release:
|
||||
return ()
|
||||
# A future member need not be legal in the preceding collection. Only
|
||||
# the bounded, validated anticipation authorizes that preceding interval.
|
||||
candidates = [(p,()) for p in self.pitches(harmonic_time,release)
|
||||
if p.number()%12 in self.harmony.members(harmonic_time,release)]
|
||||
else:
|
||||
candidates = [(n.pitch,cs) for n,cs in self.options(onset,release,center,True)]
|
||||
for pitch,support in candidates:
|
||||
if abs(pitch.number()-center)>10:
|
||||
continue
|
||||
note = self.note(onset,release,pitch,'arrival')
|
||||
if onset<harmonic_time:
|
||||
support = (generator.solo.types.ExpressionContract(note.event_id+':early','anticipation',
|
||||
'early_chord_tone',(note,),note,region.chord,boundary=harmonic_time),)
|
||||
elif support:
|
||||
note = support[0].target
|
||||
if not self.harmony.evaluate((note,),expressions=support,complete=True).valid:
|
||||
continue
|
||||
anchor = generator.solo.types.Anchor('arrival:'+str(onset),onset,onset,pitch,
|
||||
frozenset({pitch.number()%12}),self.harmony.profile.register,release-onset,
|
||||
'terminal' if effect in ('complete','handoff') else 'intermediate',region.chord,
|
||||
False,release,effect)
|
||||
result.append(Arrival(note,anchor,support,release if resume is None else resume))
|
||||
return tuple(result)
|
||||
|
||||
def cadence(self,time):
|
||||
current = self.harmony.at(time)
|
||||
previous = self.harmony.at(time-fractions.Fraction(1,8))
|
||||
if not current or not previous or not current.scale or not previous.scale or current.chord==previous.chord:
|
||||
return False
|
||||
a,b = (generator.solo.expressions.parsed_chord(s.chord) for s in (previous,current))
|
||||
root = lambda c:int(generator.model.NATURAL[c['root']['letter']]+generator.model.alteration(c['root']['alter']))%12
|
||||
return (generator.solo.expressions.quality(a)=='dominant' and
|
||||
generator.solo.expressions.quality(b)!='dominant' and (root(a)-root(b))%12==7)
|
||||
|
||||
def destination_choice(self,options,center,domain,key):
|
||||
# Each eligible harmonic degree has an octave realization close to the
|
||||
# phrase's chosen hand/register region. Register exploration belongs to
|
||||
# the phrase choice, not an unrelated octave lottery at every arrival.
|
||||
members = {}
|
||||
for value in options:
|
||||
pc = value.note.pitch.number()%12
|
||||
rank = (abs(value.note.pitch.number()-center),value.note.pitch.number())
|
||||
if pc not in members or rank<members[pc][0]:
|
||||
members[pc] = (rank,value)
|
||||
return self.choose(tuple(v for _,v in sorted(members.values(),key=lambda v:v[0])),domain,key)
|
||||
|
||||
def destinations(self,start,end,center,variant):
|
||||
"""Small connected alternatives in harmonic time, independent of a body."""
|
||||
hold,breath = self.choose(self.arrivals,'arrival',(start,variant))
|
||||
close = end==self.end or self.choose((True,False),'closure',(start,variant))
|
||||
result = []
|
||||
if close and end-start>=3:
|
||||
release = end-breath
|
||||
base = max(start+1,release-hold)
|
||||
times = [(base,base)]
|
||||
# Near a harmonic change compare anticipation, on-time and delayed
|
||||
# entry, rather than retiming an already selected strict melody.
|
||||
for span in self.harmony.spans:
|
||||
if start+2<=span.start<release-1 and abs(span.start-base)<=2:
|
||||
for offset in (fractions.Fraction(-1,2),fractions.Fraction(),fractions.Fraction(1,2)):
|
||||
onset = span.start+offset
|
||||
if start+1<=onset<release-1:
|
||||
times.append((onset,span.start if offset<0 else onset))
|
||||
onset,when = self.choose(tuple(dict.fromkeys(times)),'arrival_time',(start,variant))
|
||||
effect = 'handoff' if end==self.end else 'complete' if any(self.cadence(s.start) for s in self.harmony.spans if onset<=s.start<release) or self.cadence(when) else 'settle'
|
||||
options = self.arrival_options(onset,release,when,center,effect,end)
|
||||
if not options:
|
||||
return None
|
||||
result.append(self.destination_choice(options,center,'destination',(start,variant)))
|
||||
limit = result[0].note.start if result else end
|
||||
regions = [s for i,s in enumerate(self.harmony.spans) if s.scale and i and
|
||||
self.harmony.spans[i-1].chord!=s.chord and start+3<=s.start<limit-3]
|
||||
if regions:
|
||||
# Local repose is optional; it is not closure at every chord/cell.
|
||||
region = self.choose((*regions,None),'local_settle',(start,variant))
|
||||
if region:
|
||||
offset = self.choose((fractions.Fraction(-1,2),fractions.Fraction(),fractions.Fraction(1,2)), 'local_time',(start,variant))
|
||||
onset = region.start+offset
|
||||
duration,space = self.choose(self.arrivals,'local_duration',(start,variant))
|
||||
release = min(region.end,region.start+duration,limit-1)
|
||||
resume = min(release+space,limit-1)
|
||||
local_center = round((center+result[0].note.pitch.number())/2) if result else center
|
||||
options = self.arrival_options(onset,release,region.start if offset<0 else onset,local_center,'settle',resume)
|
||||
if options:
|
||||
result.insert(0,self.destination_choice(options,local_center,'local_destination',(start,variant)))
|
||||
return tuple(result)
|
||||
|
||||
@functools.lru_cache(maxsize=128)
|
||||
def developments(self,motif,purpose):
|
||||
if not motif:
|
||||
return ()
|
||||
if purpose=='return':
|
||||
return (motif,)
|
||||
values = []
|
||||
# The characteristic pitch relationships can survive rhythmic recasting.
|
||||
# Source holds remain source music; they do not become obligatory holds
|
||||
# at the start of every generated development.
|
||||
cursor = fractions.Fraction(); compact = []
|
||||
for i,(a,b) in enumerate(motif.rhythm):
|
||||
gap = a-motif.rhythm[i-1][1] if i else a
|
||||
cursor += min(gap,fractions.Fraction(1,2))
|
||||
length = min(b-a,fractions.Fraction(1))
|
||||
compact.append((cursor,cursor+length)); cursor += length
|
||||
if generator.solo.groups.valid(tuple(compact)):
|
||||
values.append(Idea(tuple(compact),motif.intervals,motif.source+':rephrase'))
|
||||
if purpose=='extend':
|
||||
# Reopen the provisional ending and continue its motion. This is an
|
||||
# edit to the existing idea, not a finished idea followed by a new one.
|
||||
a,b = compact[-1]
|
||||
stop = a+min(b-a,fractions.Fraction(1,2))
|
||||
rhythm = (*compact[:-1],(a,stop),(stop,stop+fractions.Fraction(1,2)),(stop+fractions.Fraction(1,2),stop+1))
|
||||
step = 2 if motif.intervals[-1]>=motif.intervals[-2] else -2
|
||||
values.append(Idea(rhythm,(*motif.intervals,motif.intervals[-1]+step,motif.intervals[-1]+2*step),motif.source+':extend'))
|
||||
for name,pattern,length,roles in self.patterns:
|
||||
if (len(pattern)==len(motif.intervals) and 1<=length<=4 and
|
||||
set(roles)&{'entrance','continue','connect','develop','answer','scalar'}):
|
||||
values.append(Idea(pattern,motif.intervals,motif.source+':rhythm:'+name))
|
||||
# A full rhythmic recurrence is still a legitimate candidate, not a duty.
|
||||
values.append(motif)
|
||||
return tuple(dict(((v.rhythm,v.intervals),v) for v in values).values())
|
||||
|
||||
@functools.lru_cache(maxsize=1024)
|
||||
def rhythm_candidates(self,cursor,remaining,approaching,fast_allowed):
|
||||
desired_roles = {'connect','continue','scalar'} if approaching else {'continue','connect','develop','answer','scalar','arpeggio'}
|
||||
eligible = []
|
||||
for length,values in sorted(self.patterns_by_length.items()):
|
||||
if not min(remaining,2)<=length<=remaining:
|
||||
continue
|
||||
for value in values:
|
||||
name,pattern,length,roles = value
|
||||
fast = any((b-a).denominator not in (1,2) for a,b in pattern)
|
||||
if (set(roles)&desired_roles and (not fast or fast_allowed) and
|
||||
generator.solo.groups.valid(tuple((cursor+a,cursor+b) for a,b in pattern))):
|
||||
eligible.append(value)
|
||||
return tuple(eligible)
|
||||
|
||||
def scale_step(self,scale,number,step):
|
||||
"""Continue from sounding pitch, never transfer a degree between maps."""
|
||||
try:
|
||||
index = generator.solo.types.scale_index(scale,number)
|
||||
except ValueError:
|
||||
# The preceding attack may belong to the previous collection. Its
|
||||
# pitch stays untouched; enter the new one in the requested direction.
|
||||
if not step:
|
||||
return None
|
||||
entries = [generator.solo.types.scale_pitch(scale,i) for i in range(-28,29)]
|
||||
entries = [p for p in entries if (p.number()-number)*step>0]
|
||||
if not entries:
|
||||
return None
|
||||
nearest = min(entries,key=lambda p:abs(p.number()-number))
|
||||
index = generator.solo.types.scale_index(scale,nearest.number())
|
||||
step -= 1 if step>0 else -1
|
||||
return generator.solo.types.scale_pitch(scale,index+step)
|
||||
|
||||
def route(self,rhythm,contour,incoming,center,mode='develop',*,degree_path=False,target=None):
|
||||
values = self.routes(rhythm,contour,incoming,center,mode,degree_path=degree_path,target=target,limit=1)
|
||||
return values[0] if values else None
|
||||
|
||||
def routes(self,rhythm,contour,incoming,center,mode='develop',*,degree_path=False,target=None,limit=3):
|
||||
return self._routes(rhythm,contour,incoming,center,mode,degree_path,target,limit,self.enabled)
|
||||
|
||||
@functools.lru_cache(maxsize=1024)
|
||||
def _routes(self,rhythm,contour,incoming,center,mode,degree_path,target,limit,enabled):
|
||||
"""Keep complete paths toward a known future event, not one next step."""
|
||||
if not rhythm:
|
||||
return ()
|
||||
initial = incoming.pitch.number() if incoming else center
|
||||
if incoming and mode in ('connect','arpeggio'):
|
||||
direction = (1 if target.pitch.number()>=initial else -1) if target else (1 if contour[-1]>=contour[0] else -1)
|
||||
center = initial+direction*2
|
||||
options = self.options(*rhythm[0],center)
|
||||
if mode=='arpeggio':
|
||||
options = tuple(v for v in options if v[0].pitch.number()%12 in self.harmony.at(rhythm[0][0]).roles.basic)
|
||||
starts = sorted(options,key=lambda v:(abs(v[0].pitch.number()-center)+max(0,abs(v[0].pitch.number()-initial)-5),v[0].pitch.number()))[:6]
|
||||
states = [(abs(n.pitch.number()-initial)/4,(n,),c,n.pitch.number()) for n,c in starts]
|
||||
def finish_distance(note):
|
||||
if target is None:
|
||||
return 0
|
||||
distance = abs(note.pitch.number()-target.pitch.number())
|
||||
return max(0,distance-2)*2+int(distance==0 and note.end==target.start)*2
|
||||
for index,(a,b) in enumerate(rhythm[1:],1):
|
||||
next_states = []
|
||||
for cost,notes,contracts,origin in states:
|
||||
wanted = origin+contour[min(index,len(contour)-1)]
|
||||
expected = contour[min(index,len(contour)-1)]-contour[min(index-1,len(contour)-1)]
|
||||
span = self.harmony.at(a)
|
||||
if degree_path:
|
||||
if not span or not span.scale:
|
||||
continue
|
||||
if target is not None:
|
||||
# Use octave-aware indices when both pitches belong to this map.
|
||||
# Across maps the target remains an actual sounding pitch.
|
||||
try:
|
||||
displacement = generator.solo.types.scale_index(span.scale,target.pitch.number())-generator.solo.types.scale_index(span.scale,notes[-1].pitch.number())
|
||||
step = max(-2,min(2,round(displacement/(len(rhythm)-index+1))))
|
||||
except ValueError:
|
||||
step = 1 if target.pitch.number()>notes[-1].pitch.number() else -1
|
||||
pitch = self.scale_step(span.scale,notes[-1].pitch.number(),step)
|
||||
else:
|
||||
pitch = self.scale_step(span.scale,notes[-1].pitch.number(),expected)
|
||||
if pitch is None:
|
||||
continue
|
||||
wanted = pitch.number()
|
||||
expected = wanted-notes[-1].pitch.number()
|
||||
for note,support in self.options(a,b,wanted):
|
||||
delta = note.pitch.number()-notes[-1].pitch.number()
|
||||
if degree_path:
|
||||
if target is None and note.pitch.number()!=wanted:
|
||||
continue
|
||||
if target is not None:
|
||||
try:
|
||||
step = generator.solo.types.scale_index(span.scale,note.pitch.number())-generator.solo.types.scale_index(span.scale,notes[-1].pitch.number())
|
||||
if abs(step)>2:
|
||||
continue
|
||||
except ValueError:
|
||||
if abs(delta)>3:
|
||||
continue
|
||||
if mode=='arpeggio' and (note.pitch.number()%12 not in span.roles.basic or delta==0):
|
||||
continue
|
||||
error = abs(note.pitch.number()-wanted)+3*int(delta==0 and expected!=0)+max(0,abs(delta)-7)*2
|
||||
if b-a>=1 and note.pitch.number()%12 not in span.roles.basic and not support:
|
||||
error += 2
|
||||
next_states.append((cost+error,(*notes,note),(*contracts,*support),origin))
|
||||
if not next_states:
|
||||
return ()
|
||||
def rank(state):
|
||||
# Admissible reachability pressure uses the actual remaining attacks;
|
||||
# a partial short route is never compared to a complete long route.
|
||||
distance = max(0,abs(state[1][-1].pitch.number()-target.pitch.number())-3*(len(rhythm)-index)) if target else 0
|
||||
return state[0]+distance,tuple(n.pitch.number() for n in state[1])
|
||||
states = generator.solo.sequences.diverse(next_states,rank,
|
||||
lambda s:(s[1][0].pitch.number(),s[1][-1].pitch.number()),8)
|
||||
values = []
|
||||
for cost,notes,contracts,_ in sorted(states,key=lambda s:(s[0]+finish_distance(s[1][-1]),tuple(n.pitch.number() for n in s[1]))):
|
||||
if self.harmony.evaluate(notes,expressions=contracts,complete=True).valid:
|
||||
values.append((notes,contracts))
|
||||
return tuple(values[:limit])
|
||||
|
||||
def contour(self,count,direction,mode,motif,time):
|
||||
if mode in ('statement','return','develop','answer') and motif:
|
||||
values = motif.intervals
|
||||
sign = 1
|
||||
# Explicit reduction/extension; never cycle a route to fit its rhythm.
|
||||
return tuple(sign*values[i] if i<len(values) else sign*values[-1]+direction*2*(i-len(values)+1) for i in range(count))
|
||||
paths = self.sequences.connecting(count) if self.sequences else ()
|
||||
paths = [p for p in paths if max(map(abs,p.steps))<=(3 if mode=='arpeggio' else 2) and (p.offsets()[-1]>0)==(direction>0) and sum(a*b<0 for a,b in zip(p.steps,p.steps[1:]))<=1]
|
||||
span = self.harmony.at(time)
|
||||
if paths and span and span.scale:
|
||||
path = min(paths,key=lambda p:(abs(abs(p.offsets()[-1])-(count-1)),p.rank()))
|
||||
if mode=='connect':
|
||||
return path.offsets()
|
||||
base = generator.solo.types.scale_pitch(span.scale,0)
|
||||
return tuple(generator.solo.types.scale_pitch(span.scale,i).number()-base.number() for i in path.offsets())
|
||||
if mode=='connect':
|
||||
return tuple(direction*i for i in range(count))
|
||||
return tuple(direction*i*(3 if mode=='arpeggio' else 2) for i in range(count))
|
||||
|
||||
def approach(self,arrival,start,variant):
|
||||
"""Compose a complete optional lead-in before choosing the body rhythm."""
|
||||
if not self.enabled:
|
||||
return (),()
|
||||
family = self.choose(('direct','approach','enclosure'),'approach_family',(start,variant,arrival.note.start))
|
||||
if family=='direct':
|
||||
return (),()
|
||||
rules = [r for r in generator.vocabulary.data('lead_ins')['patterns'] if r['kind']==family and
|
||||
(not r.get('difficulty') or self.analysis.tempo<=generator.vocabulary.data('roles')['difficulty'][r['difficulty']+'_max_bpm'])]
|
||||
for rule in sorted(rules,key=lambda r:generator.solo.policy.stable_rank(self.seed+'lead_in',(start,variant,r['id']))):
|
||||
gaps = tuple(map(fractions.Fraction,rule.get('internal_gaps',[])))
|
||||
cursor = arrival.note.start-sum(map(fractions.Fraction,rule['durations']),fractions.Fraction())-sum(gaps,fractions.Fraction())-fractions.Fraction(rule['gap'])
|
||||
if cursor<start+1:
|
||||
continue
|
||||
ns = []
|
||||
for i,(step,length) in enumerate(zip(rule['semitones'],rule['durations'])):
|
||||
pitch = generator.solo.expressions.chromatic(arrival.note.pitch,step)
|
||||
ns.append(self.note(cursor,cursor+fractions.Fraction(length),pitch,family))
|
||||
cursor = ns[-1].end+(gaps[i] if i<len(gaps) else 0)
|
||||
if any(not self.harmony.profile.permits(n.pitch.number()) for n in ns):
|
||||
continue
|
||||
early = next((c.boundary for c in arrival.contracts if c.kind=='anticipation'),None)
|
||||
c = generator.solo.types.ExpressionContract(arrival.note.event_id+':'+rule['id'],family,rule['id'],tuple(ns),
|
||||
arrival.note,arrival.anchor.chord,boundary=early)
|
||||
if self.harmony.evaluate((*ns,arrival.note),expressions=(*arrival.contracts,c),complete=True).valid:
|
||||
return tuple(ns),(c,)
|
||||
return (),()
|
||||
|
||||
def make(self,start,end,incoming,home,history,variant):
|
||||
if not any(s.scale and s.start<end and s.end>start for s in self.harmony.spans):
|
||||
self.proposals['space'] += 1
|
||||
evaluation = generator.solo.selection.evaluate((),start,end,self.harmony)
|
||||
return generator.solo.types.Phrase('space:'+str(start),start,end,(),'space','level','open',evaluation=evaluation),()
|
||||
motif = idea(home,'realized_theme') if home else self.choose(self.ideas,'source',variant) if self.ideas else None
|
||||
purpose = 'statement' if not home else self.choose(('return','develop','answer','extend'),'purpose',(start,variant))
|
||||
if purpose=='return' and history and history[-1].purpose=='return':
|
||||
purpose = 'develop'
|
||||
self.proposals[purpose] += 1
|
||||
center = self.center+self.choose((-5,0,5),'register',(start,variant))
|
||||
if purpose=='answer' and home:
|
||||
movement = home[-1].pitch.number()-home[0].pitch.number()
|
||||
center -= 3 if movement>0 else -3 if movement<0 else 0
|
||||
if incoming:
|
||||
center = max(incoming.pitch.number()-7,min(incoming.pitch.number()+7,center))
|
||||
destinations = self.destinations(start,end,center,variant)
|
||||
if destinations is None:
|
||||
self.rejections['destination_feasibility'] += 1; return None
|
||||
ending = destinations[-1].anchor.effect if destinations and destinations[-1].resume==end else 'open'
|
||||
terminal = destinations[-1].anchor if ending!='open' else None
|
||||
developments = self.developments(motif,purpose)
|
||||
notes,contracts,sources,rhythm_groups = [],[],[],[]
|
||||
cursor = start
|
||||
if start>=0 and purpose!='return':
|
||||
cursor += self.choose((fractions.Fraction(),fractions.Fraction(1,2),fractions.Fraction(1)),'entrance',(start,variant))
|
||||
signature_notes = (); group_index = 0; fast_groups = 0
|
||||
landmarks = (*destinations,*((None,) if not destinations or destinations[-1].resume<end else ()))
|
||||
for landmark in landmarks:
|
||||
approach,support = self.approach(landmark,cursor,variant) if landmark else ((),())
|
||||
stop = approach[0].start if approach else landmark.note.start if landmark else end
|
||||
target = approach[0] if approach else landmark.note if landmark else None
|
||||
while cursor<stop:
|
||||
span = self.harmony.at(cursor)
|
||||
if not span or not span.scale:
|
||||
cursor = min(stop,span.end if span else stop); continue
|
||||
remaining = stop-cursor
|
||||
choices = [v for v in developments if v.length<=remaining and generator.solo.groups.valid(tuple((cursor+a,cursor+b) for a,b in v.rhythm))]
|
||||
if group_index==0 and choices:
|
||||
motif = self.choose(choices,'theme_rhythm',(start,variant))
|
||||
pattern,length,source = motif.rhythm,motif.length,motif.source
|
||||
mode = purpose if purpose!='extend' else 'develop'
|
||||
else:
|
||||
# Select complete rhythmic groups by their musical function, keeping
|
||||
# ordered timing diversity until pitches and destination are known.
|
||||
eligible = self.rhythm_candidates(cursor,remaining,bool(target and remaining<=4),
|
||||
fast_groups<generator.vocabulary.data('solo')['subdivision_groups'])
|
||||
if eligible:
|
||||
# Families have equal standing; the combinatorial catalog's file
|
||||
# count never determines how often its material is requested.
|
||||
families = {}
|
||||
for value in eligible:
|
||||
family = 'catalog' if value[0] not in self.authored_ids else 'authored'
|
||||
families.setdefault(family,[]).append(value)
|
||||
family = self.choose(tuple(families),'rhythm_family',(start,variant,group_index))
|
||||
source,pattern,length,_ = self.choose(families[family],'rhythm',(start,variant,group_index))
|
||||
else:
|
||||
# An explicit moving primitive, never an arbitrary long hold used
|
||||
# to fill a leftover span. Tuplets may only enter as whole groups.
|
||||
length = min(fractions.Fraction(1,2),remaining)
|
||||
pattern,source = ((fractions.Fraction(),length),),'eighth_continuation'
|
||||
if not generator.solo.groups.valid(tuple((cursor+a,cursor+b) for a,b in pattern)):
|
||||
self.rejections['subdivision_remainder'] += 1; return None
|
||||
mode = 'connect' if target and remaining<=4 else self.choose(('connect','develop','arpeggio'),'movement',(start,variant,group_index))
|
||||
rhythm = tuple((cursor+a,cursor+b) for a,b in pattern)
|
||||
previous = notes[-1] if notes else incoming
|
||||
direction = 1 if target and target.pitch.number()> (previous.pitch.number() if previous else center) else -1 if target else self.choose((-1,1),'direction',(start,variant))
|
||||
desired = self.contour(len(pattern),direction,mode,motif,cursor)
|
||||
# Only the group immediately reaching a target must finish its approach.
|
||||
# Earlier material sees the future register but can retain its identity.
|
||||
next_target = target if cursor+length==stop else None
|
||||
local_center = round((center+target.pitch.number())/2) if target else center
|
||||
routes = self.routes(rhythm,desired,previous,local_center,mode,degree_path=mode=='connect',target=next_target)
|
||||
if not routes:
|
||||
self.rejections['route'] += 1; return None
|
||||
ns,cs = routes[variant%len(routes)]
|
||||
if not signature_notes and group_index==0 and len(ns)>=3:
|
||||
signature_notes = ns
|
||||
fast_groups += int(any((b-a).denominator not in (1,2) for a,b in pattern))
|
||||
notes.extend(ns); contracts.extend(cs); sources.append(source)
|
||||
rhythm_groups.append(generator.solo.groups.RhythmGroup(cursor,cursor+length,'gesture',rhythm,
|
||||
cursor%1,source+':'+mode))
|
||||
cursor += length; group_index += 1
|
||||
if landmark:
|
||||
if approach and notes and notes[-1].end==approach[0].start and notes[-1].pitch==approach[0].pitch:
|
||||
# Recompose the join as a direct route in another candidate; never
|
||||
# keep an accidental duplicated entrance because the label is valid.
|
||||
self.rejections['duplicated_approach_entrance'] += 1; return None
|
||||
notes.extend(approach); contracts.extend(support)
|
||||
note = landmark.note
|
||||
if notes and not approach and not landmark.contracts and notes[-1].end==note.start and notes[-1].pitch==note.pitch:
|
||||
before = notes[-1]
|
||||
used = {n.event_id for c in contracts for n in (*c.events,c.target)}
|
||||
held = dataclasses.replace(note,start=before.start,event_id=note.event_id+':carried')
|
||||
if before not in signature_notes and before.event_id not in used and self.harmony.evaluate((held,),complete=True).valid:
|
||||
notes.pop(); note=held
|
||||
notes.append(note); contracts.extend(landmark.contracts)
|
||||
rhythm_groups.append(generator.solo.groups.RhythmGroup(
|
||||
approach[0].start if approach else note.start,landmark.resume,'arrival',
|
||||
tuple((n.start,n.end) for n in (*approach,note)),note.start%1,landmark.anchor.effect))
|
||||
sources.append('destination:'+landmark.anchor.effect)
|
||||
cursor = landmark.resume
|
||||
if not notes:
|
||||
return None
|
||||
if self.enabled and variant%3:
|
||||
# Only genuinely local refinements remain here. Every committed
|
||||
# arrival and the treatment's identifying events are protected.
|
||||
protected = (*signature_notes,*(n for n in notes if any(n.start<=d.note.start and n.end>=d.note.end and n.pitch==d.note.pitch for d in destinations)))
|
||||
notes,contracts = self.expression(tuple(notes),tuple(contracts),protected,start,variant)
|
||||
checked = self.harmony.evaluate(notes,expressions=contracts,until=end,complete=True)
|
||||
if not checked.valid:
|
||||
self.rejections[checked.error] += 1; return None
|
||||
anchors = tuple(d.anchor for d in destinations)
|
||||
evaluation = generator.solo.selection.evaluate(notes,start,end,self.harmony,incoming=(incoming,) if incoming else (),
|
||||
home=home,purpose=purpose,contracts=contracts,center=center,destinations=anchors,history=history,identity=signature_notes)
|
||||
if not evaluation.valid:
|
||||
self.rejections['arrival_contract'] += 1; return None
|
||||
key = generator.solo.policy.stable_rank(self.seed,(start,end,variant))[:12]
|
||||
pattern = tuple((n.start-start,n.end-start) for n in notes)
|
||||
try:
|
||||
generator.solo.groups.describe(pattern,origin=start,source=purpose)
|
||||
except ValueError:
|
||||
self.rejections['rhythm_group'] += 1; return None
|
||||
# Refine diagnostics against the chosen sounding events. Local expression
|
||||
# and shared arrival sustains must not leave stale pre-edit rhythm reports.
|
||||
rhythm_groups = tuple(dataclasses.replace(g,events=tuple((n.start,n.end) for n in notes
|
||||
if g.start<=n.start<g.end)) for g in rhythm_groups)
|
||||
direction = 'up' if notes[-1].pitch.number()>notes[0].pitch.number() else 'down' if notes[-1].pitch.number()<notes[0].pitch.number() else 'returning'
|
||||
phrase = generator.solo.types.Phrase(key,start,end,tuple(notes),purpose,direction,ending,
|
||||
terminal,tuple(contracts),rhythm_groups,'home' if home else key,float(evaluation.total),evaluation,tuple(sources),anchors,signature_notes)
|
||||
return phrase,signature_notes
|
||||
|
||||
def expression(self,notes,contracts,protected,start,variant):
|
||||
protected_ids = {n.event_id for n in protected}
|
||||
used = {n.event_id for c in contracts for n in (*c.events,c.target)}
|
||||
targets = [n for n in notes if n.event_id not in used|protected_ids and n.end-n.start>=1 and n.pitch.number()%12 in self.harmony.members(n.start,n.end)]
|
||||
if not targets:
|
||||
return notes,contracts
|
||||
target = self.choose(targets,'expression_target',(start,variant))
|
||||
previous = next((n for n in reversed(notes) if n.end<=target.start),None)
|
||||
region = self.harmony.at(target.start)
|
||||
editable = previous and previous.event_id not in used|protected_ids and previous.end==target.start
|
||||
if editable and region.start==target.start and target is not notes[-1] and target.end-target.start>=fractions.Fraction(3,2):
|
||||
for rule in generator.vocabulary.data('harmony').get('suspensions',[]):
|
||||
if target.pitch.number()-previous.pitch.number() not in rule['steps']:
|
||||
continue
|
||||
held = dataclasses.replace(previous,end=target.start+fractions.Fraction(1,2),event_id=previous.event_id+':held',role='suspension')
|
||||
resolved = dataclasses.replace(target,start=held.end,event_id=target.event_id+':resolved')
|
||||
c = generator.solo.types.ExpressionContract(held.event_id,'suspension',rule['id'],(held,),resolved,region.chord,boundary=region.start)
|
||||
revised = tuple(held if n is previous else resolved if n is target else n for n in notes)
|
||||
if self.harmony.evaluate(revised,expressions=(*contracts,c),complete=True).valid:
|
||||
return revised,(*contracts,c)
|
||||
# Anticipation changes the actual rhythm before phrase selection. The
|
||||
# original committed arrival interval remains sounding in its entirety.
|
||||
if editable and region.start==target.start and previous.end-previous.start>=1:
|
||||
early = dataclasses.replace(target,start=target.start-fractions.Fraction(1,2),event_id=target.event_id+':early')
|
||||
before = dataclasses.replace(previous,end=early.start,event_id=previous.event_id+':short')
|
||||
contract = generator.solo.types.ExpressionContract(early.event_id,'anticipation','early_chord_tone',(early,),early,region.chord,boundary=region.start)
|
||||
revised = tuple(early if n is target else before if n is previous else n for n in notes)
|
||||
if self.harmony.evaluate(revised,expressions=(*contracts,contract),complete=True).valid:
|
||||
return revised,(*contracts,contract)
|
||||
# Altered colors resolve to an existing destination; the rule, not the
|
||||
# annotation or accidental, authorizes their complete exposed interval.
|
||||
if editable and variant%2 and previous.end-previous.start<=1:
|
||||
span = self.harmony.at(previous.start)
|
||||
for rule in generator.vocabulary.data('harmony')['colors']:
|
||||
if not rule.get('resolve') or not generator.solo.expressions.eligible_color(rule,span,self.harmony):
|
||||
continue
|
||||
pitch = generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],target.pitch.number())
|
||||
color = dataclasses.replace(previous,pitch=pitch,event_id=previous.event_id+':color',role='color')
|
||||
c = generator.solo.types.ExpressionContract(color.event_id,'color',rule['id'],(color,),target,region.chord)
|
||||
revised = tuple(color if n is previous else n for n in notes)
|
||||
if self.harmony.profile.permits(pitch.number()) and self.harmony.evaluate(revised,expressions=(*contracts,c),complete=True).valid:
|
||||
return revised,(*contracts,c)
|
||||
rules = [r for r in generator.vocabulary.data('lead_ins')['patterns'] if r['kind'] in ('approach','enclosure') and (not r.get('difficulty') or self.analysis.tempo<=generator.vocabulary.data('roles')['difficulty'][r['difficulty']+'_max_bpm'])]
|
||||
rules = sorted(rules,key=lambda r:generator.solo.policy.stable_rank(self.seed+'expression',(start,variant,r['id'])))
|
||||
for rule in rules:
|
||||
gaps = tuple(map(fractions.Fraction,rule.get('internal_gaps',[])))
|
||||
cursor = target.start-sum(map(fractions.Fraction,rule['durations']),fractions.Fraction())-sum(gaps,fractions.Fraction())-fractions.Fraction(rule['gap'])
|
||||
affected = [n for n in notes if n.start<target.start and n.end>cursor]
|
||||
if cursor<start or any(n.event_id in used|protected_ids or n.start<cursor for n in affected):
|
||||
continue
|
||||
gesture = []
|
||||
for i,(delta,length) in enumerate(zip(rule['semitones'],rule['durations'])):
|
||||
pitch = generator.solo.expressions.chromatic(target.pitch,delta)
|
||||
gesture.append(self.note(cursor,cursor+fractions.Fraction(length),pitch,rule['kind']))
|
||||
cursor += fractions.Fraction(length)+(gaps[i] if i<len(gaps) else 0)
|
||||
if any(not self.harmony.profile.permits(n.pitch.number()) for n in gesture):
|
||||
continue
|
||||
before = next((n for n in reversed(notes) if n.end<=gesture[0].start),None)
|
||||
if before and (abs(before.pitch.number()-gesture[0].pitch.number())>7 or before.end==gesture[0].start and before.pitch==gesture[0].pitch):
|
||||
continue
|
||||
revised = tuple(sorted((*[n for n in notes if n not in affected],*gesture),key=lambda n:n.start))
|
||||
contract = generator.solo.types.ExpressionContract(target.event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(gesture),target,self.harmony.at(target.start).chord)
|
||||
if self.harmony.evaluate(revised,expressions=(*contracts,contract),complete=True).valid:
|
||||
return revised,(*contracts,contract)
|
||||
return notes,contracts
|
||||
@@ -1,10 +1,5 @@
|
||||
"""Physical/harmonic constants and semantic generation version."""
|
||||
import dataclasses, hashlib
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Policy:
|
||||
long_note: float = 2.
|
||||
stopping_note: float = 1.
|
||||
composition_version: str = 'directed-phrases-2'
|
||||
DEFAULT=Policy()
|
||||
def stable_rank(seed, value):
|
||||
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
|
||||
"""Stable deterministic tie breaking shared with guitar and accompaniment."""
|
||||
import hashlib
|
||||
|
||||
def stable_rank(seed,value):
|
||||
return hashlib.sha256((seed+repr(value)).encode()).hexdigest()
|
||||
|
||||
@@ -34,9 +34,4 @@ def for_part(part, analysis=None):
|
||||
strings = (1, 2, 3, 4, 5, 6)
|
||||
if analysis is None or not analysis.melody:
|
||||
return Instrument(part, strings)
|
||||
# Source identity is independent of sounding register. Roles have soft,
|
||||
# overlapping centers; all legal positions remain available to both guitars.
|
||||
center=generator.vocabulary.role(part+'_solo').get('register_center',57 if part=='acoustic' else 62)
|
||||
notes=tuple(n for n in analysis.melody if n.end>analysis.body_offset)
|
||||
return Instrument(part,strings,center,
|
||||
(min(n.pitch.number() for n in notes),max(n.pitch.number() for n in notes)))
|
||||
return Instrument(part,strings)
|
||||
|
||||
@@ -11,33 +11,30 @@ def plain(value):
|
||||
return value
|
||||
|
||||
def structure(solo):
|
||||
harmony=harmonic.Harmony(solo.choices)
|
||||
harmony=harmonic.Harmony(solo.harmony)
|
||||
contracts=tuple(c for p in solo.phrases for c in p.expressions)
|
||||
checked=harmony.evaluate(solo.notes(),until=solo.end,complete=True,expressions=contracts)
|
||||
phrases=[];endings=[]
|
||||
authored=solo.diagnostics.get('construction')=='authored_repertoire'
|
||||
phrases=[]
|
||||
for p in solo.phrases:
|
||||
anchor=p.destination
|
||||
completed=anchor is None or harmonic.anchor_note(anchor,p.notes) is not None
|
||||
entry={'id':p.id,'start':str(p.start),'end':str(p.end),'purpose':p.purpose,'direction':p.direction,'ending':p.ending,
|
||||
'theme':p.theme,'notes':plain(p.notes),'rhythm_groups':plain(p.groups),'sources':p.sources,
|
||||
'anchors':[{**plain(a),'pitch':a.pitch.label(),'completed':harmonic.anchor_note(a,p.notes) is not None} for a in p.arrivals or ((anchor,) if anchor else ())],
|
||||
'identifying_events':plain(p.identity),
|
||||
'ending_completed':completed,'evaluation':plain(p.evaluation),'score':p.score,'expressions':plain(p.expressions)}
|
||||
'evaluation':plain(p.evaluation),'score':p.score,'expressions':plain(p.expressions)}
|
||||
phrases.append(entry)
|
||||
if anchor:endings.append({'phrase':p.id,'effect':p.ending,'arrival':str(anchor.earliest),'pitch':anchor.pitch.label(),'required_beats':str(anchor.duration),'completed':completed})
|
||||
ns=solo.notes()
|
||||
duration=solo.end-solo.start
|
||||
return {'schema':'composer-report-3','selection_trace':plain({k:v for k,v in solo.diagnostics.items() if not k.endswith('_seconds')}),
|
||||
'instrument_profile':plain(solo.profile),'phrases':phrases,'expressions':plain(contracts),'fretboard':fretboard_report(solo),
|
||||
'phrasing':{'endings':endings,'attacks':len(ns),'allocation_beats':str(duration),
|
||||
'phrasing':{'attacks':len(ns),'allocation_beats':str(duration),
|
||||
'attacks_per_four_beats':4*len(ns)/float(duration) if duration else 0,
|
||||
'occupancy':sum(float(n.end-n.start) for n in ns)/float(duration) if duration else 0},
|
||||
'harmonic_roles':harmony.describe(ns,expressions=contracts),'harmonic_valid':checked.valid,'harmonic_error':checked.error,
|
||||
'harmonic_roles':([] if authored else harmony.describe(ns,expressions=contracts)),'harmonic_policy':'authored (generated policy diagnostic only)' if authored else 'generated', 'harmonic_valid':checked.valid,'harmonic_error':checked.error,
|
||||
'harmonic_exposure':checked.exposure,'unresolved_treatments':plain(checked.pending)}
|
||||
|
||||
def describe(solo,scales,measure_offset=0,time_offset=0,*,detail=None):
|
||||
def describe(solo,measure_offset=0,time_offset=0,*,detail=None):
|
||||
detail=detail or structure(solo)
|
||||
lines=[f'Independent melodic-phrase solo: {solo.part}', 'Times are exact quarter beats from the chorus start; negative times are allocated lead-ins. Pitches sound one octave below guitar notation.',
|
||||
lines=[f'{solo.diagnostics.get("construction","solo")}: {solo.part}', 'Times are exact quarter beats from the chorus start; negative times are allocated lead-ins. Pitches sound one octave below guitar notation.',
|
||||
'Approximate fingering costs are not a biomechanical guarantee.',json.dumps(detail['phrasing'])]
|
||||
for p in solo.phrases:
|
||||
lines.append(f'{p.id}: {p.start}–{p.end}; {p.purpose}, {p.direction}, {p.ending}; theme {p.theme}; score {p.score:.4f}')
|
||||
@@ -61,4 +58,4 @@ def fretboard_report(solo):
|
||||
'duration_fraction_by_string':{s:d/max(.001,total) for s,d in sorted(durations.items())},
|
||||
'first_string_forced':forced,'first_string_optional':optional,
|
||||
'range_midi': [min(n.pitch.number() for n in notes),max(n.pitch.number() for n in notes)] if notes else [],
|
||||
'cross_bar_sounding_events':sum(any(n.start < t < n.end for t in {c.span.start for c in solo.choices if c.span.beat == 1}) for n in notes)}
|
||||
'cross_bar_sounding_events':sum(any(n.start < t < n.end for t in {c.start for c in solo.harmony if c.beat == 1}) for n in notes)}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Complete generated rhythm groups, indexed once, independent of source rhythms."""
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary
|
||||
from generator.solo import groups
|
||||
|
||||
class Rhythms:
|
||||
def __init__(self, library, tempo):
|
||||
self.groups=[]; limits=vocabulary.data('roles')['difficulty']
|
||||
for item in vocabulary.data('solo')['groups']:
|
||||
difficulty=item.get('difficulty')
|
||||
if difficulty and tempo>limits[difficulty+'_max_bpm']:continue
|
||||
cursor=F();pattern=[]
|
||||
for value in item['values']:
|
||||
duration=F(value.removeprefix('r:'))
|
||||
if not value.startswith('r:'):pattern.append((cursor,cursor+duration))
|
||||
cursor+=duration
|
||||
self.groups.append((item['id'],tuple(pattern),cursor,tuple(item['purpose'])))
|
||||
for cell in vocabulary.rhythm_cells(tempo):
|
||||
# Imported authored cells remain whole. They are never sliced to fit.
|
||||
if groups.valid(cell.pattern):self.groups.append((cell.id,cell.pattern,cell.duration,(cell.purpose,)))
|
||||
# Meter-indexed catalog representatives: file counts do not set style frequency.
|
||||
self.catalog={}
|
||||
for meter,items in library.snippets.items():
|
||||
representatives={}
|
||||
for item in items:
|
||||
for bar in item.bars:
|
||||
pattern=tuple((e.onset,e.onset+e.duration) for e in bar.events if e.sounding)
|
||||
if any(e.continuation for e in bar.events) or not pattern or not groups.valid(pattern):continue
|
||||
if any(b-a<F(1,2) for a,b in pattern):continue
|
||||
descriptor=groups.descriptor(pattern,bar.length)
|
||||
representatives.setdefault(descriptor,(item.id,pattern,bar.length,('develop','answer')))
|
||||
self.catalog[meter]=tuple(representatives.values())
|
||||
@@ -1,13 +0,0 @@
|
||||
"""The complete-phrase evaluation record, independent of guitar travel."""
|
||||
from dataclasses import dataclass
|
||||
from fractions import Fraction as F
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Evaluation:
|
||||
version: str
|
||||
valid: bool
|
||||
raw: dict
|
||||
weights: dict
|
||||
contributions: dict
|
||||
total: F
|
||||
denominators: dict
|
||||
@@ -1,102 +0,0 @@
|
||||
"""One complete-event evaluator, including the declared musical relationships.
|
||||
|
||||
The inputs are realized notes and independently planned obligations. Action
|
||||
counts and constructor-family labels never award musical credit.
|
||||
"""
|
||||
import collections
|
||||
import fractions
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.scoring
|
||||
import generator.solo.types
|
||||
|
||||
VERSION = 'directed-phrases-2'
|
||||
WEIGHTS = {'continuity': 2, 'repetition': 3, 'interruption': 2, 'activity': 2,
|
||||
'harmony': 2, 'register': 1, 'identity': 3, 'arrival': 2}
|
||||
|
||||
|
||||
def signature(notes):
|
||||
notes = generator.solo.types.sounding_notes(notes)
|
||||
if not notes:
|
||||
return ()
|
||||
return tuple((n.start-notes[0].start, n.end-n.start, n.pitch.number()-notes[0].pitch.number()) for n in notes)
|
||||
|
||||
|
||||
def theme_distance(home, sample, purpose):
|
||||
if not home or purpose not in ('return','develop','answer','extend'):
|
||||
return fractions.Fraction()
|
||||
# Returns preserve the whole introduced signature; development preserves
|
||||
# its selected opening intervals, allowing the rhythm and conclusion to change.
|
||||
count = len(home) if purpose=='return' else min(3,len(home))
|
||||
if count<3 or len(sample)<count:
|
||||
return fractions.Fraction(2)
|
||||
a,b = signature(home[:count]),signature(sample[:count])
|
||||
intervals = [(x[i][2]-x[i-1][2]) for x in (a,b) for i in range(1,count)]
|
||||
left,right = intervals[:count-1],intervals[count-1:]
|
||||
pitch = sum(min(fractions.Fraction(1),fractions.Fraction(abs(x-y),4)) for x,y in zip(left,right))/(count-1)
|
||||
rhythm = sum((x[0],x[1])!=(y[0],y[1]) for x,y in zip(a,b))/fractions.Fraction(count) if purpose=='return' else 0
|
||||
return pitch+rhythm
|
||||
|
||||
|
||||
def figures(notes):
|
||||
return tuple(signature(notes[i:i+4]) for i in range(max(0,len(notes)-3)))
|
||||
|
||||
|
||||
def evaluate(notes, start, end, harmony, *, incoming=(), home=(), purpose='develop', contracts=(), center=60,
|
||||
destinations=(), history=(), identity=()):
|
||||
notes = generator.solo.types.sounding_notes(notes)
|
||||
duration = end-start
|
||||
if duration <= 0:
|
||||
raise ValueError('Evaluation requires a positive span')
|
||||
checked = harmony.evaluate(notes, expressions=contracts, until=end, complete=True)
|
||||
anchors_valid,_ = generator.solo.harmonic.anchors_valid(destinations,notes,end,complete=True,closure='closed')
|
||||
sounding = sum((n.end-n.start for n in notes), fractions.Fraction())
|
||||
pitches = [n.pitch.number() for n in notes]
|
||||
joined = [n.pitch.number() for n in incoming[-1:]]+pitches
|
||||
intervals = [b-a for a,b in zip(joined, joined[1:])]
|
||||
continuity = fractions.Fraction(sum(max(0, abs(d)-7) for d in intervals),max(1,len(intervals)))
|
||||
oscillation = sum(a == -b == c and a != 0 for a,b,c in zip(intervals,intervals[1:],intervals[2:]))
|
||||
patterns = collections.Counter(figures(notes))
|
||||
recent = collections.Counter(f for p in history[-3:] for f in figures(generator.solo.types.sounding_notes(p.notes)))
|
||||
# A single clear return is allowed. Repeating the same figure several times
|
||||
# still costs something; a "return" label is not unlimited immunity.
|
||||
overuse = sum(max(0,n+recent[f]-2) for f,n in patterns.items())
|
||||
repetition = fractions.Fraction(oscillation+sum(a==b for a,b in zip(pitches,pitches[1:]))+overuse,max(1,len(notes)))
|
||||
arrival_ids = {n.event_id for a in destinations if (n:=generator.solo.harmonic.anchor_note(a,notes))}
|
||||
interruption = fractions.Fraction(sum(a.end<b.start and a.end-a.start<1 and b.end-b.start<1
|
||||
and a.event_id not in arrival_ids for a,b in zip(notes,notes[1:])),max(1,len(notes)))
|
||||
density = fractions.Fraction(len(notes)*4,1)/duration
|
||||
active = any(s.scale and s.start<end and s.end>start for s in harmony.spans)
|
||||
activity = max(0,density-7)+(2 if active and not notes else 0)
|
||||
if notes and sounding<duration/3:
|
||||
activity += (duration/3-sounding)/duration
|
||||
# Unplanned broad notes in the moving portion have a different function from
|
||||
# a fulfilled arrival. This does not penalize the required held destination.
|
||||
motion = [n for n in notes if n.event_id not in arrival_ids and n.role!='quote']
|
||||
activity += sum((max(0,n.end-n.start-2) for n in motion),fractions.Fraction())/duration
|
||||
identity_cost = theme_distance(home,identity or notes,purpose)
|
||||
register = sum(( (n.end-n.start)*max(0,abs(n.pitch.number()-center)-10) for n in notes),fractions.Fraction())/max(1,sounding)
|
||||
arrival_cost = fractions.Fraction()
|
||||
for anchor in destinations:
|
||||
target = generator.solo.harmonic.anchor_note(anchor,notes)
|
||||
if target is None:
|
||||
arrival_cost += 2
|
||||
continue
|
||||
before = [n for n in notes if n.end<=target.start][-3:]
|
||||
if before:
|
||||
distances = [abs(n.pitch.number()-target.pitch.number()) for n in before]+[0]
|
||||
# A contour may turn. What matters here is whether the final approach
|
||||
# prepares its already selected destination, rather than jumping to it.
|
||||
arrival_cost += fractions.Fraction(max(0,distances[-2]-2),6)
|
||||
arrival_cost += fractions.Fraction(sum(max(0,b-a) for a,b in zip(distances,distances[1:])),12)
|
||||
if before[-1].end<target.start:
|
||||
supported_gap = any(c.target.event_id==target.event_id and c.events[-1].end==before[-1].end for c in contracts)
|
||||
if not supported_gap:
|
||||
arrival_cost += min(1,target.start-before[-1].end)
|
||||
arrival_cost /= max(1,len(destinations))
|
||||
values = dict(continuity=continuity,repetition=repetition,interruption=interruption,
|
||||
activity=activity,harmony=checked.exposure,register=register,identity=identity_cost,arrival=arrival_cost)
|
||||
raw = {k:fractions.Fraction(str(v)).limit_denominator(1000000) for k,v in values.items()}
|
||||
contributions = {k:v*WEIGHTS[k] for k,v in raw.items()}
|
||||
return generator.solo.scoring.Evaluation(VERSION,checked.valid and anchors_valid,raw,dict(WEIGHTS),contributions,
|
||||
sum(contributions.values(),fractions.Fraction()),{'span':str(duration),'attacks':len(notes),'sounding':str(sounding),
|
||||
'destinations':len(destinations),'identity_events':len(identity or notes[:len(home)]),'history_phrases':min(3,len(history))})
|
||||
@@ -1,87 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import pathlib
|
||||
import types
|
||||
import functools
|
||||
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Sequence:
|
||||
id: str
|
||||
steps: tuple[int, ...]
|
||||
@functools.lru_cache(maxsize=32768)
|
||||
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
|
||||
|
||||
def diverse(items, rank, category, limit):
|
||||
"""Round-robin eligible musical families before a destructive cutoff."""
|
||||
groups = {}
|
||||
# Rank once: hashing full event provenance and classifying interval paths
|
||||
# used to be repeated for every retained layer. Preserve stable tie order.
|
||||
decorated = sorted(((rank(item), i, item) for i,item in enumerate(items)),key=lambda v:v[:2])
|
||||
for value in decorated:
|
||||
groups.setdefault(category(value[2]), []).append(value)
|
||||
result, index = [], 0
|
||||
while len(result) < limit:
|
||||
layer = [values[index] for values in groups.values() if len(values) > index]
|
||||
if not layer:
|
||||
break
|
||||
# Equal ranks retain category insertion order, just as the original stable
|
||||
# layer sort did; the input index is only for the initial sort.
|
||||
result.extend(v[2] for v in sorted(layer,key=lambda v:v[0])[:limit-len(result)])
|
||||
index += 1
|
||||
return tuple(result)
|
||||
|
||||
@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))
|
||||
+2
-89
@@ -3,8 +3,6 @@ from __future__ import annotations
|
||||
import dataclasses, fractions, functools
|
||||
import generator.model
|
||||
import generator.fretboard
|
||||
import generator.scales.library
|
||||
import generator.scales.selection
|
||||
import generator.solo.profiles
|
||||
|
||||
|
||||
@@ -15,7 +13,7 @@ class Pitch:
|
||||
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')
|
||||
raise ValueError('solo pitches require spelled semitones')
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def __repr__(self):
|
||||
# Exactly the former dataclass representation: cache formatting without
|
||||
@@ -52,19 +50,6 @@ def sounding_notes(notes):
|
||||
result.append(note)
|
||||
return tuple(result)
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class HarmonicTreatment:
|
||||
id: str
|
||||
kind: str
|
||||
pitch: int
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
resolution: int | None = None
|
||||
deadline: fractions.Fraction | None = None
|
||||
preparation: fractions.Fraction = fractions.Fraction(1, 2)
|
||||
event: str = ''
|
||||
resolution_event: str = ''
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ExpressionContract:
|
||||
id: str
|
||||
@@ -76,76 +61,6 @@ class ExpressionContract:
|
||||
boundary: fractions.Fraction | None = None
|
||||
preparation: Note | None = None
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Anchor:
|
||||
id: str
|
||||
earliest: fractions.Fraction
|
||||
latest: fractions.Fraction
|
||||
pitch: Pitch
|
||||
eligible: frozenset[int]
|
||||
register: tuple[int, int]
|
||||
duration: fractions.Fraction
|
||||
role: str
|
||||
chord: str
|
||||
onset_required: bool = False
|
||||
hold_until: fractions.Fraction | None = None
|
||||
effect: str = 'local_settle'
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SustainedTail:
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
pitch: int
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class EnclosureContract:
|
||||
"""A complete, timed upper/lower approach to a previously chosen arrival.
|
||||
|
||||
These snapshots describe sounding events, independent of notation splits or
|
||||
guitar placement. Only the lower event may receive a scale exception.
|
||||
"""
|
||||
id: str
|
||||
upper: Note
|
||||
lower: Note
|
||||
target: Note
|
||||
chord: str
|
||||
target_role: str
|
||||
protected_tail: SustainedTail
|
||||
approach_start: fractions.Fraction | None = None
|
||||
|
||||
@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))
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def scale_index(scale: generator.scales.library.Scale, number: int, octave: int = 4) -> int:
|
||||
"""Exact inverse by sounding pitch; spelling never moves a held note."""
|
||||
root = scale_pitch(scale, 0, octave).number()
|
||||
turns, chromatic = divmod(number - root, 12)
|
||||
if chromatic not in scale.pattern.intervals:
|
||||
raise ValueError(f'{number} is outside {scale.label()}')
|
||||
return 7 * turns + scale.pattern.intervals.index(chromatic)
|
||||
|
||||
def permitted(choices: tuple[generator.scales.selection.Choice, ...], start: fractions.Fraction, end: fractions.Fraction) -> frozenset[int]:
|
||||
covered = start
|
||||
pitches = frozenset(range(12))
|
||||
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()
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Phrase:
|
||||
id: str
|
||||
@@ -155,20 +70,18 @@ class Phrase:
|
||||
purpose: str
|
||||
direction: str
|
||||
ending: str
|
||||
destination: Anchor | None = None
|
||||
expressions: tuple[ExpressionContract, ...] = ()
|
||||
groups: tuple = ()
|
||||
theme: str = ''
|
||||
score: float = 0.
|
||||
evaluation: object = None
|
||||
sources: tuple[str, ...] = ()
|
||||
arrivals: tuple[Anchor, ...] = ()
|
||||
identity: tuple[Note, ...] = ()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Solo:
|
||||
part: str
|
||||
choices: tuple
|
||||
harmony: tuple
|
||||
phrases: tuple[Phrase, ...]
|
||||
tempo: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Explicit, deterministic supervised training; never called by an asset build."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import time
|
||||
from fractions import Fraction as F
|
||||
from generator import harmony, model, musicxml, parts, training_io, vocabulary
|
||||
from generator import harmonic_time as timeline
|
||||
from generator.solo import analysis, learned
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Span:
|
||||
start:F
|
||||
end:F
|
||||
chord:str
|
||||
|
||||
|
||||
class Context:
|
||||
def __init__(self,document):
|
||||
self.spans=tuple(Span(s.start,s.end,harmony.symbol(s.chord) if s.chord else 'N.C.')
|
||||
for s in timeline.read(document['staves'][0]))
|
||||
def at(self,time):return next((s for s in self.spans if s.start<=time<s.end),None)
|
||||
|
||||
|
||||
def load_examples(manifest):
|
||||
manifest=Path(manifest);value=json.loads(manifest.read_text())
|
||||
if value.get('version')!=1:raise ValueError('Unsupported training manifest')
|
||||
examples=[];provenance=[];seen=set();pairs=set()
|
||||
for entry in sorted(value['examples'],key=lambda e:e['id']):
|
||||
if entry['id'] in seen or entry['slot'] not in (1,2):raise ValueError('Duplicate example or invalid role')
|
||||
seen.add(entry['id']);role='acoustic' if entry['slot']==1 else 'electric'
|
||||
source=(manifest.parent/entry['source']).resolve();solo=(manifest.parent/entry['solo']).resolve()
|
||||
if not solo.is_relative_to(manifest.parent.resolve()):raise ValueError('Training solo must be inside the training directory')
|
||||
if not source.is_relative_to((manifest.parent.parent/'songs').resolve()):raise ValueError('Training sources must be inside data/songs')
|
||||
document=parts.prepare_document(musicxml.load_musicxml(source));a=analysis.analyze(document)
|
||||
from generator.production import SourceAnalysis
|
||||
pair=(SourceAnalysis.read(document).musical_key,entry['slot'])
|
||||
if pair in pairs:raise ValueError('Duplicate source music and solo slot')
|
||||
pairs.add(pair)
|
||||
evidence=training_io.read(solo,role,pitch_convention=entry['pitch_convention'])
|
||||
spans=[(F(m['start']),F(m['duration'])) for m in evidence['measures']]
|
||||
if spans!=[(x,y-x) for x,y in zip(a.boundaries,a.boundaries[1:])]:raise ValueError(entry['id']+': source/solo measure timing differs; no padding or pickup removal allowed')
|
||||
if evidence['discrepancies']:raise ValueError(entry['id']+': '+str(evidence['discrepancies']))
|
||||
ctx=Context(document)
|
||||
training_io.validate_harmony(document,evidence,entry['id'])
|
||||
events=[];cursor=F()
|
||||
from generator import fretboard
|
||||
for n in evidence['sounding_events']:
|
||||
start,end=F(n['start']),F(n['end']);number=F(n['sounding_midi'])
|
||||
if number.denominator!=1:raise ValueError(entry['id']+': microtonal guitar pitch is unsupported')
|
||||
pitch=int(number)
|
||||
if not fretboard.single_positions_for_pitch(F(pitch)):raise ValueError(entry['id']+': unplayable sounding pitch '+str(pitch)+' at '+str(start))
|
||||
if start<cursor or end<=start:raise ValueError(entry['id']+': non-monophonic/invalid event')
|
||||
if start>cursor:events.append((cursor,start,None))
|
||||
events.append((start,end,pitch));cursor=end
|
||||
if cursor<a.boundaries[-1]:events.append((cursor,a.boundaries[-1],None))
|
||||
source_hash=hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
examples.append({'id':entry['id'],'song':source_hash,'role':role,'analysis':a,'harmony':ctx,'events':tuple(events)})
|
||||
provenance.append({'id':entry['id'],'source_sha256':source_hash,'solo_sha256':evidence['sha256'],
|
||||
'pitch_convention':entry['pitch_convention'],'measures':len(spans),'pickup_beats':str(a.body_offset),
|
||||
'slot':entry['slot'],'attacks':len(evidence['sounding_events']),'annotations':len(evidence['annotations']),
|
||||
'adapter_notes':['Trailing chord-display parentheses interpreted without modifying XML.'] if any(h['display'] and h['display'].endswith(')') and '(' not in h['display'] for h in evidence['harmonies']) else []})
|
||||
if not examples:raise ValueError('No training examples')
|
||||
return examples,{'description':value['provenance'],'examples':provenance,'training_version':learned.VERSION}
|
||||
|
||||
|
||||
def likelihood(predictor,example):
|
||||
history=[];previous=None;cost=0
|
||||
for start,end,pitch in example['events']:
|
||||
harmonic,root,_=learned.chord_context(example['harmony'],start)
|
||||
action=learned.token(end-start,pitch,previous,root)
|
||||
relative=start-example['analysis'].body_offset
|
||||
c=learned.contexts(harmonic,learned.phase_at(example['analysis'],relative),learned.source_feature(example['analysis'],relative,root),tuple(history[-4:]))
|
||||
cost+=predictor.cost(action,c);history.append(action)
|
||||
if pitch is not None:previous=pitch
|
||||
return cost/len(history)
|
||||
|
||||
|
||||
def atomic_json(path,value):
|
||||
path=Path(path);path.parent.mkdir(parents=True,exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile('w',dir=path.parent,delete=False) as stream:
|
||||
json.dump(value,stream,sort_keys=True,separators=(',',':'));stream.write('\n');name=stream.name
|
||||
os.chmod(name,0o644)
|
||||
os.replace(name,path)
|
||||
|
||||
|
||||
def register_example(manifest,source,solo,slot,pitch_convention):
|
||||
"""Copy and validate a new pair before atomically publishing its manifest."""
|
||||
import shutil
|
||||
manifest=Path(manifest).resolve();root=manifest.parent
|
||||
sources=root.parent/'songs';root.mkdir(parents=True,exist_ok=True);sources.mkdir(parents=True,exist_ok=True)
|
||||
value=json.loads(manifest.read_text()) if manifest.exists() else {'version':1,'provenance':'User-authored paired song/solo training corpus.','examples':[]}
|
||||
source,solo=Path(source).resolve(),Path(solo).resolve()
|
||||
destinations=((source,sources/source.name),(solo,root/solo.name));created=[];temporary=None
|
||||
try:
|
||||
for original,destination in destinations:
|
||||
if destination.exists():
|
||||
if original.read_bytes()!=destination.read_bytes():raise ValueError(str(destination)+': already exists with different contents; no overwrite performed')
|
||||
else:shutil.copyfile(original,destination);created.append(destination)
|
||||
entry={'id':source.stem+'/solo'+str(slot),'source':'../songs/'+source.name,'solo':solo.name,
|
||||
'slot':slot,'pitch_convention':pitch_convention}
|
||||
if any(e['id']==entry['id'] for e in value['examples']):raise ValueError('Example already registered: '+entry['id'])
|
||||
value['examples'].append(entry)
|
||||
with tempfile.NamedTemporaryFile('w',dir=root,suffix='.json',delete=False) as stream:
|
||||
json.dump(value,stream);temporary=Path(stream.name)
|
||||
load_examples(temporary)
|
||||
atomic_json(manifest,value)
|
||||
except BaseException:
|
||||
for p in created:p.unlink()
|
||||
raise
|
||||
finally:
|
||||
if temporary:temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest',type=Path,default=Path('data/training/manifest.json'))
|
||||
parser.add_argument('--output',type=Path,default=Path('data/models/solo.json'))
|
||||
parser.add_argument('--report',type=Path,default=Path('build/training/report.json'))
|
||||
parser.add_argument('--leave-one-song-out',action='store_true')
|
||||
parser.add_argument('--add-source',type=Path,help='Register a source song (copied to data/songs if needed).')
|
||||
parser.add_argument('--add-solo',type=Path,help='Register its authored solo (copied unchanged to data/training).')
|
||||
parser.add_argument('--slot',type=int,choices=(1,2),help='Arrangement mapping only: 1 acoustic, 2 electric; learning is pooled.')
|
||||
parser.add_argument('--pitch-convention',choices=('guitar-written','encoded'),default='guitar-written')
|
||||
args=parser.parse_args();began=time.monotonic()
|
||||
if any((args.add_source,args.add_solo,args.slot)):
|
||||
if not all((args.add_source,args.add_solo,args.slot)):parser.error('--add-source, --add-solo and --slot must be supplied together')
|
||||
register_example(args.manifest,args.add_source,args.add_solo,args.slot,args.pitch_convention)
|
||||
examples,provenance=load_examples(args.manifest)
|
||||
value=learned.fit(examples,provenance);predictor=learned.Model(value)
|
||||
report={'version':learned.VERSION,'training':provenance,'model_sha256':vocabulary.fingerprint(value),
|
||||
'contexts':len(value['contexts']),'in_sample_event_nll':{e['id']:likelihood(predictor,e) for e in examples},
|
||||
'limitations':'A small corpus does not establish generalization. Likelihood is teacher-forced, not free generation or musical preference.'}
|
||||
if args.leave_one_song_out:
|
||||
report['held_out_event_nll']={}
|
||||
for song in sorted({e['song'] for e in examples}):
|
||||
remaining=[e for e in examples if e['song']!=song]
|
||||
if not remaining:continue
|
||||
fitted=learned.Model(learned.fit(remaining,{'evaluation_only':True}))
|
||||
report['held_out_event_nll'].update({e['id']:likelihood(fitted,e) for e in examples if e['song']==song})
|
||||
atomic_json(args.output,value);report['training_seconds']=time.monotonic()-began;atomic_json(args.report,report)
|
||||
print(f'Trained {len(examples)} solos: {len(value["contexts"])} contexts in {report["training_seconds"]:.3f}s; {args.output}')
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Literal authored-score reader for training, registered repertoire and comparison."""
|
||||
from __future__ import annotations
|
||||
import hashlib,json
|
||||
from pathlib import Path
|
||||
from fractions import Fraction as F
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
def read(path, role, form_start=None, pitch_convention='encoded'):
|
||||
if pitch_convention not in ('encoded','guitar-written'):
|
||||
raise ValueError('Unknown reference pitch convention: '+pitch_convention)
|
||||
raw=Path(path).read_bytes();tree=ET.fromstring(raw)
|
||||
result={'path':str(Path(path).resolve()),'sha256':hashlib.sha256(raw).hexdigest(),'validation_role':role,
|
||||
'pitch_convention':pitch_convention,'part_names':[n.text for n in tree.findall('./part-list/score-part/part-name')],
|
||||
'measures':[],'notes':[],'harmonies':[],'annotations':[],'discrepancies':[]}
|
||||
divisions=F(1);absolute=F();nominal=F(4);pending={};active=None;transpose=F()
|
||||
for index,m in enumerate(tree.findall('./part/measure')):
|
||||
cursor=F();extent=F();last_start=F();last_duration=F()
|
||||
if (value:=m.findtext('attributes/divisions')) is not None:divisions=F(value)
|
||||
meter=m.find('attributes/time')
|
||||
if meter is not None and meter.findtext('beats'):
|
||||
nominal=sum((F(v) for v in meter.findtext('beats').split('+')),F())*4/F(meter.findtext('beat-type'))
|
||||
for item in m:
|
||||
offset=F(item.findtext('offset','0'))/divisions
|
||||
if item.tag=='attributes' and item.find('transpose') is not None:
|
||||
transpose=F(item.findtext('transpose/chromatic','0'))+12*F(item.findtext('transpose/octave-change','0'))
|
||||
elif item.tag=='backup':cursor-=F(item.findtext('duration'))/divisions
|
||||
elif item.tag=='forward':cursor+=F(item.findtext('duration'))/divisions;extent=max(extent,cursor)
|
||||
elif item.tag=='harmony':
|
||||
active={'time':str(absolute+cursor+offset),'measure':index+1,'offset':str(cursor+offset),
|
||||
'root':item.findtext('root/root-step'),'alter':item.findtext('root/root-alter','0'),
|
||||
'kind':item.findtext('kind'),'display':item.find('kind').get('text') if item.find('kind') is not None else None,
|
||||
'bass':ET.tostring(item.find('bass'),encoding='unicode') if item.find('bass') is not None else None,
|
||||
'degrees':[{'value':d.findtext('degree-value'),'alter':d.findtext('degree-alter'),'type':d.findtext('degree-type')} for d in item.findall('degree')]}
|
||||
result['harmonies'].append(active)
|
||||
elif item.tag=='direction':
|
||||
for words in item.findall('direction-type/words'):
|
||||
result['annotations'].append({'time':str(absolute+cursor+offset),'measure':index+1,'text':''.join(words.itertext()),'interpretation':'descriptive; no production permission'})
|
||||
elif item.tag=='note':
|
||||
duration=F(item.findtext('duration','0'))/divisions
|
||||
start=last_start if item.find('chord') is not None else cursor
|
||||
end=start+duration;voice=item.findtext('voice','1');p=item.find('pitch')
|
||||
encoded={'step':p.findtext('step'),'alter':p.findtext('alter','0'),'octave':int(p.findtext('octave'))} if p is not None else None
|
||||
kinds={t.get('type') for t in item.findall('tie')} | {t.get('type') for t in item.findall('notations/tied')}
|
||||
event={'start':str(absolute+start),'end':str(absolute+end),'measure':index+1,'offset':str(start),'pitch':encoded,'rest':item.find('rest') is not None,
|
||||
'written_type':item.findtext('type'),'dots':len(item.findall('dot')),'voice':voice,'ties':sorted(kinds),
|
||||
'sounding_transposition':str(-12 if pitch_convention=='guitar-written' else transpose),
|
||||
'tablature':{'string':int(item.findtext('notations/technical/string')),'fret':int(item.findtext('notations/technical/fret'))} if item.find('notations/technical/string') is not None and item.find('notations/technical/fret') is not None else None,
|
||||
'time_modification':ET.tostring(item.find('time-modification'),encoding='unicode') if item.find('time-modification') is not None else None,
|
||||
'tuplets':[dict(t.attrib) for t in item.findall('notations/tuplet')],'grace':item.find('grace') is not None,'new_attack':'stop' not in kinds and p is not None}
|
||||
key=(voice,json.dumps(encoded,sort_keys=True))
|
||||
if 'stop' in kinds:
|
||||
old=pending.get(key)
|
||||
if old is None or result['notes'][old]['end']!=event['start']:
|
||||
result['discrepancies'].append({'measure':index+1,'issue':'unmatched/noncontiguous tie; retained literally'})
|
||||
else:event['continuation_of']=old
|
||||
if 'start' not in kinds:pending.pop(key,None)
|
||||
if 'start' in kinds:pending[key]=len(result['notes'])
|
||||
result['notes'].append(event)
|
||||
for container in ('notations/technical/fingering','notations/technical/other-technical','notations/other-notation'):
|
||||
for label in item.findall(container):
|
||||
text=''.join(label.itertext()).strip()
|
||||
if text:result['annotations'].append({'time':str(absolute+start),'measure':index+1,'text':text,
|
||||
'container':container,'interpretation':'Original note annotation; container does not establish musical permission or guitar fingering.'})
|
||||
if item.find('chord') is None and item.find('grace') is None:cursor=end
|
||||
last_start,last_duration=start,duration;extent=max(extent,end,cursor)
|
||||
if extent!=nominal and m.get('implicit')!='yes':result['discrepancies'].append({'measure':index+1,'issue':f'encoded span {extent} differs from nominal {nominal}; no padding applied'})
|
||||
result['measures'].append({'number':m.get('number'),'start':str(absolute),'duration':str(extent),'implicit':m.get('implicit'),'initial_harmony_known':any(F(h['time'])<=absolute for h in result['harmonies'])})
|
||||
absolute+=extent
|
||||
if pending:result['discrepancies'].append({'issue':'unterminated ties retained'})
|
||||
result['harmonies'].sort(key=lambda h:F(h['time']))
|
||||
# Do not run the strict score importer over these references: annotations,
|
||||
# unusual timing and high encoded pitches are evidence, not repair requests.
|
||||
from generator import harmony, model, fretboard
|
||||
def semantic(h):
|
||||
return {'root':{'letter':h['root'].lower(),'alter':model.rational(F(h['alter']))},'kind':h['kind'],
|
||||
'degrees':[{'value':int(d['value']),'alter':model.rational(F(d['alter'])),'type':d['type']} for d in h['degrees']]}
|
||||
for event in result['notes']:
|
||||
if not event['pitch']:continue
|
||||
p=event['pitch'];number=12*(p['octave']+1)+model.NATURAL[p['step'].lower()]+F(p['alter'])
|
||||
event['encoded_midi']=str(number)
|
||||
event['sounding_midi']=str(number+F(event['sounding_transposition']))
|
||||
event['legal_positions_at_encoded_pitch']=len(fretboard.single_positions_for_pitch(number))
|
||||
event['legal_positions_at_sounding_pitch']=len(fretboard.single_positions_for_pitch(F(event['sounding_midi'])))
|
||||
event['harmonic_spans']=[]
|
||||
start,end=F(event['start']),F(event['end'])
|
||||
changes=[F(h['time']) for h in result['harmonies'] if start<F(h['time'])<end]
|
||||
for a,b in zip([start,*changes],[*changes,end]):
|
||||
current=next((h for h in reversed(result['harmonies']) if F(h['time'])<=a),None)
|
||||
info={'start':str(a),'end':str(b),'chord':current,'chord_degrees':[]}
|
||||
if current and current['root']:
|
||||
try:
|
||||
chord=semantic(current);root=model.NATURAL[chord['root']['letter']]+F(current['alter'])
|
||||
info['chord_degrees']=[d for d,alter in harmony.degrees(chord).items() if (root+harmony.degree_semitones(d,alter)-number)%12==0]
|
||||
info['root_relative_semitones']=str((number-root)%12)
|
||||
info['spelled_simple_degree']=(model.LETTERS.index(p['step'].lower())-model.LETTERS.index(chord['root']['letter']))%7+1
|
||||
except (ValueError,KeyError,AttributeError) as error:
|
||||
info['uncertainty']=str(error)
|
||||
else:info['uncertainty']='No written/carried harmony; none invented.'
|
||||
event['harmonic_spans'].append(info)
|
||||
sounded=[];tie_roots={}
|
||||
for i,n in enumerate(result['notes']):
|
||||
if not n['pitch']:continue
|
||||
previous=n.get('continuation_of')
|
||||
if previous is not None and previous in tie_roots:
|
||||
root=tie_roots[previous];sounded[root]['end']=n['end'];sounded[root]['notation_events'].append(i);tie_roots[i]=root
|
||||
else:
|
||||
tie_roots[i]=len(sounded);sounded.append({'start':n['start'],'end':n['end'],'pitch':n['pitch'],
|
||||
'encoded_midi':n['encoded_midi'],'sounding_midi':n['sounding_midi'],'measure':n['measure'],'notation_events':[i]})
|
||||
result['sounding_events']=sounded
|
||||
first=form_start or next((h['measure'] for h in result['harmonies']),None)
|
||||
offset=F(result['measures'][first-1]['start']) if first else None
|
||||
result['alignment']={'form_start_measure':first,'encoded_offset':str(offset) if offset is not None else None,'basis':'explicit override' if form_start else 'first written harmony; review required','preceding_material':'retained, without invented harmony'}
|
||||
for label in result['annotations']:
|
||||
t=F(label['time']); current=[h for h in result['harmonies'] if F(h['time'])<=t]
|
||||
future=next((h for h in result['harmonies'] if F(h['time'])>t),None)
|
||||
label['current_harmony']=current[-1] if current else None
|
||||
label['following_harmony']=future
|
||||
label['events']=[n for n in result['notes'] if F(n['start'])<=t<F(n['end']) or t<=F(n['start'])<t+1]
|
||||
label['uncertainty']='Label meaning must be assessed from these events and harmonic time; label preserved verbatim.'
|
||||
expected={'b9':F(1),'#9':F(3),'b13':F(8),'#11':F(6),'6':F(9)}.get(label['text'].strip())
|
||||
if expected is not None:
|
||||
relationships=[s.get('root_relative_semitones') for n in label['events'] for s in n.get('harmonic_spans',[])]
|
||||
label['literal_interval_present']=str(expected) in relationships
|
||||
if not label['literal_interval_present']:label['uncertainty']='No matching interval in the immediate annotated context; label retained, intended scope uncertain.'
|
||||
# Lead in and Bluesy stay contextual descriptions, never enclosure permits.
|
||||
return result
|
||||
|
||||
from generator import harmony,model
|
||||
|
||||
def training_chord(h):
|
||||
result={'root':{'letter':h['root'].lower(),'alter':model.rational(F(h['alter']))},'kind':h['kind'],'degrees':[]}
|
||||
for d in h['degrees']:
|
||||
degree=int(d['value']);alter=F(d['alter'])
|
||||
if d['type']=='alter':alter+=harmony.KINDS.get(result['kind'],{}).get(degree,0)
|
||||
elif d['type']=='add' and (degree-1)%7==6:alter-=1
|
||||
result['degrees'].append({'value':degree,'alter':model.rational(alter),'type':d['type']})
|
||||
if h['bass']:
|
||||
import xml.etree.ElementTree as ET
|
||||
bass=ET.fromstring(h['bass']);result['bass']={'letter':bass.findtext('bass-step').lower(),'alter':model.rational(F(bass.findtext('bass-alter','0')))}
|
||||
display=h['display']
|
||||
if display:
|
||||
# Soundslice exports an optional chord's suffix with a trailing closing
|
||||
# parenthesis, but its actual root separately. Evidence stays untouched.
|
||||
if display.endswith(')') and '(' not in display:display=display[:-1]
|
||||
harmony.normalize_text(result,display)
|
||||
return harmony.symbol(result).strip('()')
|
||||
|
||||
|
||||
def validate_harmony(document,evidence,identity):
|
||||
source_chords=[];cursor=F()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
source_chords.extend((cursor+model.fraction(h['onset']),harmony.symbol(h).strip('()')) for h in measure['harmonies'])
|
||||
cursor+=model.fraction(measure['duration'])
|
||||
authored=[(F(h['time']),training_chord(h)) for h in evidence['harmonies']]
|
||||
if authored!=source_chords:raise ValueError(identity+': authored harmony differs from authoritative source')
|
||||
+1
-44
@@ -15,7 +15,7 @@ ACTIVE = contextvars.ContextVar('composition_vocabulary', default=None)
|
||||
def load(root=ROOT):
|
||||
# Content, not just the directory, keys memoization. Long-lived individual
|
||||
# role builds must observe edited production data without restarting Python.
|
||||
names = ('roles', 'solo', 'harmony', 'lead_ins', 'rhythms')
|
||||
names = ('roles', 'harmony', 'lead_ins', 'accompaniment')
|
||||
return _load(tuple((name, (Path(root)/(name+'.json')).read_text()) for name in names))
|
||||
|
||||
@functools.lru_cache(maxsize=16)
|
||||
@@ -29,12 +29,6 @@ def _load(contents):
|
||||
if len({v['id'] for v in entries}) != len(entries):
|
||||
raise ValueError(f'{name}: duplicate vocabulary ID')
|
||||
result[name] = value
|
||||
cells=result['rhythms']['cells']+result['solo']['cells']
|
||||
if len({c['id'] for c in cells})!=len(cells):raise ValueError('Duplicate rhythm identity')
|
||||
rules=result['solo']
|
||||
if any(type(rules['search'][k]) is not int or rules['search'][k]<=0 for k in ('histories','alternatives')):raise ValueError('Invalid deterministic search budget')
|
||||
for group in rules['groups']:
|
||||
if not group['values'] or any(F(v.removeprefix('r:'))<=0 for v in group['values']):raise ValueError('Invalid rhythm group')
|
||||
return result
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
@@ -62,40 +56,3 @@ def using(resources, name, settings=None):
|
||||
|
||||
def fingerprint(value):
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class RhythmCell:
|
||||
id: str
|
||||
pattern: tuple
|
||||
duration: F
|
||||
purpose: str
|
||||
difficulty: str | None = None
|
||||
subdivision: str = 'binary'
|
||||
entry: str = 'attack'
|
||||
exit: str = 'continue'
|
||||
|
||||
|
||||
def rhythm_cells(tempo, purpose=None):
|
||||
limits = data('roles')['difficulty']
|
||||
for cell in (*data('rhythms')['cells'],*data('solo')['cells']):
|
||||
if purpose is not None and cell['purpose'] != purpose:
|
||||
continue
|
||||
difficulty = cell.get('difficulty')
|
||||
if difficulty and tempo > limits[difficulty + '_max_bpm']:
|
||||
continue
|
||||
cursor, notes = F(), []
|
||||
for value in cell['values']:
|
||||
rest = value.startswith('r:')
|
||||
duration = F(value[2:] if rest else value)
|
||||
if duration <= 0:
|
||||
raise ValueError(cell['id'] + ': nonpositive rhythm duration')
|
||||
if not rest:
|
||||
notes.append((cursor, cursor + duration))
|
||||
cursor += duration
|
||||
if difficulty and len(notes) > limits['burst_attacks']:
|
||||
continue
|
||||
from generator.solo import groups
|
||||
realized=groups.describe(tuple(notes),source=cell['id'])
|
||||
family=cell.get('subdivision','binary')
|
||||
if family=='triplet' and not any(g.family=='triplet' for g in realized):raise ValueError(cell['id']+': missing complete tuplet group')
|
||||
yield RhythmCell(cell['id'],tuple(notes),cursor,cell['purpose'],difficulty,family,cell.get('entry','attack'),cell.get('exit','continue'))
|
||||
|
||||
Reference in New Issue
Block a user