@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user