Comping
This commit is contained in:
@@ -6,6 +6,7 @@ import itertools
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.model
|
||||
import generator.score
|
||||
|
||||
@dataclasses.dataclass
|
||||
class HarmonySpan:
|
||||
@@ -107,3 +108,41 @@ def combine(document: dict) -> None:
|
||||
a = fragments[start][-1] if start in fragments else originals[start]
|
||||
b = fragments[end][0] if end in fragments else originals[end]
|
||||
a['tie_to'], b['tie_from'] = b['id'], a['id']
|
||||
|
||||
def validate_comping(document: dict) -> None:
|
||||
"""Check complete sounding events, not barline or notation fragments."""
|
||||
tones, starts, cursor = {}, {}, fractions.Fraction()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
for note in generator.score.measure_notes(measure):
|
||||
for pitch in note['pitches']:
|
||||
tones[pitch['id']] = pitch
|
||||
starts[pitch['id']] = cursor + generator.model.fraction(note['onset'])
|
||||
cursor += generator.model.fraction(measure['duration'])
|
||||
groups = {}
|
||||
for identifier, pitch in tones.items():
|
||||
if pitch.get('tie_from') in tones:
|
||||
continue
|
||||
start, tail = starts[identifier], pitch
|
||||
seen = {identifier}
|
||||
while tail.get('tie_to') in tones:
|
||||
target = tail['tie_to']
|
||||
if target in seen:
|
||||
raise ValueError('cyclic acoustic sustain')
|
||||
seen.add(target)
|
||||
tail = tones[target]
|
||||
end = starts[tail['id']] + generator.model.fraction(tail['duration'])
|
||||
string = pitch.get('tablature', {}).get('string')
|
||||
if string is None:
|
||||
raise ValueError('acoustic comping requires placed notes')
|
||||
groups.setdefault((start, end), set()).add('bass' if string >= 5 else 'upper')
|
||||
events = []
|
||||
for (start, end), lanes in groups.items():
|
||||
if len(lanes) == 2:
|
||||
if end - start <= 1:
|
||||
raise ValueError('combined acoustic sustain must exceed one beat')
|
||||
elif end - start not in (fractions.Fraction(1, 2), 1):
|
||||
raise ValueError('separate acoustic comping requires quarter/eighth notes')
|
||||
events.append((start, end))
|
||||
events.sort()
|
||||
if any(a[1] > b[0] for a, b in zip(events, events[1:])):
|
||||
raise ValueError('acoustic bass and upper must release together or remain separate')
|
||||
|
||||
@@ -50,7 +50,15 @@ def audit(source, documents):
|
||||
exceptions.append({'measure': m['number'], 'reason': 'source-defined solo pickup allocation'})
|
||||
continue
|
||||
position = section.source_measure_index + j
|
||||
key = (section.source_measure_index + j, repr(harmonic_key(m, carried[offset + j])))
|
||||
# A complete figure depends on both measures' effective harmony and
|
||||
# source-defined participation. Changed final turnarounds/pickups are
|
||||
# explicit contexts, not incidental changes between iterations.
|
||||
first = generator.score.body_start_measure_index(authored)
|
||||
block_start = first + 2 * ((position - first) // 2)
|
||||
neighbors = range(max(section.source_measure_index, block_start), min(section.source_measure_index + section.measure_count, block_start + 2))
|
||||
context = tuple(repr(harmonic_key(measures[offset + k - section.source_measure_index], carried[offset + k - section.source_measure_index])) for k in neighbors)
|
||||
boundary = following.role if following and block_start + 2 >= section.source_measure_index + section.measure_count and generator.score.body_start_measure_index(authored) and following.role in ('slash', 'melody') else ''
|
||||
key = (position, context, boundary)
|
||||
signature = tuple((v['id'], generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
|
||||
tuple((generator.model.pitch_number(p), generator.model.fraction(p['duration']), p['tablature']['string'], p['tablature']['fret'], bool(p.get('tie_from')) if position not in entry_positions or generator.model.fraction(n['onset']) else False) for p in n['pitches']))
|
||||
for v in m['voices'] for n in v['notes'] if n['pitches'])
|
||||
@@ -60,7 +68,7 @@ def audit(source, documents):
|
||||
reused += 1
|
||||
else:
|
||||
if any(k[0] == key[0] for k in seen):
|
||||
exceptions.append({'measure': m['number'], 'reason': 'changed effective harmony or duration'})
|
||||
exceptions.append({'measure': m['number'], 'reason': 'changed two-bar harmony, duration, or protected participation'})
|
||||
seen[key] = signature
|
||||
offset += section.measure_count
|
||||
result[part] = {'reused_bars': reused, 'distinct_form_bars': len(seen), 'exceptions': exceptions}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""The recorded three-bar hold, expressed once as source-derived score time."""
|
||||
import copy
|
||||
import fractions
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic_voicing
|
||||
import generator.comping
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.parts
|
||||
import generator.score
|
||||
|
||||
BARS = 3
|
||||
|
||||
def template(source):
|
||||
result = copy.deepcopy(source[-1])
|
||||
# An ending is three full bars in the final active meter, never a pickup.
|
||||
for change in result.get('attributes', []):
|
||||
for key in ('meter', 'key', 'clef', 'transpose', 'staff_details'):
|
||||
if key in change:
|
||||
result[key] = copy.deepcopy(change[key])
|
||||
result.update(duration=generator.model.rational(generator.model.meter_length(result['meter'])), implicit=False,
|
||||
attributes=[], harmonies=[], voices=[], tempos=[], section_label='')
|
||||
result['time_signature'] = generator.model.meter_label(result['meter'])
|
||||
return result
|
||||
|
||||
def duration(source):
|
||||
return BARS * generator.model.fraction(template(source)['duration']) if source else fractions.Fraction()
|
||||
|
||||
def chord(source):
|
||||
effective = [{**m, 'harmonies': [h for h in m['harmonies'] if not h.get('chord_optional')]} for m in source]
|
||||
prior = generator.accompaniment.carried_chords(effective)[-1]
|
||||
return generator.accompaniment.active_chord(effective[-1], generator.model.fraction(effective[-1]['duration']), prior)
|
||||
|
||||
def append(source, output, part, *, sounding=False, center=55):
|
||||
if not source or not output:
|
||||
return
|
||||
model = template(source)
|
||||
harmony = chord(source)
|
||||
grip = ()
|
||||
if sounding:
|
||||
if harmony is None:
|
||||
raise ValueError('Ending: final performed harmony is missing or N.C.; cannot invent a closing chord')
|
||||
options = generator.comping.voicings(generator.comping.semantic_key(harmony), part)
|
||||
recent = {}
|
||||
for m in reversed(output):
|
||||
for n in sorted(generator.score.measure_notes(m), key=lambda n: generator.model.fraction(n['onset']), reverse=True):
|
||||
for p in n['pitches']:
|
||||
if 'tablature' in p:
|
||||
tab = p['tablature']
|
||||
recent.setdefault(tab['string'], generator.fretboard.TabPosition(tab['string'], tab['fret'], int(generator.model.pitch_number(p)) - 12))
|
||||
if len(recent) >= 4:
|
||||
break
|
||||
previous = tuple(recent[s] for s in sorted(recent, reverse=True))
|
||||
if part == 'acoustic':
|
||||
bass = next((recent[s] for s in (5, 6) if s in recent), None)
|
||||
previous = generator.acoustic_voicing.Voicing(bass, *(recent[s] for s in (4, 3, 2))) if bass and all(s in recent for s in (4, 3, 2)) else ()
|
||||
grip = min(options, key=lambda v: (generator.comping.local_cost(part, generator.comping.semantic_key(harmony), v, center).moved(generator.comping.movement(part, previous, v)), v))
|
||||
length = generator.model.fraction(model['duration'])
|
||||
previous = None
|
||||
for index in range(BARS):
|
||||
measure = generator.parts.build_measure(model, f'measure_{len(output) + 1}', 'rest', part, None, 'Ending' if index == 0 else '')
|
||||
measure['ending_sustain'] = True
|
||||
if index == 0 and harmony:
|
||||
measure['harmonies'] = [{**copy.deepcopy(harmony), 'onset': generator.model.rational(0)}]
|
||||
measure['harmonies'][0].pop('chord_optional', None)
|
||||
if grip:
|
||||
notes, cursor = [], fractions.Fraction()
|
||||
for duration, written in generator.accompaniment.written_durations(length):
|
||||
n = generator.model.make_event(f'ending_{part}_{index}_{len(notes)}', cursor, duration, 'note', generator.fretboard.chord_pitches(harmony, grip), written)
|
||||
n['voice'] = 'voice_arranged'
|
||||
if previous:
|
||||
for a, b in zip(previous['pitches'], n['pitches']):
|
||||
a['tie_to'], b['tie_from'] = b['id'], a['id']
|
||||
notes.append(n)
|
||||
previous = n
|
||||
cursor += duration
|
||||
measure['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
|
||||
output.append(measure)
|
||||
generator.score.reidentify(output[-BARS:], [])
|
||||
@@ -8,6 +8,7 @@ import generator.accompaniment
|
||||
import generator.arrange.accompaniment
|
||||
import generator.acoustic
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.ending
|
||||
import generator.arrange.form
|
||||
import generator.arrange.pickup
|
||||
import generator.electric
|
||||
@@ -61,10 +62,20 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
|
||||
section_measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None)
|
||||
previous = generator.accompaniment.carried_chords(section_measures)
|
||||
segment = []
|
||||
stop = None
|
||||
next_section = sections[index + 1] if index + 1 < len(sections) else None
|
||||
if next_section and generator.score.body_start_measure_index(source):
|
||||
last = section.source_measure_index + section.measure_count - 1
|
||||
if next_section.role == 'slash':
|
||||
stop = (last, generator.model.fraction(section_measures[last]['duration']) - generator.model.fraction(source[0]['duration']))
|
||||
elif next_section.role == 'melody' and generator.arrange.pickup.enters(next_section, source):
|
||||
attacks = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(source[0]) if n['pitches']]
|
||||
if attacks:
|
||||
stop = (last, generator.model.fraction(section_measures[last]['duration']) - generator.model.fraction(source[0]['duration']) + min(attacks))
|
||||
for si in range(section.source_measure_index, section.source_measure_index + section.measure_count):
|
||||
m = section_measures[si]
|
||||
if role == 'accompaniment' and accompaniment is not None:
|
||||
result = accompaniment(m, previous[si], si)
|
||||
result = accompaniment(m, previous[si], si, sources=section_measures, bounds=(section.source_measure_index, section.source_measure_index + section.measure_count), stop=stop)
|
||||
result['id'] = f'measure_{len(output) + len(segment) + 1}'
|
||||
result['section_label'] = section.label if not segment else ''
|
||||
else:
|
||||
@@ -97,6 +108,10 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
|
||||
octave_shifts.append({**shift, 'id': f'pickup_{section.section_index}_{shift["id"]}', 'start': generator.model.rational(offset + low), 'end': generator.model.rational(offset + high)})
|
||||
output.extend(segment)
|
||||
absolute += segment_length
|
||||
if sections and sections[-1].section_index == FINAL_SECTION_INDEX:
|
||||
generator.arrange.ending.append(source, output, part_type,
|
||||
sounding=part_type != 'vocal' and (owned is None or 'accompaniment' in owned),
|
||||
center=generator.vocabulary.role(part_type + '_accompaniment').get('center', 55))
|
||||
tuplets = generator.arrange.pickup.retain_links(output, tuplets)
|
||||
result = generator.score.make_arranged_document(document, staff, staff_id='staff_arranged_' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts)
|
||||
result['metadata'].setdefault('tempo', 124)
|
||||
@@ -104,6 +119,7 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
|
||||
if accompaniment is None and (owned is None or 'accompaniment' in owned):
|
||||
generator.acoustic.prepare(result)
|
||||
generator.acoustic_voicing.place(result)
|
||||
generator.acoustic.validate_comping(result)
|
||||
generator.acoustic.combine(result)
|
||||
elif part_type == 'electric' and (owned is None or 'melody' in owned):
|
||||
generator.melody.harmonize(result)
|
||||
|
||||
+156
-110
@@ -135,91 +135,157 @@ class HarmonyChoice:
|
||||
def key(self):
|
||||
return (self.index, self.start, self.end, semantic_key(self.chord), self.events)
|
||||
|
||||
def split_measures(flat, inputs, part):
|
||||
"""Project a selected sounding figure into notation; barlines create ties only."""
|
||||
output, fragments, original = [], {}, {}
|
||||
cursor = fractions.Fraction()
|
||||
for m, prior, index in inputs:
|
||||
length = generator.model.fraction(m['duration'])
|
||||
result = generator.parts.build_measure(m, f'comp_{part}_{index}', 'rest', part, prior)
|
||||
voices = []
|
||||
for voice in flat['voices']:
|
||||
notes = []
|
||||
for n in voice['notes']:
|
||||
start = generator.model.fraction(n['onset'])
|
||||
end = start + generator.model.fraction(n['duration'])
|
||||
a, b = max(cursor, start), min(cursor + length, end)
|
||||
if a >= b:
|
||||
continue
|
||||
for duration, written in generator.accompaniment.written_durations(b - a):
|
||||
pitches = [{k: v for k, v in p.items() if k not in ('tie_from', 'tie_to')} for p in n['pitches']]
|
||||
event = generator.model.make_event(f'comp_{part}_{index}_{voice["id"]}_{len(notes)}', a - cursor, duration, 'note' if pitches else 'rest', pitches, written)
|
||||
event['voice'] = voice['id']
|
||||
for old, new in zip(n['pitches'], event['pitches']):
|
||||
fragments.setdefault(old['id'], []).append(new)
|
||||
original[old['id']] = old
|
||||
notes.append(event)
|
||||
a += duration
|
||||
voices.append({'id': voice['id'], 'notes': notes})
|
||||
result['voices'] = voices
|
||||
for chord in result['harmonies']:
|
||||
chord.pop('chord_optional', None)
|
||||
output.append(result)
|
||||
cursor += length
|
||||
for pieces in fragments.values():
|
||||
for a, b in zip(pieces, pieces[1:]):
|
||||
a['tie_to'], b['tie_from'] = b['id'], a['id']
|
||||
for identifier, old in original.items():
|
||||
if old.get('tie_to') in fragments:
|
||||
a, b = fragments[identifier][-1], fragments[old['tie_to']][0]
|
||||
a['tie_to'], b['tie_from'] = b['id'], a['id']
|
||||
return tuple(output)
|
||||
|
||||
class Canonical:
|
||||
def __init__(self, document, part, seed):
|
||||
self.part, self.seed = part, seed
|
||||
self.policy = generator.vocabulary.role(part + '_accompaniment')
|
||||
self.cache, self._reports, self._rhythm_reports = {}, {}, {}
|
||||
measures = generator.score.source_staff(document)['measures']
|
||||
self.measures = generator.score.source_staff(document)['measures']
|
||||
self.melody = generator.comping_rhythm.melody_contexts(document)
|
||||
first_index = generator.score.body_start_measure_index(measures)
|
||||
sections = generator.arrange.form.full_arrangement_sections(measures, part)
|
||||
self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(measures)
|
||||
and b.source_measure_index == first_index for a, b in zip(sections, sections[1:]))
|
||||
carried = generator.accompaniment.carried_chords(measures)
|
||||
inputs = []
|
||||
for index in range(first_index, len(measures)):
|
||||
source = copy.deepcopy(measures[index])
|
||||
for chord in source['harmonies']:
|
||||
chord.pop('chord_optional', None)
|
||||
inputs.append((source, carried[index], index))
|
||||
layers = tuple(generator.comping_rhythm.candidates(m, prior, part, self.melody[i], seed + '/' + str(i)) for m, prior, i in inputs)
|
||||
rhythms = generator.comping_rhythm.select(layers, seed)
|
||||
self.rhythms = {i: r for (_, _, i), r in zip(inputs, rhythms)}
|
||||
prepared = [(m, prior, i, self.prepare(m, prior, i, r)) for (m, prior, i), r in zip(inputs, rhythms)]
|
||||
active = tuple(s for _, _, _, spans in prepared for s in spans if s.events)
|
||||
self.first = generator.score.body_start_measure_index(self.measures)
|
||||
sections = generator.arrange.form.full_arrangement_sections(self.measures, part)
|
||||
self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(self.measures)
|
||||
and b.source_measure_index == self.first for a, b in zip(sections, sections[1:]))
|
||||
carried = generator.accompaniment.carried_chords(self.measures)
|
||||
self.groups = []
|
||||
index = self.first
|
||||
while index < len(self.measures):
|
||||
size = 1
|
||||
if index + 1 < len(self.measures):
|
||||
lengths = [generator.model.fraction(m['duration']) for m in self.measures[index:index + 2]]
|
||||
if any(tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths) for p in generator.vocabulary.accompaniment(part)['patterns']):
|
||||
size = 2
|
||||
self.groups.append(tuple((self.measures[i], carried[i], i) for i in range(index, index + size)))
|
||||
index += size
|
||||
layers = [self.candidates(group) for group in self.groups]
|
||||
selected = generator.comping_rhythm.select(layers, seed)
|
||||
self.block_rhythms = {group[0][2]: rhythm for group, rhythm in zip(self.groups, selected)}
|
||||
prepared = [self.prepare(group, rhythm) for group, rhythm in zip(self.groups, selected)]
|
||||
active = tuple(span for spans in prepared for span in spans if span.events)
|
||||
path, self.path_cost = self.select(active, close=self.closed)
|
||||
self.base = dict(zip((s.key for s in active), path))
|
||||
self.order = tuple((s.index, s.start, v) for s, v in zip(active, path))
|
||||
self.rhythms = {}
|
||||
previous = path[-1] if self.closed and path else ()
|
||||
for source, prior, index, spans in prepared:
|
||||
selected = tuple(self.base[s.key] for s in spans if s.events)
|
||||
self.cache[self.key(source, prior, index)] = self.render(source, prior, index, spans, selected, previous, False)
|
||||
if selected:
|
||||
previous = selected[-1]
|
||||
if generator.vocabulary.accompaniment(part)['connect_holds']:
|
||||
self.connect_holds(inputs)
|
||||
for group, rhythm, spans in zip(self.groups, selected, prepared):
|
||||
grips = tuple(self.base[s.key] for s in spans if s.events)
|
||||
self.cache[self.key(group)] = self.render(group, rhythm, spans, grips, previous, False)
|
||||
cursor = fractions.Fraction()
|
||||
for m, _, i in group:
|
||||
length = generator.model.fraction(m['duration'])
|
||||
self.rhythms[i] = dataclasses.replace(rhythm, events=tuple(dataclasses.replace(e, start=max(e.start, cursor) - cursor, end=min(e.end, cursor + length) - cursor, harmonic_time=e.harmonic_time - cursor if e.harmonic_time is not None else None) for e in rhythm.events if e.start < cursor + length and e.end > cursor))
|
||||
cursor += length
|
||||
if grips:
|
||||
previous = grips[-1]
|
||||
@property
|
||||
def report(self):
|
||||
return [row for key in sorted(self._reports, key=lambda k: (k[0], repr(k))) for row in self._reports[key]]
|
||||
return [row for key in sorted(self._reports, key=repr) for row in self._reports[key]]
|
||||
@property
|
||||
def rhythm_report(self):
|
||||
return [self._rhythm_reports[key] for key in sorted(self._rhythm_reports, key=lambda k: (k[0], repr(k)))]
|
||||
return [row for key in sorted(self._rhythm_reports, key=repr) for row in self._rhythm_reports[key]]
|
||||
@staticmethod
|
||||
def key(source, prior, index):
|
||||
return (index, tuple((a, b, semantic_key(c) if c else '') for a, b, c in generator.comping_rhythm.spans(source, prior)),
|
||||
source['duration'].__repr__())
|
||||
def prepare(self, source, prior, index, rhythm):
|
||||
def key(inputs, limit=None):
|
||||
return (tuple((i, str(generator.model.fraction(m['duration'])), tuple((str(a), str(b), semantic_key(c) if c else '') for a, b, c in generator.comping_rhythm.spans(m, p))) for m, p, i in inputs), str(limit))
|
||||
def candidates(self, inputs, limit=None):
|
||||
flat = generator.comping_rhythm.flatten(inputs)
|
||||
melody, cursor, lengths = [], fractions.Fraction(), []
|
||||
for m, _, i in inputs:
|
||||
melody.extend((a + cursor, b + cursor) for a, b in self.melody[i])
|
||||
lengths.append(generator.model.fraction(m['duration']))
|
||||
cursor += lengths[-1]
|
||||
return generator.comping_rhythm.candidates(flat, None, self.part, tuple(melody), self.seed + '/' + str(inputs[0][2]), lengths=tuple(lengths), limit=limit)
|
||||
def prepare(self, inputs, rhythm):
|
||||
flat = generator.comping_rhythm.flatten(inputs)
|
||||
spans = []
|
||||
for start, end, chord in generator.comping_rhythm.spans(source, prior):
|
||||
offsets, cursor = [], fractions.Fraction()
|
||||
for m, _, i in inputs:
|
||||
offsets.append((cursor, i))
|
||||
cursor += generator.model.fraction(m['duration'])
|
||||
for start, end, chord in generator.comping_rhythm.spans(flat, None):
|
||||
if not chord:
|
||||
continue
|
||||
events = tuple(e for e in rhythm.events if start <= e.start < end)
|
||||
offset, index = next((a, i) for a, i in reversed(offsets) if a <= start)
|
||||
events = tuple(e for e in rhythm.events if start <= (e.harmonic_time if e.harmonic_time is not None else e.start) < end)
|
||||
try:
|
||||
options = voicings(semantic_key(chord), self.part)
|
||||
except ValueError as error:
|
||||
raise ValueError(f'source measure {source["number"]}, beat {start + 1}: {error}') from error
|
||||
raise ValueError(f'source measure {inputs[index - inputs[0][2]][0]["number"]}, beat {start - offset + 1}: {error}') from error
|
||||
spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options))
|
||||
self._rhythm_reports[self.key(source, prior, index)] = {
|
||||
'source_index': index, 'families': dict(rhythm.families), 'source_melody': [(str(a), str(b)) for a, b in self.melody[index]],
|
||||
'contributions': dict(rhythm.contributions), 'selection_cost': rhythm.cost, 'exceptions': rhythm.exceptions,
|
||||
'layers': {lane: {'attacks': sum(e.lane == lane for e in rhythm.events),
|
||||
'events': [{'onset': str(e.start), 'duration': str(e.end - e.start)} for e in rhythm.events if e.lane == lane], 'tied_continuations': 0} for lane, _ in rhythm.families},
|
||||
'overlap_beats': str(sum((max(0, min(a.end, b.end) - max(a.start, b.start)) for a in rhythm.events for b in rhythm.events if a.lane == 'bass' and b.lane == 'upper'), fractions.Fraction()))}
|
||||
return tuple(spans)
|
||||
def select(self, spans, *, left=(), right=(), close=False, fixed=None):
|
||||
layers = tuple((fixed[s.key],) if fixed and s.key in fixed else s.options for s in spans)
|
||||
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55)) for v in layer}
|
||||
for s, layer in zip(spans, layers))
|
||||
def select(self, spans, *, left=(), right=(), close=False):
|
||||
layers = tuple(s.options for s in spans)
|
||||
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55)) for v in layer} for s, layer in zip(spans, layers))
|
||||
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, close=close)
|
||||
def __call__(self, source, prior, index):
|
||||
key = self.key(source, prior, index)
|
||||
def __call__(self, source, prior, index, *, sources=None, bounds=None, stop=None):
|
||||
group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),))
|
||||
sources = sources or self.measures
|
||||
carried = generator.accompaniment.carried_chords(sources)
|
||||
low, high = bounds or (self.first, len(sources))
|
||||
group = tuple((source if i == index else sources[i], prior if i == index else carried[i], i) for _, _, i in group if low <= i < high)
|
||||
limit = None
|
||||
if stop and any(i == stop[0] for _, _, i in group):
|
||||
limit = sum((generator.model.fraction(m['duration']) for m, _, i in group if i < stop[0]), fractions.Fraction()) + stop[1]
|
||||
key = self.key(group, limit)
|
||||
if key not in self.cache:
|
||||
# Effective pickup/turnaround harmony may differ from the main form.
|
||||
# Solve that measure between fixed canonical neighbors. Request order
|
||||
# and previously generated arrangement sections cannot change it.
|
||||
choices = generator.comping_rhythm.candidates(source, prior, self.part, self.melody[index], self.seed + '/' + str(index))
|
||||
rhythm = generator.comping_rhythm.select((choices,), self.seed, self.rhythms.get(index - 1), self.rhythms.get(index + 1))[0]
|
||||
spans = self.prepare(source, prior, index, rhythm)
|
||||
before = [v for i, _, v in self.order if i < index]
|
||||
after = [v for i, _, v in self.order if i > index]
|
||||
choices = self.candidates(group, limit)
|
||||
start, end = group[0][2], group[-1][2]
|
||||
# Preserve the canonical whole figure where still legal under changed
|
||||
# effective harmony. Recompute only genuinely different source context.
|
||||
original = self.block_rhythms.get(start)
|
||||
matches = [r for r in choices if original and r.pattern_id == original.pattern_id]
|
||||
rhythm = matches[0] if matches else generator.comping_rhythm.select((choices,), self.seed, self.block_rhythms.get(start - 2), self.block_rhythms.get(end + 1))[0]
|
||||
spans = self.prepare(group, rhythm)
|
||||
before = [v for i, _, v in self.order if i < start]
|
||||
after = [v for i, _, v in self.order if i > end]
|
||||
left = before[-1] if before else self.order[-1][2] if self.closed and self.order else ()
|
||||
right = after[0] if after else self.order[0][2] if self.closed and self.order else ()
|
||||
selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right, fixed=self.base)
|
||||
self.cache[key] = self.render(source, prior, index, spans, selected, left, True)
|
||||
return copy.deepcopy(self.cache[key])
|
||||
def render(self, source, prior, index, spans, selected, previous, variant):
|
||||
length = generator.model.fraction(source['duration'])
|
||||
selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right)
|
||||
self.cache[key] = self.render(group, rhythm, spans, selected, left, True, limit)
|
||||
return copy.deepcopy(self.cache[key][index - group[0][2]])
|
||||
def render(self, inputs, rhythm, spans, selected, previous, variant, limit=None):
|
||||
flat = generator.comping_rhythm.flatten(inputs)
|
||||
length = generator.model.fraction(flat['duration'])
|
||||
index = inputs[0][2]
|
||||
attacks, rows = [], []
|
||||
choices = iter(selected)
|
||||
for span in spans:
|
||||
@@ -227,8 +293,8 @@ class Canonical:
|
||||
bass = grip[0] if grip and self.part == 'acoustic' else None
|
||||
degrees = generator.harmony.degrees(span.chord)
|
||||
root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter']))
|
||||
rows.append({'source_index': index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord),
|
||||
'figure': span.figure, 'tones': len(grip), 'bass': bool(bass), 'variant': variant,
|
||||
rows.append({'source_index': span.index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord),
|
||||
'figure': span.figure, 'pattern_id': rhythm.pattern_id, 'tones': len(grip), 'bass': bool(bass), 'variant': variant,
|
||||
'family': (generator.chord_families.acoustic(span.chord, grip) if self.part == 'acoustic' else generator.chord_families.electric(grip)) if grip else None,
|
||||
'family_coverage': generator.chord_families.coverage(span.chord, span.options, self.part),
|
||||
'fret_span': generator.fretboard.fret_span(grip),
|
||||
@@ -239,66 +305,46 @@ class Canonical:
|
||||
'bass_string': bass.string_number if bass else None,
|
||||
'movement': movement(self.part, previous, grip), 'voicing_candidates': len(span.options)})
|
||||
for event in span.events:
|
||||
positions = (grip[0],) if event.lane == 'bass' else grip[1:] if event.lane == 'upper' else grip
|
||||
attacks.append((event.lane, event.start, event.end, positions, span.chord))
|
||||
lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane]
|
||||
for lane in lanes:
|
||||
positions = (grip[0],) if lane == 'bass' else grip[1:] if lane == 'upper' else grip
|
||||
attacks.append((lane, event.start, event.end, positions, span.chord))
|
||||
if grip:
|
||||
previous = grip
|
||||
self._reports[self.key(source, prior, index)] = rows
|
||||
key = self.key(inputs, limit)
|
||||
self._reports[key] = rows
|
||||
voices = []
|
||||
notes, cursor, lane = [], fractions.Fraction(), ''
|
||||
def emit(a, b, positions=(), chord=None):
|
||||
nonlocal cursor
|
||||
previous = None
|
||||
for duration, written in generator.accompaniment.written_durations(b - a):
|
||||
pitches = generator.fretboard.chord_pitches(chord, positions) if positions else []
|
||||
n = generator.model.make_event(f'comp_{self.part}_{index}_{lane}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written)
|
||||
n['voice'] = lane
|
||||
if self.part == 'acoustic':
|
||||
n['stem'] = 'down' if lane == 'bass' else 'up'
|
||||
if previous and pitches:
|
||||
for old, new in zip(previous['pitches'], n['pitches']):
|
||||
old['tie_to'], new['tie_from'] = new['id'], old['id']
|
||||
notes.append(n)
|
||||
previous = n
|
||||
a += duration
|
||||
cursor = b
|
||||
for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']:
|
||||
notes, cursor = [], fractions.Fraction()
|
||||
def emit(a, b, positions=(), chord=None):
|
||||
previous = None
|
||||
for duration, written in generator.accompaniment.written_durations(b - a):
|
||||
pitches = generator.fretboard.chord_pitches(chord, positions) if positions else []
|
||||
n = generator.model.make_event(f'comp_{self.part}_{index}_{lane}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written)
|
||||
n['voice'] = lane
|
||||
if previous and pitches:
|
||||
for old, new in zip(previous['pitches'], n['pitches']):
|
||||
old['tie_to'], new['tie_from'] = new['id'], old['id']
|
||||
notes.append(n)
|
||||
previous = n
|
||||
a += duration
|
||||
for _, start, end, positions, chord in sorted((e for e in attacks if e[0] == lane), key=lambda e: e[1]):
|
||||
if cursor < start:
|
||||
emit(cursor, start)
|
||||
emit(start, end, positions, chord)
|
||||
cursor = end
|
||||
if cursor < length:
|
||||
emit(cursor, length)
|
||||
voices.append({'id': lane, 'notes': notes})
|
||||
result = generator.parts.build_measure(source, 'canonical', 'rest', self.part, prior)
|
||||
result['voices'] = voices
|
||||
result = split_measures({'voices': voices}, inputs, self.part)
|
||||
reports, cursor = [], fractions.Fraction()
|
||||
for m, _, i in inputs:
|
||||
duration = generator.model.fraction(m['duration'])
|
||||
reports.append({'source_index': i, 'pattern_id': rhythm.pattern_id, 'pattern_start': index,
|
||||
'families': dict(rhythm.families), 'contributions': dict(rhythm.contributions), 'selection_cost': rhythm.cost,
|
||||
'exceptions': rhythm.exceptions, 'source_melody': [(str(a), str(b)) for a, b in self.melody[i]],
|
||||
'events': [{'layer': e.lane, 'onset': str(max(e.start, cursor) - cursor), 'duration': str(min(e.end, cursor + duration) - max(e.start, cursor)),
|
||||
'continuation': e.start < cursor, 'harmonic_target': str(e.harmonic_time - cursor) if e.harmonic_time is not None else None} for e in rhythm.events if e.start < cursor + duration and e.end > cursor]})
|
||||
cursor += duration
|
||||
self._rhythm_reports[key] = reports
|
||||
return result
|
||||
def connect_holds(self, inputs):
|
||||
"""Only explicit sustained neighbors can share an attack across a barline.
|
||||
|
||||
These are canonical internal joins, never links across role boundaries.
|
||||
The direct reattack remains when a grip or required attack count differs.
|
||||
"""
|
||||
for left, right in zip(inputs, inputs[1:]):
|
||||
before = self.cache[self.key(*left)]
|
||||
after = self.cache[self.key(*right)]
|
||||
previous_chord = generator.accompaniment.active_chord(left[0], generator.model.fraction(left[0]['duration']), left[1])
|
||||
next_chord = generator.accompaniment.active_chord(right[0], fractions.Fraction(), right[1])
|
||||
if not generator.accompaniment.same_chord(previous_chord, next_chord):
|
||||
continue
|
||||
for a, b in zip(before['voices'], after['voices']):
|
||||
if a['id'] == 'bass':
|
||||
continue
|
||||
old, new = a['notes'][-1], b['notes'][0]
|
||||
minimum = 1 if a['id'] == 'upper' else 0
|
||||
attacks = sum(bool(n['pitches']) and not any('tie_from' in p for p in n['pitches']) for n in b['notes'])
|
||||
if not old['pitches'] or not new['pitches'] or attacks <= minimum or min(generator.model.fraction(n['duration']) for n in (old, new)) < 1:
|
||||
continue
|
||||
if [p['tablature'] for p in old['pitches']] != [p['tablature'] for p in new['pitches']]:
|
||||
continue
|
||||
for p, q in zip(old['pitches'], new['pitches']):
|
||||
p['tie_to'], q['tie_from'] = q['id'], p['id']
|
||||
row = self._rhythm_reports[self.key(*right)]['layers'][a['id']]
|
||||
row['attacks'] -= 1
|
||||
row['tied_continuations'] += 1
|
||||
|
||||
+120
-134
@@ -1,7 +1,6 @@
|
||||
"""Bounded, source-only measure rhythms; acoustic layers are selected jointly."""
|
||||
"""Complete, source-only comping figures. Time is in rational quarter notes."""
|
||||
import dataclasses
|
||||
import fractions
|
||||
import itertools
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.model
|
||||
@@ -14,6 +13,7 @@ class Attack:
|
||||
lane: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
harmonic_time: fractions.Fraction | None = None
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Rhythm:
|
||||
@@ -21,12 +21,13 @@ class Rhythm:
|
||||
families: tuple
|
||||
contributions: tuple
|
||||
exceptions: tuple = ()
|
||||
pattern_id: str = ''
|
||||
@property
|
||||
def cost(self):
|
||||
return sum(value for _, value in self.contributions)
|
||||
@property
|
||||
def signature(self):
|
||||
return tuple((e.lane, str(e.start), str(e.end - e.start)) for e in self.events)
|
||||
return tuple((e.lane, str(e.start), str(e.end - e.start), str(e.harmonic_time)) for e in self.events)
|
||||
|
||||
def melody_contexts(document):
|
||||
melody = generator.solo.analysis.attacks(document)
|
||||
@@ -49,151 +50,136 @@ def spans(measure, prior):
|
||||
result.append((a, b, chord))
|
||||
return tuple(result)
|
||||
|
||||
def lane_score(events, lane, melody, harmony, length):
|
||||
"""Named source-context preferences, independent of the deterministic seed."""
|
||||
attacks = [a for a, b in melody if a >= 0]
|
||||
occupied = sum((max(0, min(length, b) - max(0, a)) for a, b in melody), fractions.Fraction())
|
||||
space = max(0, length - occupied)
|
||||
busy = len(attacks) >= length
|
||||
if lane == 'bass':
|
||||
target = min(2, sum(c is not None and b - a >= 1 for a, b, c in harmony))
|
||||
elif lane == 'upper':
|
||||
target = 2.5 if space >= length / 2 else 1.5 if busy else 2
|
||||
else:
|
||||
target = .5 if busy else 1.5 if space >= length / 2 else 1
|
||||
# A half-attack tolerance makes neighboring densities genuine alternatives.
|
||||
# The objective does not always fill up to a layer's permitted maximum.
|
||||
activity = .35 * max(0, abs(len(events) - target) - .5)
|
||||
support, articulation = 0., 0.
|
||||
for e in events:
|
||||
# Supporting an entrance is different from doubling every moving note.
|
||||
entrance = any(a == e.start and (i == 0 and a >= 0 or i > 0 and a - melody[i - 1][1] >= fractions.Fraction(1, 2)) for i, (a, _) in enumerate(melody))
|
||||
moving = any(a <= e.start < b and b - a <= 1 for a, b in melody)
|
||||
gap = not any(a <= e.start < b for a, b in melody)
|
||||
if lane != 'bass':
|
||||
support += -.2 if entrance else .25 if moving and e.start in attacks else -.15 if gap else 0
|
||||
articulation += .12 if busy and e.end - e.start > 1 else .08 if not busy and e.end - e.start < 1 else 0
|
||||
elif e.start and e.start.denominator != 1:
|
||||
support += .15
|
||||
divisor = max(1, len(events))
|
||||
return (('activity', activity), ('melody_support', support / divisor), ('duration_contrast', articulation / divisor))
|
||||
def flatten(inputs):
|
||||
"""Harmony/timing adapter only; never concatenate separately chosen rhythms."""
|
||||
first = inputs[0][0]
|
||||
result = {**first, 'harmonies': []}
|
||||
cursor = fractions.Fraction()
|
||||
for m, prior, _ in inputs:
|
||||
chord = generator.accompaniment.active_chord(m, fractions.Fraction(), prior)
|
||||
initial = chord or {'kind': 'none'}
|
||||
result['harmonies'].append({**initial, 'onset': generator.model.rational(cursor)})
|
||||
result['harmonies'].extend({**h, 'onset': generator.model.rational(cursor + generator.model.fraction(h['onset']))} for h in m['harmonies'] if generator.model.fraction(h['onset']))
|
||||
cursor += generator.model.fraction(m['duration'])
|
||||
result['duration'] = generator.model.rational(cursor)
|
||||
return result
|
||||
|
||||
def validate_events(events, part):
|
||||
ordered = sorted(events, key=lambda e: e.start)
|
||||
for e in ordered:
|
||||
duration = e.end - e.start
|
||||
if duration <= 0:
|
||||
raise ValueError('comping event must have positive sounding duration')
|
||||
if part == 'acoustic':
|
||||
if e.lane == 'combined':
|
||||
if duration <= 1:
|
||||
raise ValueError('combined acoustic sustain must exceed one beat')
|
||||
elif e.lane not in ('bass', 'upper') or duration not in (fractions.Fraction(1, 2), 1):
|
||||
raise ValueError('ordinary acoustic attacks require separate quarter/eighth notes')
|
||||
elif e.lane != 'electric':
|
||||
raise ValueError('electric comping requires chord events')
|
||||
if any(a.end > b.start for a, b in zip(ordered, ordered[1:])):
|
||||
raise ValueError('separate comping events cannot overlap; use one combined sustain')
|
||||
|
||||
def fit(pattern, measure, prior, part, *, anticipate=False, limit=None):
|
||||
"""Bind a complete figure to written harmony, with bounded early arrivals."""
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
limit = length if limit is None else min(length, limit)
|
||||
harmony = spans(measure, prior)
|
||||
events, exceptions = [], []
|
||||
for row in pattern['events']:
|
||||
start = fractions.Fraction(row['onset'])
|
||||
end = min(start + fractions.Fraction(row['duration']), limit)
|
||||
if start >= limit:
|
||||
continue
|
||||
active = next(((a, b, c) for a, b, c in harmony if a <= start < b), None)
|
||||
if active is None or active[2] is None:
|
||||
exceptions.append(('space', 'no written harmony at ' + str(start)))
|
||||
continue
|
||||
_, boundary, chord = active
|
||||
target = None
|
||||
if anticipate and row.get('anticipate') and row['layer'] in ('upper', 'electric'):
|
||||
future = next(((a, b, c) for a, b, c in harmony if a == start + fractions.Fraction(1, 2) and c), None)
|
||||
if future and future[0] < end and not generator.accompaniment.same_chord(chord, future[2]):
|
||||
target, boundary = future[0], future[1]
|
||||
end = min(end, boundary)
|
||||
events.append(Attack(row['layer'], start, end, target))
|
||||
try:
|
||||
validate_events(events, part)
|
||||
except ValueError:
|
||||
return None
|
||||
return tuple(events), tuple(exceptions)
|
||||
|
||||
def evaluate(events, part, melody, harmony, lengths):
|
||||
activity, support, coverage, articulation = 0., 0., 0., 0.
|
||||
cursor = fractions.Fraction()
|
||||
for length in lengths:
|
||||
local = [e for e in events if cursor <= e.start < cursor + length]
|
||||
attacks = [a for a, b in melody if cursor <= a < cursor + length]
|
||||
busy = len(attacks) >= length
|
||||
sounding = any(e.start < cursor + length and e.end > cursor for e in events)
|
||||
target = (1 if busy else 1.5) if part == 'electric' else (2 if busy else 2.5)
|
||||
# Sustained occupancy and planned space are not missing attacks.
|
||||
activity += .22 * max(0, abs(len(local) - target) - .75) if not (sounding and not local) else 0
|
||||
for e in local:
|
||||
gap = not any(a <= e.start < b for a, b in melody)
|
||||
entrance = any(a == e.start and (i == 0 or a - melody[i - 1][1] >= fractions.Fraction(1, 2)) for i, (a, _) in enumerate(melody))
|
||||
support += -.2 if entrance else -.12 if gap else .18 if busy and e.start in attacks else 0
|
||||
articulation += .08 if busy and e.end - e.start > 2 else 0
|
||||
cursor += length
|
||||
for a, b, chord in harmony:
|
||||
if chord and b - a >= 1 and not any(e.start < b and e.end > a for e in events):
|
||||
coverage += .18 # A preference, never a chord-attack quota.
|
||||
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):
|
||||
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 anticipate in (False, True):
|
||||
fitted = fit(pattern, measure, prior, part, anticipate=anticipate, limit=limit)
|
||||
if fitted is None:
|
||||
continue
|
||||
events, exceptions = fitted
|
||||
if anticipate and not any(e.harmonic_time is not None for e in events):
|
||||
continue
|
||||
contributions = evaluate(events, part, melody, spans(measure, prior), lengths)
|
||||
result.append(Rhythm(events, (('pattern', pattern['family']),), contributions, exceptions, pattern['id'] + ('.anticipated' if anticipate else '')))
|
||||
if not result:
|
||||
raise ValueError(f'measure {measure["number"]}: no {part} pattern for lengths {lengths} and written harmonic/release constraints')
|
||||
return representatives(result, policy['pattern_candidates'], seed)
|
||||
|
||||
def representatives(candidates, limit, seed):
|
||||
"""Keep counts, placements and duration families before the final cutoff."""
|
||||
unique = {}
|
||||
for candidate in candidates:
|
||||
key = candidate.signature
|
||||
rank = (candidate.cost, generator.solo.policy.stable_rank(seed, key))
|
||||
if key not in unique or rank < unique[key][0]:
|
||||
unique[key] = rank, candidate
|
||||
ordered = [v for _, v in sorted(unique.values(), key=lambda item: item[0])]
|
||||
groups = {}
|
||||
shapes = set()
|
||||
for c in candidates:
|
||||
rank = (c.cost, generator.solo.policy.stable_rank(seed, (c.pattern_id, c.signature)))
|
||||
if c.signature not in unique or rank < unique[c.signature][0]:
|
||||
unique[c.signature] = rank, c
|
||||
ordered = [c for _, c in sorted(unique.values(), key=lambda item: item[0])]
|
||||
retained, families = [], set()
|
||||
for c in ordered:
|
||||
counts = tuple((lane, sum(e.lane == lane for e in c.events)) for lane, _ in c.families)
|
||||
shape = (counts, c.families, any(e.start.denominator != 1 for e in c.events))
|
||||
# Round-robin attack counts first. Otherwise many cosmetic variants of
|
||||
# the cheapest density can exclude every one- or three-attack idea.
|
||||
if shape not in shapes:
|
||||
groups.setdefault(counts, []).append(c)
|
||||
shapes.add(shape)
|
||||
result = []
|
||||
while groups and len(result) < limit:
|
||||
for key in list(groups):
|
||||
result.append(groups[key].pop(0))
|
||||
if not groups[key]:
|
||||
del groups[key]
|
||||
if len(result) == limit:
|
||||
break
|
||||
return tuple(result)
|
||||
|
||||
def lane_candidates(measure, prior, lane, melody, policy, seed):
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
harmony = spans(measure, prior)
|
||||
active = [(a, b, c) for a, b, c in harmony if c]
|
||||
if not active:
|
||||
return Rhythm((), ((lane, 'space'),), (('activity', 0.),)),
|
||||
low, high = policy['layers'][lane]['attacks']
|
||||
# Genuinely different harmony gets a gesture, not its own density budget.
|
||||
required = [(a, b) for a, b, c in active if b - a >= (2 if lane == 'bass' else 1)]
|
||||
if lane == 'bass' and active[0][0] == 0 and not any(a == 0 for a, _ in required):
|
||||
required.insert(0, active[0][:2])
|
||||
if lane == 'electric' and len(active) == 1 and generator.accompaniment.same_chord(active[0][2], prior):
|
||||
required = []
|
||||
# With very fast harmonic rhythm, establish the edges and longest spans.
|
||||
# Short intervening chords may be silent; no incompatible sustain is kept.
|
||||
if len(required) > high + 1:
|
||||
edges = [required[0], required[-1]]
|
||||
required = sorted(edges + sorted(required[1:-1], key=lambda ab: (-(ab[1] - ab[0]), ab[0]))[:high - 1])
|
||||
extra = int(len(required) > high)
|
||||
grid = fractions.Fraction(policy['grid'])
|
||||
positions = {i * grid for i in range(int(length / grid) + 1) if i * grid < length}
|
||||
positions.update(a for a, _, _ in active)
|
||||
positions = sorted(t for t in positions if any(a <= t < b for a, b, _ in active))
|
||||
result = []
|
||||
for count in range(low, high + extra + 1):
|
||||
for onsets in itertools.combinations(positions, count):
|
||||
if lane == 'bass' and active[0][0] == 0 and (not onsets or onsets[0] != 0):
|
||||
continue
|
||||
if any(not any(a <= t < b for t in onsets) for a, b in required):
|
||||
continue
|
||||
if any(b - a < fractions.Fraction(policy['attack_spacing']) for a, b in zip(onsets, onsets[1:])):
|
||||
continue
|
||||
for family in policy['layers'][lane]['families']:
|
||||
durations = policy['families'][family]
|
||||
events = []
|
||||
for i, a in enumerate(onsets):
|
||||
end = next(b for start, b, c in active if start <= a < b)
|
||||
following = onsets[i + 1] if i + 1 < len(onsets) else length
|
||||
duration = fractions.Fraction(durations[i % len(durations)])
|
||||
events.append(Attack(lane, a, min(a + duration, following, end)))
|
||||
if policy.get('allowed_durations') and any(e.end - e.start not in tuple(map(fractions.Fraction, policy['allowed_durations'])) for e in events):
|
||||
continue
|
||||
exceptions = ((lane, 'extra attack for harmonic changes'),) if count > high else ()
|
||||
contributions = (*lane_score(events, lane, melody, harmony, length), ('extra_attacks', .5 * bool(exceptions)))
|
||||
result.append(Rhythm(tuple(events), ((lane, family if count else 'space'),), contributions, exceptions))
|
||||
if not result:
|
||||
# A short partial bar can have fewer gestures, not invented subdivisions.
|
||||
if length < 1 and not policy.get('allowed_durations'):
|
||||
a, b, _ = active[0]
|
||||
return Rhythm((Attack(lane, a, b),), ((lane, 'partial'),), (('activity', 0.),)),
|
||||
raise ValueError(f'measure {measure["number"]}: no {lane} rhythm within the bounded attack/spacing policy')
|
||||
return representatives(result, policy['lane_candidates'], seed + '/' + lane)
|
||||
|
||||
def crowding(events):
|
||||
times = sorted({e.start for e in events})
|
||||
cost = .22 * sum(y - x < 1 for x, y in zip(times, times[1:]))
|
||||
# Three successive half-beat changes are a busy figure even though each
|
||||
# layer is sparse. Coincident attacks count as one hand action.
|
||||
return cost + .5 * sum(z - x <= 1 for x, y, z in zip(times, times[1:], times[2:]))
|
||||
|
||||
def candidates(measure, prior, part, melody, seed):
|
||||
policy = generator.vocabulary.accompaniment(part)
|
||||
if part == 'electric':
|
||||
return lane_candidates(measure, prior, 'electric', melody, policy, seed)
|
||||
bass = lane_candidates(measure, prior, 'bass', melody, policy, seed)
|
||||
upper = lane_candidates(measure, prior, 'upper', melody, policy, seed)
|
||||
result = []
|
||||
for a, b in itertools.product(bass, upper):
|
||||
costs = tuple((lane + '_' + k, v) for lane, c in (('bass', a), ('upper', b)) for k, v in c.contributions)
|
||||
result.append(Rhythm(tuple(sorted((*a.events, *b.events))), (*a.families, *b.families), (*costs, ('combined_crowding', crowding((*a.events, *b.events)))), (*a.exceptions, *b.exceptions)))
|
||||
return representatives(result, policy['measure_candidates'], seed)
|
||||
if c.families not in families:
|
||||
retained.append(c)
|
||||
families.add(c.families)
|
||||
retained.extend(c for c in ordered if c not in retained)
|
||||
return tuple(retained[:limit])
|
||||
|
||||
def transition(previous, current):
|
||||
if previous is None:
|
||||
return 0.
|
||||
# Recurrence is allowed; penalize repeating an entire bar more than sharing
|
||||
# a duration family. This stays separate from the source-context objective.
|
||||
same_counts = all(sum(e.lane == lane for e in previous.events) == sum(e.lane == lane for e in current.events) for lane, _ in current.families)
|
||||
return .45 * (previous.signature == current.signature) + .12 * (previous.families == current.families) + .08 * same_counts
|
||||
return .18 * (previous.signature == current.signature) + .04 * (previous.families == current.families)
|
||||
|
||||
def select(layers, seed, left=None, right=None):
|
||||
"""One state per concrete bar rhythm; compare complete equal-span paths."""
|
||||
"""Compare complete two-bar paths, not independently chosen bar fragments."""
|
||||
states = [(0., (), (), left)]
|
||||
for i, layer in enumerate(layers):
|
||||
following = []
|
||||
for c in layer:
|
||||
options = [(cost + c.cost + transition(previous, c), (*ties, generator.solo.policy.stable_rank(seed, (i, c.signature))), (*path, c), c) for cost, ties, path, previous in states]
|
||||
options = [(cost + c.cost + transition(previous, c), (*ties, generator.solo.policy.stable_rank(seed, (i, c.pattern_id, c.signature))), (*path, c), c) for cost, ties, path, previous in states]
|
||||
following.append(min(options, key=lambda item: item[:2]))
|
||||
states = following
|
||||
return min(states, key=lambda item: (item[0] + (transition(item[3], right) if right else 0), item[1]))[2]
|
||||
|
||||
@@ -43,9 +43,15 @@ def validate_pages(document, pages):
|
||||
def timing(document):
|
||||
times = boundaries(document)
|
||||
measures = document['staves'][0]['measures']
|
||||
# Metronome accents follow each actual measure, including meter changes.
|
||||
ending = [i for i, m in enumerate(measures) if m.get('ending_sustain')]
|
||||
if ending and ending != list(range(ending[0], len(measures))):
|
||||
raise ValueError('held ending must be a contiguous terminal section')
|
||||
sustain_start = times[ending[0]] if ending else times[-1]
|
||||
# The written held ending participates in page time, not metronome time.
|
||||
clicks = []
|
||||
for start, measure in zip(times, measures):
|
||||
if start >= sustain_start:
|
||||
break
|
||||
duration = generator.model.fraction(measure['duration'])
|
||||
beat = fractions.Fraction()
|
||||
while beat < duration:
|
||||
@@ -53,7 +59,7 @@ def timing(document):
|
||||
beat += 1
|
||||
return dict(song_id=document['metadata'].get('song_id', ''),
|
||||
duration=generator.model.rational(times[-1]),
|
||||
count_in_beats=4, clicks=clicks)
|
||||
sustain_start=generator.model.rational(sustain_start), count_in_beats=4, clicks=clicks)
|
||||
|
||||
def describe_wav(path, root, *, song_id='', bpm=0):
|
||||
if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root.resolve()):
|
||||
|
||||
@@ -26,6 +26,7 @@ import generator.parts
|
||||
import generator.score
|
||||
import generator.vocabulary
|
||||
import generator.arrange.accompaniment
|
||||
import generator.arrange.ending
|
||||
import generator.arrange.form
|
||||
import generator.arrange.generate
|
||||
import generator.arrange.pickup
|
||||
@@ -94,6 +95,7 @@ def windows(document, part):
|
||||
result[-1] = (a, start, old)
|
||||
result.append((start, end, part + '_' + role))
|
||||
cursor = end
|
||||
result.append((cursor, cursor + generator.arrange.ending.duration(source), part + ('_accompaniment' if part != 'vocal' else '_rest')))
|
||||
return tuple(result)
|
||||
|
||||
def dependencies(role, resources):
|
||||
@@ -254,6 +256,8 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
result = generator.arrange.generate._arrange_document(document, part, {}, owned={'vocal' if part == 'vocal' else 'melody'})
|
||||
allowed = tuple((a, b) for a, b, r in windows(document, part) if r == role)
|
||||
_mask(result, allowed)
|
||||
if role == "acoustic_accompaniment":
|
||||
generator.acoustic.validate_comping(result)
|
||||
staff = result['staves'][0]
|
||||
for measure in staff['measures']:
|
||||
measure['id'] = role + '_' + measure['id']
|
||||
|
||||
+39
-20
@@ -23,25 +23,44 @@ def _load(contents):
|
||||
result = {}
|
||||
for name, text in contents:
|
||||
value = json.loads(text)
|
||||
if value.get('version') != (3 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'):
|
||||
if value.get('version') != (4 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':
|
||||
if fractions.Fraction(value['grid']) != fractions.Fraction(1, 2):
|
||||
raise ValueError('accompaniment requires an eighth-note placement grid')
|
||||
for part, lanes in (('electric', ('electric',)), ('acoustic', ('bass', 'upper'))):
|
||||
signatures = {}
|
||||
for part in ('electric', 'acoustic'):
|
||||
policy = value['instruments'][part]
|
||||
if fractions.Fraction(policy['attack_spacing']) < fractions.Fraction(1, 2):
|
||||
raise ValueError('accompaniment attacks must be at least an eighth apart')
|
||||
for lane in lanes:
|
||||
layer = policy['layers'][lane]
|
||||
if not 0 <= layer['attacks'][0] <= layer['attacks'][1] <= 3 or not layer['families']:
|
||||
raise ValueError('invalid accompaniment attack range or duration families')
|
||||
for family in layer['families']:
|
||||
durations = policy['families'][family]
|
||||
if not durations or any(fractions.Fraction(d) <= 0 for d in durations):
|
||||
raise ValueError('accompaniment durations must be positive')
|
||||
if part == 'acoustic' and any(fractions.Fraction(d) not in (fractions.Fraction(1, 2), 1) for d in durations):
|
||||
raise ValueError('acoustic rhythm uses quarter and eighth notes')
|
||||
identifiers = set()
|
||||
signatures[part] = set()
|
||||
for pattern in policy['patterns']:
|
||||
if pattern['id'] in identifiers or not pattern['family']:
|
||||
raise ValueError('duplicate or unnamed accompaniment pattern')
|
||||
identifiers.add(pattern['id'])
|
||||
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')
|
||||
# Validate musical events before any measure projection or ties.
|
||||
previous = fractions.Fraction()
|
||||
onsets = []
|
||||
for row in pattern['events']:
|
||||
onset, duration = fractions.Fraction(row['onset']), fractions.Fraction(row['duration'])
|
||||
if onset < previous or duration <= 0 or onset + duration > sum(lengths):
|
||||
raise ValueError('overlapping or out-of-bounds accompaniment pattern')
|
||||
if onset.denominator > 2 or duration.denominator > 2:
|
||||
raise ValueError('comping vocabulary uses a quarter/eighth grid')
|
||||
if part == 'acoustic':
|
||||
if row['layer'] == 'combined':
|
||||
if duration <= 1:
|
||||
raise ValueError('combined acoustic sustain must exceed one beat')
|
||||
elif row['layer'] not in ('bass', 'upper') or duration not in (fractions.Fraction(1, 2), 1):
|
||||
raise ValueError('acoustic short attacks require bass/upper quarter/eighth notes')
|
||||
elif row['layer'] != 'electric':
|
||||
raise ValueError('electric pattern must contain electric chord attacks')
|
||||
if row.get('anticipate') and row['layer'] not in ('upper', 'electric'):
|
||||
raise ValueError('only upper/electric chords can anticipate')
|
||||
previous = onset + duration
|
||||
onsets.append(onset)
|
||||
if len(lengths) == 2:
|
||||
signatures[part].add((lengths, tuple(onsets)))
|
||||
families = policy['voicing_families']
|
||||
if not families.get('provenance'):
|
||||
raise ValueError('guitar voicing families require provenance')
|
||||
@@ -49,16 +68,16 @@ def _load(contents):
|
||||
if set(families['rank']) != {'drop-two', 'close-position', 'compact-open', 'compact-doubled', 'three-note-shell'} or any(type(v) is not int or not 0 <= v <= 2 for v in families['rank'].values()):
|
||||
raise ValueError('invalid electric family ranking')
|
||||
else:
|
||||
if set(map(fractions.Fraction, policy['allowed_durations'])) != {fractions.Fraction(1, 2), fractions.Fraction(1)} or policy['connect_holds']:
|
||||
raise ValueError('acoustic rhythm must retain short quarter/eighth attacks')
|
||||
for kind in ('triad', 'tetrad', 'extended'):
|
||||
if set(families[kind]) != {'5/1', '5/5', '6/1', '6/5'}:
|
||||
raise ValueError('acoustic families require root/fifth bass on both bass strings')
|
||||
for orders in families[kind].values():
|
||||
if not orders or any(len(o) != 3 or set(o) - {'root', 'fifth', 'guide', 'terminal', 'color'} for o in orders):
|
||||
raise ValueError('invalid acoustic upper chord order')
|
||||
if not 1 <= value['lane_candidates'] <= 64 or not 1 <= value['measure_candidates'] <= 64:
|
||||
raise ValueError('accompaniment candidate budgets must be 1–64')
|
||||
if signatures['acoustic'] & signatures['electric']:
|
||||
raise ValueError('acoustic/electric two-bar onset patterns must be distinct')
|
||||
if not 1 <= value['pattern_candidates'] <= 64:
|
||||
raise ValueError('accompaniment pattern budget must be 1–64')
|
||||
entries = value.get('cells', value.get('patterns', value.get('colors', value.get('transformations', []))))
|
||||
if len({v['id'] for v in entries}) != len(entries):
|
||||
raise ValueError(f'{name}: duplicate vocabulary ID')
|
||||
|
||||
Reference in New Issue
Block a user