@@ -104,6 +104,8 @@ Electric accompaniment and electric melody share compact chord families on strin
|
|||||||
|
|
||||||
Accompaniment uses independently authored two-bar jazz pattern families, chosen against each song's original melody and harmonic rhythm. Acoustic repeats two patterns (A–B); electric repeats four (A–B–C–D). Occasional source-phrase-ending fills recur at the same positions in every iteration; both instruments allow at most one per eight full measures, including the repeat join. Acoustic normally has 1–3 attack positions per full bar with quarter/eighth bass and upper-chord notes that may overlap or sound together. Electric has distinct patterns with normally 0–2 attacks per bar, stabs, sustains and space. Cross-bar holds use ties; repeated attacks remain attacks. Pattern phase follows source-form position, including partial entries and odd-length forms. Meter changes use compatible cycles, with explicit diagnostics if fewer distinct patterns are available. Tempo changes do not reshuffle the accompaniment.
|
Accompaniment uses independently authored two-bar jazz pattern families, chosen against each song's original melody and harmonic rhythm. Acoustic repeats two patterns (A–B); electric repeats four (A–B–C–D). Occasional source-phrase-ending fills recur at the same positions in every iteration; both instruments allow at most one per eight full measures, including the repeat join. Acoustic normally has 1–3 attack positions per full bar with quarter/eighth bass and upper-chord notes that may overlap or sound together. Electric has distinct patterns with normally 0–2 attacks per bar, stabs, sustains and space. Cross-bar holds use ties; repeated attacks remain attacks. Pattern phase follows source-form position, including partial entries and odd-length forms. Meter changes use compatible cycles, with explicit diagnostics if fewer distinct patterns are available. Tempo changes do not reshuffle the accompaniment.
|
||||||
|
|
||||||
|
Each instrument has 24 distinct two-bar patterns in 4/4, 24 in 3/4, and 24 for each of the 3+2 and 2+3 groupings in 5/4. Written additive grouping takes precedence. Otherwise, the original chord changes and melody choose one consistent 5/4 grouping per continuous meter section, shared as source analysis by the independently generated parts. Explicit meter matching prevents treating 6/8 as 3/4. The learned solo model and playback count-in are unchanged by this accompaniment expansion.
|
||||||
|
|
||||||
Both accompaniment parts balance movement with sounding register and a soft cost for mean fret positions above nine; comfortable shifts can win over staying high. Acoustic uses a register center of MIDI 53 and electric uses 60. Compact spans and preferred chord families remain higher priorities, including for the final sustained chord. Electric melody's supporting chords use the same balance after the existing melody-string and foreground priorities; the original melody pitches and rhythm remain fixed. Voicing preferences do not reseed accompaniment rhythms.
|
Both accompaniment parts balance movement with sounding register and a soft cost for mean fret positions above nine; comfortable shifts can win over staying high. Acoustic uses a register center of MIDI 53 and electric uses 60. Compact spans and preferred chord families remain higher priorities, including for the final sustained chord. Electric melody's supporting chords use the same balance after the existing melody-string and foreground priorities; the original melody pitches and rhythm remain fixed. Voicing preferences do not reseed accompaniment rhythms.
|
||||||
|
|
||||||
The first acoustic attack for each new chord includes bass, alone or paired with the upper chord. A mid-measure change follows this rule; an unchanged chord crossing a barline does not create a bass obligation. Delayed entrances remain delayed. A qualified half-beat anticipated chord includes its bass at the anticipated entrance. Bass and upper notes share one engraved part, with ties preserving overlaps and independent releases.
|
The first acoustic attack for each new chord includes bass, alone or paired with the upper chord. A mid-measure change follows this rule; an unchanged chord crossing a barline does not create a bass obligation. Delayed entrances remain delayed. A qualified half-beat anticipated chord includes its bass at the anticipated entrance. Bass and upper notes share one engraved part, with ties preserving overlaps and independent releases.
|
||||||
|
|||||||
+8
-2
@@ -131,9 +131,15 @@ The active voicing selector compares complete paths with dynamic programming ove
|
|||||||
|
|
||||||
Silent spans retain the preceding hand position. A continuous accompaniment repeat includes the last-to-first transition. Source-harmony variants, such as relocated pickup harmony, are solved between fixed canonical neighbors without mutating a shared previous-grip state; request order cannot change them. Per-span composition reports include candidate counts, selected family, bass/string family availability, selected positions, bass degree and movement, plus `fret_span`, `span_group` and `span_candidates` (primary/fallback counts before dominance reduction). These are diagnostic sidecar fields; the score/runtime format is unchanged.
|
Silent spans retain the preceding hand position. A continuous accompaniment repeat includes the last-to-first transition. Source-harmony variants, such as relocated pickup harmony, are solved between fixed canonical neighbors without mutating a shared previous-grip state; request order cannot change them. Per-span composition reports include candidate counts, selected family, bass/string family availability, selected positions, bass degree and movement, plus `fret_span`, `span_group` and `span_candidates` (primary/fallback counts before dominance reduction). These are diagnostic sidecar fields; the score/runtime format is unchanged.
|
||||||
|
|
||||||
`comping_rhythm.py` selects recurring source-only figures from version 5 of `composition/accompaniment.json`. The normal acoustic cycle has two two-bar slots; electric has four. Each template and anticipation mode is evaluated at every occurrence of its slot before retrieval limits apply. Up to eight diverse candidates per slot enter bounded complete-cycle comparison. Distinct slots differ in attack/duration relationships, not just IDs, bass/upper assignments or anticipation flags. Repetition is not penalized. The source melody and harmonic rhythm affect selection; title, iteration, generated solo and tempo do not. `groove_cycles` reports slot identities, anticipation modes, aggregate cost, compared cycles and any reduction caused by vocabulary or form length.
|
`comping_rhythm.py` selects recurring source-only figures from version 6 of `composition/accompaniment.json`. The normal acoustic cycle has two two-bar slots; electric has four. Each template and anticipation mode is evaluated at every occurrence of its slot before retrieval limits apply. Up to eight diverse candidates per slot enter bounded complete-cycle comparison. Distinct slots differ in attack/duration relationships, not just IDs, bass/upper assignments or anticipation flags. Repetition is not penalized. The source melody and harmonic rhythm affect selection; title, iteration, generated solo and tempo do not. `groove_cycles` reports slot identities, anticipation modes, aggregate cost, compared cycles and any reduction caused by vocabulary or form length.
|
||||||
|
|
||||||
The cycle begins at the first full source-body measure. A partial-form entry slices the corresponding figure instead of restarting it; an odd final bar uses the appropriate half-pattern. Meter changes restart a compatible cycle. Unsupported durations fail explicitly, and limited vocabularies report a shorter cycle. Optional final-harmony changes rebind the same selected pattern. Canonical grips are fixed wherever the chord and interval start remain unchanged, so a new turnaround cannot move neighboring unaffected voicings.
|
Meter-specific full patterns declare `meter` and, for 5/4, `grouping: [3, 2]` or `[2, 3]`; `meter: partial` marks the bounded one-bar pickup vocabulary. The loader checks nominal lengths, grouping, quarter/eighth arithmetic, lane overlap and distinct acoustic/electric onset patterns. Each role has 24 distinct attack/duration figures per selectable pool: 4/4 (11 families), 3/4 (12 families), and each 5/4 grouping (12 families). The 4/4 pool removes one duplicate acoustic figure, develops another previously duplicated answer, and adds three electric variants. Patterns are independently authored from general principles, with research URLs and provenance in the resource, rather than transcriptions. The counts describe available choices, not required musical activity.
|
||||||
|
|
||||||
|
`grouping_for` is a pure source-only decision. It honors written 3+2 or 2+3, otherwise compares actual chord changes at the secondary boundary, then melody entrances following at least an eighth-note rest, then other melody attacks; equal evidence defaults to 3+2. An unchanged chord marking is not a change. The selection stays fixed through a continuous meter section, and each role derives it independently from the same source. Fills cannot borrow another grouping. No accents or playback swing changes are inferred. `groove_cycles` additionally reports grouping, its evidence and the eligible pattern count. Equal-duration compound meters (such as 6/8) do not borrow the 3/4 vocabulary; unsupported comping meters fail explicitly.
|
||||||
|
|
||||||
|
Accompaniment dependency keys and choice streams include only full-pattern meters and partial lengths present in that source, plus the applicable instrument policy. Adding unused-meter patterns therefore does not reseed an existing song; unrelated role policies remain excluded. Research prose is excluded from musical seeds. This schema migration changes the accompaniment choice-key domain once; it does not promise identical previous 4/4 tie-break winners. Source melodies, registered solos, notation format and playback count-in are unchanged.
|
||||||
|
|
||||||
|
The cycle begins at the first full source-body measure. A partial-form entry first fits the incoming harmony, then slices the sounded figure instead of restarting it; this prevents a previously clipped chord hold from reappearing as a new attack. An odd final bar uses the appropriate half-pattern. Meter changes restart a compatible cycle. Unsupported durations fail explicitly, and limited vocabularies report a shorter cycle. Optional final-harmony changes rebind the same selected pattern. Canonical grips are fixed wherever the chord and interval start remain unchanged, so a new turnaround cannot move neighboring unaffected voicings.
|
||||||
|
|
||||||
Fills replace only a two-bar figure's ending bar, using a same-family variation that improves the existing source-fit objective. Eligible boundaries are source section endings, form endings, or a melody breath of at least one quarter-note beat at the block end. Carried events from the opening bar are protected. The largest improvements are retained with an eight-measure minimum separation around the whole cyclic form, equally for both roles; no fill quota applies. Fills do not change cycle phase and recur at identical source positions. Reports record the base slot, selected fill, source index and reason.
|
Fills replace only a two-bar figure's ending bar, using a same-family variation that improves the existing source-fit objective. Eligible boundaries are source section endings, form endings, or a melody breath of at least one quarter-note beat at the block end. Carried events from the opening bar are protected. The largest improvements are retained with an eight-measure minimum separation around the whole cyclic form, equally for both roles; no fill quota applies. Fills do not change cycle phase and recur at identical source positions. Reports record the base slot, selected fill, source index and reason.
|
||||||
|
|
||||||
|
|||||||
+5134
-274
File diff suppressed because it is too large
Load Diff
@@ -284,6 +284,17 @@ class Canonical:
|
|||||||
offset = sum((generator.model.fraction(m['duration']) for m, _, i in original_group if i < start), fractions.Fraction())
|
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:
|
if original_group[0][2] in self.patterns:
|
||||||
pattern, anticipate = self.patterns[original_group[0][2]]
|
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))
|
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)
|
rhythm = generator.comping_rhythm.realize(pattern, group, self.part, self.melody, anticipate=anticipate, limit=limit, boundary=True)
|
||||||
if rhythm is None:
|
if rhythm is None:
|
||||||
|
|||||||
@@ -9,6 +9,65 @@ import generator.solo.analysis
|
|||||||
import generator.solo.policy
|
import generator.solo.policy
|
||||||
import generator.vocabulary
|
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)
|
@dataclasses.dataclass(frozen=True, order=True)
|
||||||
class Attack:
|
class Attack:
|
||||||
lane: str
|
lane: str
|
||||||
@@ -164,13 +223,11 @@ def evaluate(events, part, melody, harmony, lengths):
|
|||||||
divisor = max(1, len(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)))
|
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']),)
|
lengths = lengths or (generator.model.fraction(measure['duration']),)
|
||||||
policy = generator.vocabulary.accompaniment(part)
|
policy = generator.vocabulary.accompaniment(part)
|
||||||
result = []
|
result = []
|
||||||
for pattern in policy['patterns']:
|
for pattern in patterns_for(policy, measure, lengths, grouping):
|
||||||
if tuple(map(fractions.Fraction, pattern['lengths'])) != tuple(lengths):
|
|
||||||
continue
|
|
||||||
for anticipate in (False, True):
|
for anticipate in (False, True):
|
||||||
fitted = fit(pattern, measure, prior, part, anticipate=anticipate, limit=limit)
|
fitted = fit(pattern, measure, prior, part, anticipate=anticipate, limit=limit)
|
||||||
if fitted is None:
|
if fitted is None:
|
||||||
@@ -257,14 +314,15 @@ def choose_cycles(groups, part, melody, seed):
|
|||||||
runs = []
|
runs = []
|
||||||
for group in groups:
|
for group in groups:
|
||||||
m = group[0][0]
|
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:
|
if not runs or runs[-1][0] != key:
|
||||||
runs.append((key, []))
|
runs.append((key, []))
|
||||||
runs[-1][1].append(group)
|
runs[-1][1].append(group)
|
||||||
for (meter, length), run in runs:
|
for ((meter, _), length), run in runs:
|
||||||
patterns = [p for p in policy['patterns'] if tuple(map(fractions.Fraction, p['lengths'])) == (length, length)]
|
grouping, grouping_evidence = grouping_for(run, melody)
|
||||||
|
patterns = patterns_for(policy, run[0][0][0], (length, length), grouping)
|
||||||
if not patterns:
|
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']
|
requested = settings['cycle_patterns']
|
||||||
identities = {p['id']: identity(p) for p in patterns}
|
identities = {p['id']: identity(p) for p in patterns}
|
||||||
available = len(set(identities.values()))
|
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')
|
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]
|
cycle = winner[1]
|
||||||
report = {'source_start': run[0][0][2], 'source_end': run[-1][-1][2], 'meter': meter,
|
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],
|
'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,
|
'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}
|
'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):
|
if any(e.start < middle < e.end for e in current.events):
|
||||||
continue
|
continue
|
||||||
for variation in policy['patterns']:
|
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
|
continue
|
||||||
hybrid = {**base, 'id': base['id'] + '.fill.' + variation['id'], 'events':
|
hybrid = {**base, 'id': base['id'] + '.fill.' + variation['id'], 'events':
|
||||||
[r for r in base['events'] if fractions.Fraction(r['onset']) < middle] +
|
[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')))
|
result.append((cursor, cursor + generator.arrange.ending.duration(source), part + ('_accompaniment' if part != 'vocal' else '_rest')))
|
||||||
return tuple(result)
|
return tuple(result)
|
||||||
|
|
||||||
def dependencies(role, resources):
|
def dependencies(role, resources, measures=None):
|
||||||
names = ['harmony', 'lead_ins'] if role.endswith('_solo') else []
|
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']},
|
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 {}),
|
**({'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}}
|
**{name: resources[name] for name in names}}
|
||||||
|
|
||||||
def _code_key(role):
|
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
|
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
|
algorithm = VERSION + '/' + generator.solo.learned.VERSION if role.endswith('_solo') else VERSION
|
||||||
inputs = {'version': algorithm, 'source': source.musical_key, 'role': role, 'settings': settings,
|
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.
|
# Source notation/lyrics also invalidate cached score representation, never the musical seed.
|
||||||
key = generator.vocabulary.fingerprint({'inputs': inputs, 'notation': source.document_json})
|
key = generator.vocabulary.fingerprint({'inputs': inputs, 'notation': source.document_json})
|
||||||
path = pathlib.Path(cache_dir) / role / (key + '.json') if cache_dir else None
|
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.
|
# rhythm patterns. Removing this new field preserves the pre-policy seed.
|
||||||
choice_data = copy.deepcopy(choice_data)
|
choice_data = copy.deepcopy(choice_data)
|
||||||
choice_data['roles']['role'].pop('voicing', None)
|
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'}
|
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})
|
seed = generator.vocabulary.fingerprint({'source': choice_source, 'role': role, 'settings': choice_settings, 'data': choice_data, 'version': algorithm})
|
||||||
details = {}
|
details = {}
|
||||||
|
|||||||
+11
-2
@@ -23,7 +23,7 @@ def _load(contents):
|
|||||||
result = {}
|
result = {}
|
||||||
for name, text in contents:
|
for name, text in contents:
|
||||||
value = json.loads(text)
|
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')
|
raise ValueError(f'{name}: unsupported or unprovenanced production vocabulary')
|
||||||
if name == 'accompaniment':
|
if name == 'accompaniment':
|
||||||
signatures = {}
|
signatures = {}
|
||||||
@@ -43,6 +43,15 @@ def _load(contents):
|
|||||||
lengths = tuple(map(fractions.Fraction, pattern['lengths']))
|
lengths = tuple(map(fractions.Fraction, pattern['lengths']))
|
||||||
if not 1 <= len(lengths) <= 2 or any(n <= 0 for n in 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')
|
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.
|
# Validate musical events before any measure projection or ties.
|
||||||
previous, releases = fractions.Fraction(), {}
|
previous, releases = fractions.Fraction(), {}
|
||||||
onsets = []
|
onsets = []
|
||||||
@@ -69,7 +78,7 @@ def _load(contents):
|
|||||||
previous = onset
|
previous = onset
|
||||||
onsets.append(onset)
|
onsets.append(onset)
|
||||||
if len(lengths) == 2:
|
if len(lengths) == 2:
|
||||||
signatures[part].add((lengths, tuple(onsets)))
|
signatures[part].add((meter, grouping, tuple(onsets)))
|
||||||
families = policy['voicing_families']
|
families = policy['voicing_families']
|
||||||
if not families.get('provenance'):
|
if not families.get('provenance'):
|
||||||
raise ValueError('guitar voicing families require provenance')
|
raise ValueError('guitar voicing families require provenance')
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""Meter-specific vocabulary and source-derived quintuple grouping."""
|
||||||
|
import concurrent.futures
|
||||||
|
import copy
|
||||||
|
import fractions
|
||||||
|
import json
|
||||||
|
import multiprocessing
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import unittest.mock
|
||||||
|
|
||||||
|
import generator.accompaniment
|
||||||
|
import generator.comping
|
||||||
|
import generator.comping_rhythm
|
||||||
|
import generator.model
|
||||||
|
import generator.production
|
||||||
|
import generator.vocabulary
|
||||||
|
import tests.solo_fixtures
|
||||||
|
import tests.test_comping_cycles
|
||||||
|
|
||||||
|
F = fractions.Fraction
|
||||||
|
|
||||||
|
class CompingMeters(unittest.TestCase):
|
||||||
|
def source(self, beats=5, count=10, explicit=()):
|
||||||
|
source = tests.solo_fixtures.song(meter=beats, count=count, chords=('C7', 'FM7'))
|
||||||
|
if explicit:
|
||||||
|
for m in source['staves'][0]['measures']:
|
||||||
|
m['meter']['groups'][0]['beats'] = list(explicit)
|
||||||
|
m['time_signature'] = generator.model.meter_label(m['meter'])
|
||||||
|
source['metadata']['time_signature'] = source['staves'][0]['measures'][0]['time_signature']
|
||||||
|
return source
|
||||||
|
def test_balanced_pools_and_all_families_reach_joint_evaluation(self):
|
||||||
|
for beats, grouping in ((4, ()), (3, ()), (5, (3, 2)), (5, (2, 3))):
|
||||||
|
source = self.source(beats, 2, grouping)
|
||||||
|
inputs = tests.test_comping_cycles.groups(source)[0]
|
||||||
|
flat = generator.comping_rhythm.flatten(inputs)
|
||||||
|
for role in ('acoustic', 'electric'):
|
||||||
|
with self.subTest(beats=beats, grouping=grouping, role=role):
|
||||||
|
policy = generator.vocabulary.accompaniment(role)
|
||||||
|
pool = generator.comping_rhythm.patterns_for(policy, flat, (F(beats),) * 2, grouping)
|
||||||
|
self.assertEqual(len(pool), 24)
|
||||||
|
self.assertEqual(len({generator.comping_rhythm.identity(p) for p in pool}), 24)
|
||||||
|
self.assertEqual(len({p['family'] for p in pool}), 11 if beats == 4 else 12)
|
||||||
|
candidates = generator.comping_rhythm.candidates(flat, None, role, (), 'coverage', lengths=(F(beats),) * 2, grouping=grouping)
|
||||||
|
self.assertEqual({p['family'] for p in pool}, {dict(c.families)['pattern'] for c in candidates})
|
||||||
|
for p in pool:
|
||||||
|
fitted = generator.comping_rhythm.realize(p, inputs, role, generator.comping_rhythm.melody_contexts(source))
|
||||||
|
self.assertIsNotNone(fitted, p['id'])
|
||||||
|
generator.comping_rhythm.validate_events(fitted.events, role)
|
||||||
|
for start in (0, beats):
|
||||||
|
self.assertLessEqual(len({e.start for e in fitted.events if start <= e.start < start + beats}), 3 if role == 'acoustic' else 2)
|
||||||
|
if role == 'electric':
|
||||||
|
self.assertTrue(any(F(e['onset']) < beats < F(e['onset']) + F(e['duration']) for p in pool for e in p['events']))
|
||||||
|
def test_explicit_grouping_overrides_harmony_and_melody(self):
|
||||||
|
source = self.source(explicit=(2, 3))
|
||||||
|
for m in source['staves'][0]['measures']:
|
||||||
|
m['harmonies'].append({**generator.harmony.parse_symbol('G7'), 'id': 'secondary', 'onset': generator.model.rational(3)})
|
||||||
|
grouping, evidence = generator.comping_rhythm.grouping_for(tests.test_comping_cycles.groups(source), generator.comping_rhythm.melody_contexts(source))
|
||||||
|
self.assertEqual(grouping, (2, 3))
|
||||||
|
self.assertEqual(evidence['reason'], 'written')
|
||||||
|
def test_harmonic_boundary_then_melodic_entrances_then_attacks(self):
|
||||||
|
for boundary in (2, 3):
|
||||||
|
source = self.source()
|
||||||
|
for m in source['staves'][0]['measures']:
|
||||||
|
m['harmonies'].append({**generator.harmony.parse_symbol('G7'), 'id': 'secondary', 'onset': generator.model.rational(boundary)})
|
||||||
|
grouping, _ = generator.comping_rhythm.grouping_for(tests.test_comping_cycles.groups(source), generator.comping_rhythm.melody_contexts(source))
|
||||||
|
self.assertEqual(grouping, (boundary, 5 - boundary))
|
||||||
|
source = self.source()
|
||||||
|
# Competing secondary pulses have equal attack counts. Only one follows
|
||||||
|
# an actual rest; an uninterrupted reattack is weaker grouping evidence.
|
||||||
|
other = 5 - boundary
|
||||||
|
contexts = tuple(((F(other) - F(1, 2), F(other)), (F(other), F(other) + F(1, 2)), (F(boundary), F(boundary) + F(1, 2))) for _ in range(10))
|
||||||
|
grouping, _ = generator.comping_rhythm.grouping_for(tests.test_comping_cycles.groups(source), contexts)
|
||||||
|
self.assertEqual(grouping, (boundary, 5 - boundary))
|
||||||
|
contexts = tuple(((F(boundary), F(boundary + 1)),) for _ in range(10))
|
||||||
|
grouping, _ = generator.comping_rhythm.grouping_for(tests.test_comping_cycles.groups(source), contexts)
|
||||||
|
self.assertEqual(grouping, (boundary, 5 - boundary))
|
||||||
|
def test_same_chord_marking_is_not_a_harmonic_change(self):
|
||||||
|
source = self.source()
|
||||||
|
for m in source['staves'][0]['measures']:
|
||||||
|
m['harmonies'].append({**m['harmonies'][0], 'onset': generator.model.rational(2)})
|
||||||
|
grouping, evidence = generator.comping_rhythm.grouping_for(tests.test_comping_cycles.groups(source), generator.comping_rhythm.melody_contexts(source))
|
||||||
|
self.assertEqual(grouping, (3, 2))
|
||||||
|
self.assertEqual(evidence['reason'], 'default_3+2')
|
||||||
|
def test_not_just_equal_measure_length(self):
|
||||||
|
source = self.source(3)
|
||||||
|
m = source['staves'][0]['measures'][0]
|
||||||
|
m.update(meter={'groups': [{'beats': [6], 'unit': 8}]}, time_signature='6/8')
|
||||||
|
with self.assertRaisesRegex(ValueError, 'no comping vocabulary for 6/8'):
|
||||||
|
generator.comping.Canonical(source, 'electric', 'compound')
|
||||||
|
def test_schema_rejects_wrong_meter_lengths_and_missing_grouping(self):
|
||||||
|
root = pathlib.Path('data/composition')
|
||||||
|
contents = {p.stem: p.read_text() for p in root.glob('*.json')}
|
||||||
|
for change in ({'meter': '3/4'}, {'grouping': []}, {'grouping': [4, 1]}):
|
||||||
|
value = json.loads(contents['accompaniment'])
|
||||||
|
pattern = next(p for p in value['instruments']['electric']['patterns'] if p['meter'] == '5/4')
|
||||||
|
pattern.update(change)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
generator.vocabulary._load(tuple((name, json.dumps(value) if name == 'accompaniment' else text) for name, text in contents.items()))
|
||||||
|
def test_full_cycles_odd_forms_and_grouping_consistency(self):
|
||||||
|
for beats, grouping in ((4, ()), (3, ()), (5, (3, 2)), (5, (2, 3))):
|
||||||
|
source = self.source(beats, 9, grouping)
|
||||||
|
reports = []
|
||||||
|
for role, width in (('acoustic', 2), ('electric', 4)):
|
||||||
|
canonical = generator.comping.Canonical(source, role, 'cycles')
|
||||||
|
report = canonical.groove_report[0]
|
||||||
|
reports.append(report)
|
||||||
|
self.assertEqual(report['cycle_patterns'], width)
|
||||||
|
self.assertEqual(report['eligible_patterns'], 24)
|
||||||
|
self.assertIsNone(report['reduction'])
|
||||||
|
for location in canonical.locations.values():
|
||||||
|
slot = location['cycle_slot']
|
||||||
|
self.assertEqual(location['base_pattern'], report['patterns'][slot])
|
||||||
|
last = canonical.block_rhythms[8]
|
||||||
|
self.assertTrue(all(0 <= e.start < e.end <= beats for e in last.events))
|
||||||
|
self.assertEqual(reports[0]['grouping'], reports[1]['grouping'])
|
||||||
|
def test_grouping_changes_restart_and_fills_cannot_cross_groups(self):
|
||||||
|
source = self.source(count=18, explicit=(3, 2))
|
||||||
|
for m in source['staves'][0]['measures'][9:]:
|
||||||
|
m['meter']['groups'][0]['beats'] = [2, 3]
|
||||||
|
m['time_signature'] = '2+3/4'
|
||||||
|
for role in ('acoustic', 'electric'):
|
||||||
|
canonical = generator.comping.Canonical(source, role, 'change')
|
||||||
|
self.assertEqual([r['grouping'] for r in canonical.groove_report], [[3, 2], [2, 3]])
|
||||||
|
self.assertEqual(canonical.locations[9]['cycle_slot'], 0)
|
||||||
|
for pattern, _ in canonical.patterns.values():
|
||||||
|
suffix = '.' + '+'.join(map(str, pattern['grouping'])) + '.'
|
||||||
|
if '.fill.' in pattern['id']:
|
||||||
|
self.assertTrue(all(suffix in p for p in pattern['id'].split('.fill.')))
|
||||||
|
def test_every_fill_candidate_uses_its_base_grouping(self):
|
||||||
|
source = self.source(count=16, explicit=(2, 3))
|
||||||
|
checked = []
|
||||||
|
realize = generator.comping_rhythm.realize
|
||||||
|
def inspect(pattern, *args, **kwargs):
|
||||||
|
if '.fill.' in pattern['id']:
|
||||||
|
checked.append(pattern['id'])
|
||||||
|
self.assertTrue(all('.2+3.' in p for p in pattern['id'].split('.fill.')))
|
||||||
|
return realize(pattern, *args, **kwargs)
|
||||||
|
with unittest.mock.patch.object(generator.comping_rhythm, 'realize', side_effect=inspect):
|
||||||
|
generator.comping.Canonical(source, 'electric', 'fill-group')
|
||||||
|
self.assertTrue(checked)
|
||||||
|
def test_partial_pickup_and_mid_pattern_entry_keep_meter_and_phase(self):
|
||||||
|
for beats in (3, 5):
|
||||||
|
source = self.source(beats)
|
||||||
|
m = copy.deepcopy(source['staves'][0]['measures'][0])
|
||||||
|
m['duration'] = generator.model.rational(F(3, 2))
|
||||||
|
candidates = generator.comping_rhythm.candidates(m, None, 'electric', (), 'pickup')
|
||||||
|
self.assertTrue(candidates)
|
||||||
|
self.assertTrue(all(0 <= e.start < e.end <= F(3, 2) for c in candidates for e in c.events))
|
||||||
|
for role in ('acoustic', 'electric'):
|
||||||
|
canonical = generator.comping.Canonical(source, role, 'entry')
|
||||||
|
measures = source['staves'][0]['measures']
|
||||||
|
carried = generator.accompaniment.carried_chords(measures)
|
||||||
|
full = canonical(measures[3], carried[3], 3)
|
||||||
|
partial = canonical(measures[3], carried[3], 3, bounds=(3, 10))
|
||||||
|
# Rest engraving can split differently at a sliced boundary. Sounded
|
||||||
|
# onsets, releases, voicings and tie relationships must remain equal.
|
||||||
|
self.assertEqual([n for n in tests.test_comping_cycles.signature(full) if n[-1]], [n for n in tests.test_comping_cycles.signature(partial) if n[-1]])
|
||||||
|
def test_unused_meter_and_other_role_data_do_not_reseed(self):
|
||||||
|
resources = generator.vocabulary.load()
|
||||||
|
measures = self.source(4)['staves'][0]['measures']
|
||||||
|
baseline = generator.production.dependencies('acoustic_accompaniment', resources, measures)
|
||||||
|
changed = copy.deepcopy(resources)
|
||||||
|
for role, policy in changed['accompaniment']['instruments'].items():
|
||||||
|
for p in policy['patterns']:
|
||||||
|
if role == 'electric' or p['meter'] in ('3/4', '5/4'):
|
||||||
|
p['events'] = []
|
||||||
|
self.assertEqual(baseline, generator.production.dependencies('acoustic_accompaniment', changed, measures))
|
||||||
|
changed['accompaniment']['instruments']['acoustic']['patterns'][0]['events'] = []
|
||||||
|
self.assertNotEqual(baseline, generator.production.dependencies('acoustic_accompaniment', changed, measures))
|
||||||
|
def test_chord_changes_anticipations_and_cross_bar_ties(self):
|
||||||
|
for beats, grouping in ((4, ()), (3, ()), (5, (3, 2)), (5, (2, 3))):
|
||||||
|
source = self.source(beats, 2, grouping)
|
||||||
|
for m in source['staves'][0]['measures']:
|
||||||
|
m['harmonies'].append({**generator.harmony.parse_symbol('G7'), 'id': 'middle', 'onset': generator.model.rational(beats - 1)})
|
||||||
|
inputs = tests.test_comping_cycles.groups(source)[0]
|
||||||
|
for role in ('acoustic', 'electric'):
|
||||||
|
pool = generator.comping_rhythm.patterns_for(generator.vocabulary.accompaniment(role), inputs[0][0], (F(beats),) * 2, grouping)
|
||||||
|
anticipated = False
|
||||||
|
for p in pool:
|
||||||
|
result = generator.comping_rhythm.realize(p, inputs, role, generator.comping_rhythm.melody_contexts(source), anticipate=True)
|
||||||
|
self.assertIsNotNone(result, p['id'])
|
||||||
|
generator.comping_rhythm.validate_events(result.events, role)
|
||||||
|
for e in result.events:
|
||||||
|
if e.harmonic_time is not None:
|
||||||
|
anticipated = True
|
||||||
|
self.assertEqual(e.harmonic_time - e.start, F(1, 2))
|
||||||
|
self.assertTrue(anticipated)
|
||||||
|
def test_roles_are_deterministic_alone_cached_and_parallel(self):
|
||||||
|
for meter in (3, 5):
|
||||||
|
source = generator.production.SourceAnalysis.read(self.source(meter, 9))
|
||||||
|
roles = ('acoustic_accompaniment', 'electric_accompaniment')
|
||||||
|
with tempfile.TemporaryDirectory() as cache:
|
||||||
|
clean = {r: generator.production.generate_role(source, r, pathlib.Path('data')) for r in roles}
|
||||||
|
for role in reversed(roles):
|
||||||
|
cold = generator.production.generate_role(source, role, pathlib.Path('data'), cache_dir=cache)
|
||||||
|
warm = generator.production.generate_role(source, role, pathlib.Path('data'), cache_dir=cache)
|
||||||
|
self.assertEqual(clean[role]['document'], cold['document'])
|
||||||
|
self.assertEqual(cold['document'], warm['document'])
|
||||||
|
self.assertTrue(warm['cache_hit'])
|
||||||
|
with concurrent.futures.ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as pool:
|
||||||
|
jobs = {r: pool.submit(generator.production._job, (source, r, pathlib.Path('data'), {}, None)) for r in reversed(roles)}
|
||||||
|
for role, job in jobs.items():
|
||||||
|
self.assertEqual(clean[role]['document'], job.result()['document'])
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user