Training Model

This commit is contained in:
khodowany
2026-10-05 19:44:33 -05:00
parent db7966a430
commit e7180e557b
6603 changed files with 8919 additions and 523063 deletions
-8
View File
@@ -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:
-165
View File
@@ -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
-92
View File
@@ -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']
+11 -40
View File
@@ -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)
+1 -1
View File
@@ -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()
+2 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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)
-82
View File
@@ -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
-94
View File
@@ -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))
View File
-92
View File
@@ -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)
-32
View File
@@ -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)
-44
View File
@@ -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)
-112
View File
@@ -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)
+2 -27
View File
@@ -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
+63
View File
@@ -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
View File
@@ -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')
-104
View File
@@ -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
+220
View File
@@ -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}')
+16 -13
View File
@@ -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
View File
@@ -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)]
+130
View File
@@ -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())}}
-656
View File
@@ -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
+5 -10
View File
@@ -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()
+1 -6
View File
@@ -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)
+9 -12
View File
@@ -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)}
-32
View File
@@ -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())
-13
View File
@@ -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
-102
View File
@@ -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))})
-87
View File
@@ -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
View File
@@ -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
+151
View File
@@ -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()
+155
View File
@@ -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
View File
@@ -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'))