Cleanup
ci / checks (push) Failing after 23m22s

This commit is contained in:
khodowany
2026-10-08 02:13:45 -05:00
parent 300097a37e
commit bc7baea7c4
88 changed files with 18844 additions and 1335 deletions
-45
View File
@@ -1,45 +0,0 @@
"""Immutable source events, meter boundaries and tempo. No solo construction."""
from __future__ import annotations
import dataclasses, fractions
import generator.model, generator.score, generator.solo.types
@dataclasses.dataclass(frozen=True)
class Analysis:
boundaries: tuple
tempo: fractions.Fraction
melody: tuple
body_offset: fractions.Fraction = fractions.Fraction()
def attacks(document: dict) -> tuple[generator.solo.types.Note, ...]:
"""Collapse tied tones before extracting monophonic melody fragments."""
result, pending = [], {}
absolute = fractions.Fraction()
for measure in generator.score.source_staff(document)['measures']:
for event in generator.score.measure_notes(measure):
if event.get('grace') or event.get('cue'):
continue
for tone in event['pitches']:
number = generator.model.pitch_number(tone)
if number.denominator != 1:
continue
start = absolute + generator.model.fraction(event['onset'])
end = start + generator.model.fraction(tone['duration'])
pitch = generator.solo.types.Pitch(tone['letter'], generator.model.alteration(tone['alter']), tone['octave'])
source = pending.pop(tone.get('tie_from'), None)
if source is not None:
result[source] = dataclasses.replace(result[source], end=end, sources=(*result[source].sources, event['id']))
else:
source = len(result)
result.append(generator.solo.types.Note(start, end, pitch, (event['id'],)))
if 'tie_to' in tone:
pending[tone['id']] = source
absolute += generator.model.fraction(measure['duration'])
return tuple(sorted(result, key=lambda n: (n.start, -n.pitch.number(), n.sources)))
def analyze(document):
measures = generator.score.source_staff(document)['measures']
boundaries = [fractions.Fraction()]
for m in measures:
boundaries.append(boundaries[-1] + generator.model.fraction(m['duration']))
tempo = next((generator.model.fraction(t['bpm']) * generator.model.written_length(t['written']) for m in measures for t in m.get('tempos', []) if 'bpm' in t), fractions.Fraction(120))
return Analysis(tuple(boundaries), tempo, attacks(document), boundaries[generator.score.body_start_measure_index(measures)])
+15 -12
View File
@@ -6,20 +6,21 @@ policy. Unknown song/role pairs go to the learned composer.
"""
from __future__ import annotations
import dataclasses
import fractions
import hashlib
import json
import pathlib
import fractions
import generator.harmonic_time
import generator.musicxml
import generator.parts
import generator.training_io
import generator.harmonic_time
import generator.solo.analysis
import generator.solo.types
import generator.solo.harmonic
import generator.solo.placement
import generator.solo.profiles
import generator.solo.harmonic
import generator.production
import generator.solo.types
import generator.source
import generator.training_io
import generator.vocabulary
def sounding(evidence, offset=fractions.Fraction()):
result = []
@@ -32,9 +33,10 @@ def sounding(evidence, offset=fractions.Fraction()):
result.append(generator.solo.types.Note(fractions.Fraction(event['start']) - offset, fractions.Fraction(event['end']) - offset, pitch, role='authored', event_id=f'authored_{i}'))
return tuple(result)
def validate_evidence(document, evidence):
def validate_evidence(document, evidence, *, composition=None):
"""Training uses the same source-form harmonic context as solo playback."""
h = generator.solo.harmonic.Harmony(generator.harmonic_time.solo_source(document['staves'][0]))
composition = composition or generator.vocabulary.current()
h = generator.solo.harmonic.Harmony(generator.harmonic_time.solo_source(document['staves'][0]), composition=composition)
checked = h.evaluate(sounding(evidence), complete=True)
if not checked.valid:
raise ValueError('Unsupported harmonic relationship in authored solo: ' + checked.error)
@@ -56,7 +58,7 @@ def lookup(source, role, data_dir):
if not path.is_relative_to((root / 'songs').resolve()):
raise ValueError('Registered sources must be in data/songs')
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
if generator.production.SourceAnalysis.read(doc).musical_key == source.musical_key:
if generator.source.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')
@@ -67,7 +69,8 @@ def lookup(source, role, data_dir):
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):
def realize(entry, source_analysis, measures, part, start, end, initial=None, *, composition=None):
composition = composition or generator.vocabulary.current()
evidence = generator.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']))
@@ -86,7 +89,7 @@ def realize(entry, source_analysis, measures, part, start, end, initial=None):
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 generator.harmonic_time.read({'measures': measures}, initial=initial))
checked = generator.solo.harmonic.Harmony(spans).evaluate(placed[1], complete=True)
checked = generator.solo.harmonic.Harmony(spans, composition=composition).evaluate(placed[1], complete=True)
if not checked.valid:
raise ValueError(entry['id'] + ': ' + checked.error)
phrase = generator.solo.types.Phrase('authored', start, end, placed[1], 'authored', 'authored', 'handoff', theme=entry['id'], expressions=checked.contracts)
+17 -14
View File
@@ -1,26 +1,28 @@
"""The sole production solo composer: frozen learned model plus music validity."""
import dataclasses
import time
import fractions
import pathlib
import time
import generator.harmonic_time
import generator.vocabulary
import generator.solo.types
import generator.solo.profiles
import generator.solo.harmonic
import generator.solo.placement
import generator.solo.learned
import generator.solo.decoder
import generator.solo.groups
import generator.solo.harmonic
import generator.solo.learned
import generator.solo.placement
import generator.solo.profiles
import generator.solo.types
import generator.vocabulary
def compose(analysis, measures, part, end, *, learned_model=None, initial=None, expressions_enabled=True, start=fractions.Fraction()):
def compose(analysis, measures, part, end, *, learned_model=None, initial=None, expressions_enabled=True, start=fractions.Fraction(), composition=None):
composition = composition or generator.vocabulary.current()
began = time.monotonic()
spans = tuple(dataclasses.replace(s, start=s.start + start, end=s.end + start) for s in generator.harmonic_time.read({'measures': measures}, initial=initial))
profile = generator.solo.profiles.for_part(part, analysis)
harmony = generator.solo.harmonic.Harmony(spans, profile)
harmony = generator.solo.harmonic.Harmony(spans, profile, composition=composition)
model = learned_model or generator.solo.learned.Model.load(pathlib.Path(__file__).resolve().parents[2] / 'data/models/solo.json')
settings = generator.vocabulary.role(part + '_solo')
builder = generator.solo.decoder.Decoder(analysis, harmony, model, part, start, end, expressions_enabled=expressions_enabled, attempts=settings.get('attempts', 4), seed=settings.get('choice_seed', ''))
settings = composition.role(part + '_solo')
builder = generator.solo.decoder.Decoder(analysis, harmony, model, part, start, end, expressions_enabled=expressions_enabled, attempts=settings.get('attempts', 4), seed=settings.get('choice_seed', ''), composition=composition)
selected = builder.compose()
decoded = time.monotonic()
placed = generator.solo.placement.place(selected.notes, analysis.tempo, profile=profile)
@@ -48,10 +50,11 @@ def compose(analysis, measures, part, end, *, learned_model=None, initial=None,
'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)
validate(result, composition=composition)
return result
def validate(solo):
def validate(solo, *, composition=None):
composition = composition or generator.vocabulary.current()
previous = None
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:
@@ -59,7 +62,7 @@ def validate(solo):
if n.position not in generator.solo.placement.positions(n.pitch.number(), solo.profile):
raise ValueError('invalid guitar placement')
previous = n
checked = generator.solo.harmonic.Harmony(solo.harmony).evaluate(solo.notes(), until=solo.end, complete=True,
checked = generator.solo.harmonic.Harmony(solo.harmony, composition=composition).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)
+19 -17
View File
@@ -8,17 +8,18 @@ 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
import fractions
import hashlib
import math
import random
import generator.harmony
import generator.model
import generator.vocabulary
import generator.solo.expressions
import generator.solo.groups
import generator.solo.learned
import generator.solo.types
import generator.harmony
import generator.vocabulary
class SearchExhausted(ValueError):
pass
@@ -35,7 +36,8 @@ class State:
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=''):
def __init__(self, analysis, harmony, model, role, start, end, *, expressions_enabled=True, attempts=4, seed='', composition=None):
self.composition = composition or generator.vocabulary.current()
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
@@ -45,7 +47,7 @@ class Decoder:
self.seed = seed or hashlib.sha256(repr((source, role, generator.solo.learned.VERSION)).encode()).hexdigest()
self.expressions_enabled = expressions_enabled
self.low, self.high = harmony.profile.register
limits = generator.vocabulary.data('roles')['difficulty']
limits = self.composition.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']
@@ -92,12 +94,12 @@ class Decoder:
bases = []
regions = [(None, n)]
regions.extend((s.start, dataclasses.replace(n, start=s.start)) for s in self.harmony.spans
if n.start < s.start < n.end and s.start - n.start <= fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']))
if n.start < s.start < n.end and s.start - n.start <= fractions.Fraction(self.composition.data('harmony')['anticipation_max']))
for boundary, arrived in regions:
exposed = [s for s, _, _ in self.harmony.segments(arrived) if n.pitch.number() % 12 not in s.roles.basic | s.roles.specified]
if not exposed:
continue
for rule in generator.vocabulary.data('harmony')['colors']:
for rule in self.composition.data('harmony')['colors']:
if arrived.end - arrived.start > fractions.Fraction(rule['max_beats']):
continue
if any(s.chord == 'N.C.' or not generator.solo.expressions.eligible_color(rule, s, self.harmony) or
@@ -139,7 +141,7 @@ class Decoder:
if self.harmony.evaluate((n,), complete=True, expressions=(c,), infer=False).valid:
return tuple([c])
for arriving in self.harmony.spans:
if n.start < arriving.start < n.end and arriving.start - n.start <= fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']):
if n.start < arriving.start < n.end and arriving.start - n.start <= fractions.Fraction(self.composition.data('harmony')['anticipation_max']):
c = generator.solo.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,), infer=False).valid:
return tuple([c])
@@ -175,9 +177,9 @@ class Decoder:
for rule, boundary in self.color_bases(n):
if rule.get('resolve'):
for gap in (fractions.Fraction(), fractions.Fraction(1, 2), fractions.Fraction(1)):
if gap <= fractions.Fraction(generator.vocabulary.data('harmony')['resolution_gap']):
if gap <= fractions.Fraction(self.composition.data('harmony')['resolution_gap']):
requests.append(('color', rule, boundary, n.end + gap, (-2, -1, 1, 2)))
delay = generator.vocabulary.data('lead_ins')['delayed_approach']
delay = self.composition.data('lead_ins')['delayed_approach']
if n.end - n.start <= fractions.Fraction(delay['max_note']) and n.pitch.number() % 12 not in self.harmony.permitted(n.start, n.end):
for arrival in map(fractions.Fraction, delay['arrivals']):
if n.end <= n.start + arrival <= n.start + fractions.Fraction(delay['deadline']):
@@ -219,7 +221,7 @@ class Decoder:
if self.expressions_enabled and not self.harmony.ordinary(n):
for events, ns, contracts in self.resolutions(n):
add(events, ns, contracts)
rule = generator.vocabulary.data('lead_ins')['passing']
rule = self.composition.data('lead_ins')['passing']
if duration <= fractions.Fraction(rule['max_note']):
for step in rule['steps']:
p = number + step
@@ -235,8 +237,8 @@ class Decoder:
c = generator.solo.types.ExpressionContract(n.event_id + ':passing', 'passing', rule['id'], (n,), target, self.harmony.at(end).chord)
add(((time, end, number), (end, end + hold, p)), (n, target), (c, *extra))
if self.expressions_enabled:
limits = generator.vocabulary.data('roles')['difficulty']
for rule in generator.vocabulary.data('lead_ins')['patterns']:
limits = self.composition.data('roles')['difficulty']
for rule in self.composition.data('lead_ins')['patterns']:
difficulty = rule.get('difficulty')
if difficulty and self.analysis.tempo > limits[difficulty + '_max_bpm']:
continue
@@ -313,9 +315,9 @@ class Decoder:
return None
contracts = tuple(dataclasses.replace(c, preparation=state.notes[-1]) if c.kind in ('connection', 'passing') and c.preparation is None else c for c in contracts)
if self.harmony.ordinary(state.notes[-1]):
_, error = generator.solo.expressions.validate((state.notes[-1], *notes), contracts, self.harmony)
_, error = generator.solo.expressions.validate((state.notes[-1], *notes), contracts, self.harmony, composition=self.composition)
else:
_, error = generator.solo.expressions.validate((*state.notes, *notes), (*state.contracts, *contracts), self.harmony)
_, error = generator.solo.expressions.validate((*state.notes, *notes), (*state.contracts, *contracts), self.harmony, composition=self.composition)
if error:
return None
history = state.history
+21 -18
View File
@@ -1,13 +1,14 @@
"""Typed, target-driven harmonic expression against written harmony and explicit musical relationships."""
from __future__ import annotations
import dataclasses
import functools
import fractions
import functools
import generator.harmony
import generator.model
import generator.pitch
import generator.vocabulary
import generator.solo.types
import generator.vocabulary
@functools.lru_cache(maxsize=512)
def parsed_chord(symbol):
@@ -90,8 +91,9 @@ def _eligible_color(rule, span, harmony):
return False
return True
def validate(notes, contracts, harmony):
def validate(notes, contracts, harmony, *, composition=None):
"""Return event permissions only after checking complete actual gestures."""
composition = composition or generator.vocabulary.current()
actual = {n.event_id: n for n in notes}
approved = {}
@@ -115,7 +117,7 @@ def validate(notes, contracts, harmony):
if not previous or previous.chord.strip('()') == span.chord.strip('()'):
return {}, c.id + ': anticipation requires a harmonic change'
if c.kind in ('approach', 'enclosure', 'connection'):
rule = next((r for r in generator.vocabulary.data('lead_ins')['patterns'] if r['id'] == c.rule and r['kind'] == c.kind), None)
rule = next((r for r in composition.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'
arrival = c.boundary if c.boundary is not None else target.start
@@ -129,7 +131,7 @@ def validate(notes, contracts, harmony):
return {}, c.id + ': invalid internal approach gaps'
gap = fractions.Fraction(rule['gap'])
cursor = target.start - gap - sum((fractions.Fraction(d) for d in rule['durations']), fractions.Fraction()) - sum(gaps, fractions.Fraction())
if target.start - cursor > fractions.Fraction(generator.vocabulary.data('lead_ins')['max_approach']):
if target.start - cursor > fractions.Fraction(composition.data('lead_ins')['max_approach']):
return {}, c.id + ': approach too long'
for i, (n, step, duration) in enumerate(zip(c.events, rule['semitones'], rule['durations'])):
if n.start != cursor or n.end != cursor + fractions.Fraction(duration) or n.pitch.number() != target.pitch.number() + step:
@@ -144,7 +146,7 @@ def validate(notes, contracts, harmony):
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 == 'passing':
rule = generator.vocabulary.data('lead_ins')['passing']
rule = composition.data('lead_ins')['passing']
if c.rule != rule['id'] or len(c.events) != 1 or not c.preparation:
return {}, c.id + ': unsupported passing figure'
n, = c.events
@@ -160,7 +162,7 @@ def validate(notes, contracts, harmony):
if not supported(before) or not supported(target):
return {}, c.id + ': unsupported passing endpoints'
elif c.kind == 'delayed_approach':
rule = generator.vocabulary.data('lead_ins').get('delayed_approach', {})
rule = composition.data('lead_ins').get('delayed_approach', {})
if c.rule != rule.get('id') or len(c.events) != 1:
return {}, c.id + ': unknown delayed approach'
n, = c.events
@@ -171,7 +173,7 @@ def validate(notes, contracts, harmony):
if target.pitch.number() % 12 not in harmony.members(target.start, target.end) or target.pitch.number() % 12 not in harmony.permitted(target.start, target.end):
return {}, c.id + ': invalid delayed destination'
elif c.kind == 'color':
rule = next((r for r in generator.vocabulary.data('harmony')['colors'] if r['id'] == c.rule), None)
rule = next((r for r in composition.data('harmony')['colors'] if r['id'] == c.rule), None)
if not rule:
return {}, c.id + ': unknown color policy'
colored = False
@@ -193,10 +195,10 @@ def validate(notes, contracts, harmony):
return {}, c.id + ': no interval expresses the declared color'
last = c.events[-1]
if rule.get('resolve'):
if target.event_id == last.event_id or not 0 <= target.start - last.end <= fractions.Fraction(generator.vocabulary.data('harmony')['resolution_gap']) or not 0 < abs(target.pitch.number() - last.pitch.number()) <= 2 or target.pitch.number() % 12 not in harmony.members(target.start, target.end):
if target.event_id == last.event_id or not 0 <= target.start - last.end <= fractions.Fraction(composition.data('harmony')['resolution_gap']) or not 0 < abs(target.pitch.number() - last.pitch.number()) <= 2 or target.pitch.number() % 12 not in harmony.members(target.start, target.end):
return {}, c.id + ': unresolved color'
elif c.kind == 'suspension':
rule = next((r for r in generator.vocabulary.data('harmony').get('suspensions', []) if r['id'] == c.rule), None)
rule = next((r for r in composition.data('harmony').get('suspensions', []) if r['id'] == c.rule), None)
if not rule or len(c.events) != 1 or c.boundary is None:
return {}, c.id + ': unsupported suspension'
held, = c.events
@@ -211,23 +213,23 @@ def validate(notes, contracts, harmony):
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 generator.vocabulary.data('harmony')['colors'] if r['id'] == c.rule), None)
rule = next((r for r in composition.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'
n, = c.events
if not n.start < c.boundary < n.end or c.boundary - n.start > fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']):
if not n.start < c.boundary < n.end or c.boundary - n.start > fractions.Fraction(composition.data('harmony')['anticipation_max']):
return {}, c.id + ': invalid anticipated color interval'
arrived = dataclasses.replace(n, start=c.boundary)
target = arrived if c.target == n else c.target
inner = generator.solo.types.ExpressionContract(c.id + ':arrived', 'color', rule['id'], (arrived,), target, span.chord)
_, error = validate((arrived, *((target,) if target != arrived else ())), (inner,), harmony)
_, error = validate((arrived, *((target,) if target != arrived else ())), (inner,), harmony, composition=composition)
if error:
return {}, error
elif c.kind == 'anticipation':
if c.rule not in ('early_chord_tone', 'early_reattack') or len(c.events) != 1 or span.start != c.boundary:
return {}, c.id + ': unknown anticipation or non-boundary target'
n, = c.events
if c.boundary is None or not n.start < c.boundary or c.boundary - n.start > fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']):
if c.boundary is None or not n.start < c.boundary or c.boundary - n.start > fractions.Fraction(composition.data('harmony')['anticipation_max']):
return {}, c.id + ': invalid anticipation interval'
if c.rule == 'early_chord_tone' and (n != target or not c.boundary < n.end):
return {}, c.id + ': invalid held anticipation'
@@ -238,7 +240,7 @@ def validate(notes, contracts, harmony):
elif c.kind == 'blues':
if len(c.events) != 1:
return {}, c.id + ': blues treatment requires one inflected event'
rule = next((r for r in generator.vocabulary.data('lead_ins')['blues'] if r['id'] == c.rule), None)
rule = next((r for r in composition.data('lead_ins')['blues'] if r['id'] == c.rule), None)
n, = c.events
chord = parsed_chord(span.chord)
if not rule or n.end - n.start > fractions.Fraction(1, 2) or n.end != target.start or target.pitch.number() % 12 not in span.roles.basic:
@@ -262,15 +264,16 @@ def validate(notes, contracts, harmony):
approved[n.event_id] = c
return approved, ''
def validate_frontier(notes, contracts, harmony, until, complete):
def validate_frontier(notes, contracts, harmony, until, complete, *, composition=None):
"""Validate heard prefixes and bounded future obligations separately.
Future contract events are used ONLY to establish rule feasibility. They do
not count as heard resolution; the contract stays pending until its entire
target hold is present. Missing past events and expired deadlines fail.
"""
composition = composition or generator.vocabulary.current()
if complete:
approved, error = validate(notes, contracts, harmony)
approved, error = validate(notes, contracts, harmony, composition=composition)
return approved, (), error
actual = {n.event_id: n for n in notes}
future = dict(actual)
@@ -290,5 +293,5 @@ def validate_frontier(notes, contracts, harmony, until, complete):
return {}, (), c.id + ': missing/changed event before resolution deadline'
if incomplete:
pending.append(c)
approved, error = validate(tuple(sorted(future.values(), key=lambda n: (n.start, n.event_id))), contracts, harmony)
approved, error = validate(tuple(sorted(future.values(), key=lambda n: (n.start, n.event_id))), contracts, harmony, composition=composition)
return {key: c for key, c in approved.items() if key in actual}, tuple(pending), error
+10 -7
View File
@@ -5,14 +5,16 @@ permission follows the actual sounded interval and validated resolution.
"""
from __future__ import annotations
import dataclasses
import functools
import fractions
import functools
import generator.harmony
import generator.model
import generator.solo.types
import generator.solo.profiles
import generator.solo.expressions
import generator.solo.profiles
import generator.solo.recognition
import generator.solo.types
import generator.vocabulary
@dataclasses.dataclass(frozen=True)
class Roles:
@@ -55,7 +57,8 @@ class Result:
contracts: tuple = ()
class Harmony:
def __init__(self, spans, profile=None):
def __init__(self, spans, profile=None, *, composition=None):
self.composition = composition or generator.vocabulary.current()
self.profile = profile or generator.solo.profiles.UNRESTRICTED
self.source_spans = spans
self.spans = []
@@ -112,14 +115,14 @@ class Harmony:
expressions = tuple(dataclasses.replace(c, events=tuple(self.identified(n) for n in c.events),
target=self.identified(c.target), preparation=self.identified(c.preparation) if c.preparation else None) for c in expressions)
frontier = until if until is not None else max((n.end for n in notes), default=fractions.Fraction())
permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, complete or until is None)
permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, complete or until is None, composition=self.composition)
if error:
return Result(False, 0, error=error)
if infer and not pending and (complete or until is None) and any(not self.ordinary(n) and n.event_id not in permissions for n in notes):
expressions, error = generator.solo.recognition.recognize(notes, self, expressions)
expressions, error = generator.solo.recognition.recognize(notes, self, expressions, composition=self.composition)
if error:
return Result(False, 0, error=error)
permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, True)
permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, True, composition=self.composition)
if error:
return Result(False, 0, error=error)
exposed = sounded = fractions.Fraction()
+1 -2
View File
@@ -2,11 +2,10 @@
from __future__ import annotations
import dataclasses
import functools
import fractions
import functools
import generator.fretboard
import generator.vocabulary
@dataclasses.dataclass(frozen=True)
class Instrument:
+14 -12
View File
@@ -5,9 +5,10 @@ Existing expression validators prove each candidate; deterministic set-cover
search chooses a mutually consistent explanation, never edits the notes.
"""
import fractions
import generator.vocabulary
import generator.solo.expressions
import generator.solo.types
import generator.vocabulary
def signature(c):
return (c.kind, c.rule, tuple(n.event_id for n in c.events), c.target.event_id,
@@ -20,7 +21,8 @@ def merge(*groups):
result.setdefault(signature(c), c)
return tuple(result.values())
def recognize(notes, harmony, provided=(), *, budget=4096):
def recognize(notes, harmony, provided=(), *, budget=4096, composition=None):
composition = composition or generator.vocabulary.current()
required = {n.event_id for n in notes if not harmony.ordinary(n)}
fixed = {n.event_id for c in provided for n in c.events}
if required <= fixed:
@@ -46,7 +48,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
if not (covered & required):
return False
combined = merge(provided, bundle)
_, error = generator.solo.expressions.validate(notes, combined, harmony)
_, error = generator.solo.expressions.validate(notes, combined, harmony, composition=composition)
if error:
return False
candidates[tuple(signature(a) for a in bundle)] = (bundle, covered)
@@ -57,11 +59,11 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
for i, n in enumerate(notes):
if n.event_id not in required:
continue
future = [t for t in notes[i + 1:] if n.end <= t.start <= n.end + fractions.Fraction(generator.vocabulary.data('harmony')['resolution_gap'])
future = [t for t in notes[i + 1:] if n.end <= t.start <= n.end + fractions.Fraction(composition.data('harmony')['resolution_gap'])
and 0 < abs(t.pitch.number() - n.pitch.number()) <= 2]
regions = [(None, harmony.at(n.start))]
regions.extend((s.start, s) for s in harmony.spans if n.start < s.start < n.end
and s.start - n.start <= fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']))
and s.start - n.start <= fractions.Fraction(composition.data('harmony')['anticipation_max']))
for boundary, span in regions:
if not span or span.chord == 'N.C.':
continue
@@ -69,7 +71,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
c = contract('anticipation', 'early_chord_tone', (n,), n, boundary)
if add(c):
single.setdefault(n.event_id, []).append((c,))
for rule in generator.vocabulary.data('harmony')['colors']:
for rule in composition.data('harmony')['colors']:
if not generator.solo.expressions.eligible_color(rule, span, harmony) and boundary is not None:
continue
kind = 'anticipated_color' if boundary is not None else 'color'
@@ -85,7 +87,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
target = notes[i + 1]
for s in harmony.spans:
if n.start < s.start < n.end:
for rule in generator.vocabulary.data('harmony').get('suspensions', []):
for rule in composition.data('harmony').get('suspensions', []):
add(contract('suspension', rule['id'], (n,), target, s.start))
elif s.start == n.end == target.start and n.pitch.number() == target.pitch.number():
add(contract('anticipation', 'early_reattack', (n,), target, s.start))
@@ -99,7 +101,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
targets.append((None, support))
elif c.kind == 'anticipation':
targets.append((c.boundary, support))
for rule in generator.vocabulary.data('lead_ins')['patterns']:
for rule in composition.data('lead_ins')['patterns']:
size = len(rule['semitones'])
if i < size:
continue
@@ -113,7 +115,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
add(contract(rule['kind'], rule['id'], ns, target, boundary, preparation), extras)
# Delayed approaches may include real space or intervening valid music.
# They still have an exact semitone target and a finite deadline.
rule = generator.vocabulary.data('lead_ins')['delayed_approach']
rule = composition.data('lead_ins')['delayed_approach']
for n in reversed(notes[:i]):
if target.start - n.start > fractions.Fraction(rule['deadline']):
break
@@ -124,8 +126,8 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
if n.event_id in required:
for left in [(), *single.get(before.event_id, ())]:
for right in [(), *single.get(target.event_id, ())]:
add(contract('passing', generator.vocabulary.data('lead_ins')['passing']['id'], (n,), target, preparation=before), merge(left, right))
for rule in generator.vocabulary.data('lead_ins')['blues']:
add(contract('passing', composition.data('lead_ins')['passing']['id'], (n,), target, preparation=before), merge(left, right))
for rule in composition.data('lead_ins')['blues']:
add(contract('blues', rule['id'], (n,), target, preparation=before))
options = {identifier: [] for identifier in sorted(required - fixed, key=lambda identifier: (by_id[identifier].start, identifier))}
for bundle, covered in candidates.values():
@@ -152,7 +154,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
identifier = min(remaining, key=lambda x: (len(options[x]), by_id[x].start, x))
for bundle, added in options[identifier]:
combined = merge(chosen, bundle)
_, error = generator.solo.expressions.validate(notes, combined, harmony)
_, error = generator.solo.expressions.validate(notes, combined, harmony, composition=composition)
if error:
continue
result = solve(combined, covered | added)
+11 -8
View File
@@ -1,9 +1,10 @@
"""Compact reports of actual selected phrases, not duplicated draft ancestry."""
import dataclasses, fractions, json
import generator.solo.placement
import generator.solo.harmonic
import collections
import generator.fretboard
import dataclasses, fractions, json
import generator.solo.harmonic
import generator.solo.placement
import generator.vocabulary
def plain(value):
if dataclasses.is_dataclass(value):
@@ -16,8 +17,9 @@ def plain(value):
return str(value)
return value
def structure(solo):
harmony = generator.solo.harmonic.Harmony(solo.harmony)
def structure(solo, *, composition=None):
composition = composition or generator.vocabulary.current()
harmony = generator.solo.harmonic.Harmony(solo.harmony, composition=composition)
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 = []
@@ -37,8 +39,9 @@ def structure(solo):
'harmonic_roles': harmony.describe(ns, expressions=contracts), 'harmonic_policy': 'contextual (shared by authored and predicted music)', 'harmonic_valid': checked.valid, 'harmonic_error': checked.error,
'harmonic_exposure': checked.exposure, 'unresolved_treatments': plain(checked.pending)}
def describe(solo, measure_offset=0, time_offset=0, *, detail=None):
detail = detail or structure(solo)
def describe(solo, measure_offset=0, time_offset=0, *, detail=None, composition=None):
composition = composition or generator.vocabulary.current()
detail = detail or structure(solo, composition=composition)
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: