@@ -284,6 +284,17 @@ class Canonical:
|
||||
offset = sum((generator.model.fraction(m['duration']) for m, _, i in original_group if i < start), fractions.Fraction())
|
||||
if original_group[0][2] in self.patterns:
|
||||
pattern, anticipate = self.patterns[original_group[0][2]]
|
||||
if offset:
|
||||
# Fit the incoming context before slicing. A raw template sustain
|
||||
# may already have ended at a chord change; projecting it first
|
||||
# would resurrect that note as an unintended new downbeat attack.
|
||||
context = tuple((source if i == index else sources[i], prior if i == index else carried[i], i)
|
||||
for _, _, i in original_group if i <= end)
|
||||
incoming = generator.comping_rhythm.realize(pattern, context, self.part, self.melody, anticipate=anticipate, limit=offset + limit if limit is not None else None, boundary=True)
|
||||
if incoming is None:
|
||||
raise ValueError(f'source measure {source["number"]}: selected groove cannot fit incoming harmony')
|
||||
pattern = {**pattern, 'events': [{'onset': str(e.start), 'duration': str(e.end - e.start),
|
||||
'layer': e.lane, 'anticipate': e.harmonic_time is not None and e.lane in ('upper', 'electric')} for e in incoming.events]}
|
||||
pattern = generator.comping_rhythm.project(pattern, offset, tuple(generator.model.fraction(m['duration']) for m, _, _ in group))
|
||||
rhythm = generator.comping_rhythm.realize(pattern, group, self.part, self.melody, anticipate=anticipate, limit=limit, boundary=True)
|
||||
if rhythm is None:
|
||||
|
||||
@@ -9,6 +9,65 @@ import generator.solo.analysis
|
||||
import generator.solo.policy
|
||||
import generator.vocabulary
|
||||
|
||||
def meter_context(measure):
|
||||
"""Nominal meter and explicit additive grouping, independent of bar length.
|
||||
|
||||
In particular, a three-quarter-note pickup or a 6/8 bar is not 3/4.
|
||||
"""
|
||||
groups = measure['meter']['groups']
|
||||
if not groups or any(g['unit'] != 4 for g in groups):
|
||||
raise ValueError(f"source measure {measure['number']}: no comping vocabulary for {measure['time_signature']}")
|
||||
beats = tuple(b for g in groups for b in g['beats'])
|
||||
total = sum(beats)
|
||||
if total not in (3, 4, 5) or (len(beats) > 1 and beats not in ((3, 2), (2, 3))):
|
||||
raise ValueError(f"source measure {measure['number']}: unsupported comping grouping {measure['time_signature']}")
|
||||
return f'{total}/4', beats if len(beats) > 1 else ()
|
||||
|
||||
def grouping_for(run, melody):
|
||||
"""Choose one source-only quintuple feel, shared by independent roles.
|
||||
|
||||
Written grouping wins. Otherwise compare actual chord changes, entrances
|
||||
after an eighth-note breath, then all melodic attacks at the group boundary.
|
||||
Counts are evidence, not inferred accents or performance dynamics.
|
||||
"""
|
||||
meter, explicit = meter_context(run[0][0][0])
|
||||
if meter != '5/4':
|
||||
return (), {'reason': 'simple_meter'}
|
||||
if explicit:
|
||||
return explicit, {'reason': 'written'}
|
||||
evidence = {}
|
||||
for boundary in (3, 2):
|
||||
changes, entrances, attacks = 0, 0, 0
|
||||
for group in run:
|
||||
for m, prior, i in group:
|
||||
changes += sum(a == boundary and c is not None for a, _, c in spans(m, prior))
|
||||
context = melody[i]
|
||||
for a, _ in context:
|
||||
if a != boundary:
|
||||
continue
|
||||
attacks += 1
|
||||
previous = max((b for x, b in context if x < a), default=fractions.Fraction())
|
||||
entrances += a - previous >= fractions.Fraction(1, 2)
|
||||
evidence[boundary] = (changes, entrances, attacks)
|
||||
boundary = 2 if evidence[2] > evidence[3] else 3
|
||||
return (boundary, 5 - boundary), {'reason': 'source' if evidence[2] != evidence[3] else 'default_3+2',
|
||||
'boundary_evidence': {str(k): dict(zip(('chord_changes', 'melody_entrances', 'melody_attacks'), v)) for k, v in evidence.items()}}
|
||||
|
||||
def patterns_for(policy, measure, lengths, grouping=()):
|
||||
meter, explicit = meter_context(measure)
|
||||
grouping = explicit or grouping or ((3, 2) if meter == '5/4' else ())
|
||||
# Untagged in-memory figures are allowed for independently authored unit
|
||||
# tests; the resource loader requires metadata for every production entry.
|
||||
return [p for p in policy['patterns'] if tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths)
|
||||
and (p.get('meter') == 'partial' or (p.get('meter', meter) == meter and tuple(p.get('grouping', grouping)) == grouping))]
|
||||
|
||||
def relevant_policy(policy, measures):
|
||||
"""An unused meter's vocabulary must not influence this role's decisions."""
|
||||
meters = {meter_context(m)[0] for m in measures}
|
||||
lengths = {generator.model.fraction(m['duration']) for m in measures}
|
||||
return {**policy, 'patterns': [p for p in policy['patterns'] if p.get('meter') in meters
|
||||
or (p.get('meter') == 'partial' and fractions.Fraction(p['lengths'][0]) in lengths)]}
|
||||
|
||||
@dataclasses.dataclass(frozen=True, order=True)
|
||||
class Attack:
|
||||
lane: str
|
||||
@@ -164,13 +223,11 @@ def evaluate(events, part, melody, harmony, lengths):
|
||||
divisor = max(1, len(lengths))
|
||||
return tuple((k, v / divisor) for k, v in (('activity', activity), ('melody_support', support), ('harmonic_support', coverage), ('articulation', articulation)))
|
||||
|
||||
def candidates(measure, prior, part, melody, seed, *, lengths=None, limit=None):
|
||||
def candidates(measure, prior, part, melody, seed, *, lengths=None, limit=None, grouping=()):
|
||||
lengths = lengths or (generator.model.fraction(measure['duration']),)
|
||||
policy = generator.vocabulary.accompaniment(part)
|
||||
result = []
|
||||
for pattern in policy['patterns']:
|
||||
if tuple(map(fractions.Fraction, pattern['lengths'])) != tuple(lengths):
|
||||
continue
|
||||
for pattern in patterns_for(policy, measure, lengths, grouping):
|
||||
for anticipate in (False, True):
|
||||
fitted = fit(pattern, measure, prior, part, anticipate=anticipate, limit=limit)
|
||||
if fitted is None:
|
||||
@@ -257,14 +314,15 @@ def choose_cycles(groups, part, melody, seed):
|
||||
runs = []
|
||||
for group in groups:
|
||||
m = group[0][0]
|
||||
key = (m['time_signature'], generator.model.fraction(m['duration']))
|
||||
key = (meter_context(m), generator.model.fraction(m['duration']))
|
||||
if not runs or runs[-1][0] != key:
|
||||
runs.append((key, []))
|
||||
runs[-1][1].append(group)
|
||||
for (meter, length), run in runs:
|
||||
patterns = [p for p in policy['patterns'] if tuple(map(fractions.Fraction, p['lengths'])) == (length, length)]
|
||||
for ((meter, _), length), run in runs:
|
||||
grouping, grouping_evidence = grouping_for(run, melody)
|
||||
patterns = patterns_for(policy, run[0][0][0], (length, length), grouping)
|
||||
if not patterns:
|
||||
patterns = [p for p in policy['patterns'] if tuple(map(fractions.Fraction, p['lengths'])) == (length,)]
|
||||
patterns = patterns_for(policy, run[0][0][0], (length,), grouping)
|
||||
requested = settings['cycle_patterns']
|
||||
identities = {p['id']: identity(p) for p in patterns}
|
||||
available = len(set(identities.values()))
|
||||
@@ -311,6 +369,7 @@ def choose_cycles(groups, part, melody, seed):
|
||||
raise ValueError(f'source measure {run[0][0][0]["number"]}: no legal recurring {part} groove for {meter}; check harmonic releases and pattern vocabulary')
|
||||
cycle = winner[1]
|
||||
report = {'source_start': run[0][0][2], 'source_end': run[-1][-1][2], 'meter': meter,
|
||||
'grouping': list(grouping), 'grouping_evidence': grouping_evidence, 'eligible_patterns': len(patterns),
|
||||
'requested_patterns': requested, 'cycle_patterns': len(cycle), 'patterns': [o[1]['id'] for o in cycle],
|
||||
'anticipation': [o[2] for o in cycle], 'cost': winner[0][0], 'complete_cycles_compared': examined,
|
||||
'reduction': 'available distinct, legal patterns or source length' if len(cycle) < requested else None}
|
||||
@@ -347,7 +406,7 @@ def add_fills(groups, selected, locations, part, melody, seed):
|
||||
if any(e.start < middle < e.end for e in current.events):
|
||||
continue
|
||||
for variation in policy['patterns']:
|
||||
if variation['family'] != base['family'] or variation['lengths'] != base['lengths']:
|
||||
if variation['family'] != base['family'] or variation['lengths'] != base['lengths'] or variation.get('meter') != base.get('meter') or variation.get('grouping') != base.get('grouping'):
|
||||
continue
|
||||
hybrid = {**base, 'id': base['id'] + '.fill.' + variation['id'], 'events':
|
||||
[r for r in base['events'] if fractions.Fraction(r['onset']) < middle] +
|
||||
|
||||
@@ -98,11 +98,15 @@ def windows(document, part):
|
||||
result.append((cursor, cursor + generator.arrange.ending.duration(source), part + ('_accompaniment' if part != 'vocal' else '_rest')))
|
||||
return tuple(result)
|
||||
|
||||
def dependencies(role, resources):
|
||||
def dependencies(role, resources, measures=None):
|
||||
names = ['harmony', 'lead_ins'] if role.endswith('_solo') else []
|
||||
accompaniment = {}
|
||||
if role.endswith('_accompaniment'):
|
||||
policy = generator.vocabulary.accompaniment(role.split('_')[0], resources)
|
||||
accompaniment = {'accompaniment': generator.comping_rhythm.relevant_policy(policy, measures) if measures is not None else policy}
|
||||
return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']},
|
||||
**({'voicing_families': generator.vocabulary.accompaniment('electric', resources)['voicing_families']} if role == 'electric_melody' else {}),
|
||||
**({'accompaniment': generator.vocabulary.accompaniment(role.split('_')[0], resources)} if role.endswith('_accompaniment') else {}),
|
||||
**accompaniment,
|
||||
**{name: resources[name] for name in names}}
|
||||
|
||||
def _code_key(role):
|
||||
@@ -193,7 +197,7 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
registered = generator.solo.authored.lookup(source, role.removesuffix('_solo'), data_dir) if role.endswith('_solo') and not settings.get('predict', False) else None
|
||||
algorithm = VERSION + '/' + generator.solo.learned.VERSION if role.endswith('_solo') else VERSION
|
||||
inputs = {'version': algorithm, 'source': source.musical_key, 'role': role, 'settings': settings,
|
||||
'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)}
|
||||
'vocabulary': dependencies(role, resources, generator.score.source_staff(source.document())['measures']), '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 = generator.vocabulary.fingerprint({'inputs': inputs, 'notation': source.document_json})
|
||||
path = pathlib.Path(cache_dir) / role / (key + '.json') if cache_dir else None
|
||||
@@ -219,6 +223,8 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
# rhythm patterns. Removing this new field preserves the pre-policy seed.
|
||||
choice_data = copy.deepcopy(choice_data)
|
||||
choice_data['roles']['role'].pop('voicing', None)
|
||||
for field in ('provenance', 'license', 'research'):
|
||||
choice_data['accompaniment'].pop(field, None)
|
||||
choice_settings = {k: v for k, v in settings.items() if k != 'voicing'}
|
||||
seed = generator.vocabulary.fingerprint({'source': choice_source, 'role': role, 'settings': choice_settings, 'data': choice_data, 'version': algorithm})
|
||||
details = {}
|
||||
|
||||
+11
-2
@@ -23,7 +23,7 @@ def _load(contents):
|
||||
result = {}
|
||||
for name, text in contents:
|
||||
value = json.loads(text)
|
||||
if value.get('version') != (5 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'):
|
||||
if value.get('version') != (6 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'):
|
||||
raise ValueError(f'{name}: unsupported or unprovenanced production vocabulary')
|
||||
if name == 'accompaniment':
|
||||
signatures = {}
|
||||
@@ -43,6 +43,15 @@ def _load(contents):
|
||||
lengths = tuple(map(fractions.Fraction, pattern['lengths']))
|
||||
if not 1 <= len(lengths) <= 2 or any(n <= 0 for n in lengths):
|
||||
raise ValueError('patterns require one or two positive measure lengths')
|
||||
meter = pattern.get('meter')
|
||||
grouping = tuple(pattern.get('grouping', ()))
|
||||
if meter == 'partial':
|
||||
if len(lengths) != 1 or grouping:
|
||||
raise ValueError('partial patterns require one measure and no grouping')
|
||||
elif meter not in ('3/4', '4/4', '5/4') or lengths != (fractions.Fraction(meter[0]),) * 2:
|
||||
raise ValueError('full patterns require an explicit supported meter and two complete bars')
|
||||
if (meter == '5/4' and grouping not in ((3, 2), (2, 3))) or (meter != '5/4' and grouping):
|
||||
raise ValueError('5/4 patterns require a 3+2 or 2+3 grouping')
|
||||
# Validate musical events before any measure projection or ties.
|
||||
previous, releases = fractions.Fraction(), {}
|
||||
onsets = []
|
||||
@@ -69,7 +78,7 @@ def _load(contents):
|
||||
previous = onset
|
||||
onsets.append(onset)
|
||||
if len(lengths) == 2:
|
||||
signatures[part].add((lengths, tuple(onsets)))
|
||||
signatures[part].add((meter, grouping, tuple(onsets)))
|
||||
families = policy['voicing_families']
|
||||
if not families.get('provenance'):
|
||||
raise ValueError('guitar voicing families require provenance')
|
||||
|
||||
Reference in New Issue
Block a user