Generation
This commit is contained in:
+67
-55
@@ -7,6 +7,7 @@ import itertools
|
||||
import logging
|
||||
import typing
|
||||
|
||||
import generator.acoustic
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
@@ -233,6 +234,13 @@ class EventPlacement:
|
||||
exact: tuple
|
||||
added: tuple[TabPosition, ...] = ()
|
||||
|
||||
def acoustic_candidates(span: generator.acoustic.HarmonySpan):
|
||||
required, allowed, bass = chord_requirements(span.harmony, 'acoustic')
|
||||
for shape in chord_positions(required, allowed, bass, profile='acoustic'):
|
||||
active = tuple((str(p.string_number), p) for p in shape)
|
||||
choices = tuple(EventPlacement(note, (), tuple(p for p in shape if (p.string_number >= 5) == (note['_guitar_lane'] == 'bass'))) for note in span.notes)
|
||||
yield AttackCandidate(active, choices, shape, voicing_cost(shape, 'acoustic', allowed))
|
||||
|
||||
def continued_sources(notes):
|
||||
result = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p}
|
||||
result.update(f'{n["_continue_from"]}-s{s}' for n in notes if '_continue_from' in n for s, _ in STANDARD_TUNING)
|
||||
@@ -246,8 +254,8 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
|
||||
chords = [n for n in notes if '_harmony' in n]
|
||||
chord = chords[0] if chords else None
|
||||
profile = chord.get('_guitar_profile', '') if chord else ''
|
||||
if len(chords) > 1 and (profile != 'acoustic' or any(n['_harmony'] != chord['_harmony'] for n in chords)):
|
||||
raise ValueError('simultaneous generated voices must share one acoustic harmony')
|
||||
if len(chords) > 1:
|
||||
raise ValueError('simultaneous generated chords require acoustic harmony-span placement')
|
||||
for note in notes:
|
||||
for tone in note['pitches']:
|
||||
options = tones[tone['id']].positions
|
||||
@@ -265,49 +273,40 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
|
||||
root = value
|
||||
melody = tuple(sorted({p.midi for _, options in pending for p in options})) if profile == 'electric' else ()
|
||||
fresh = has_attack(notes)
|
||||
basses = [chord.get('_bass_pc', requirements[2])] if chord else [None]
|
||||
if profile == 'acoustic' and requirements[2] not in basses:
|
||||
basses.append(requirements[2])
|
||||
for bass in basses:
|
||||
candidates = chord_positions(requirements[0], requirements[1], bass, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody, root=root) if chord else [()]
|
||||
found = False
|
||||
for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))):
|
||||
exact_positions = dict(exact)
|
||||
for positions in candidates:
|
||||
if profile:
|
||||
if any(p not in positions for p in exact_positions.values()):
|
||||
continue
|
||||
assignment = dict(exact)
|
||||
choices = []
|
||||
compatible = True
|
||||
for note in notes:
|
||||
added = tuple(p for p in positions if p not in exact_positions.values() and (note in chords and (profile != 'acoustic' or (p.string_number >= 5) == (note['_guitar_lane'] == 'bass'))))
|
||||
if any(p.midi % 12 not in requirements[1] for p in added):
|
||||
candidates = chord_positions(*requirements, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody, root=root) if chord else [()]
|
||||
for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))):
|
||||
exact_positions = dict(exact)
|
||||
for positions in candidates:
|
||||
if profile:
|
||||
if any(p not in positions for p in exact_positions.values()):
|
||||
continue
|
||||
assignment = dict(exact)
|
||||
choices = []
|
||||
compatible = True
|
||||
for note in notes:
|
||||
added = tuple(p for p in positions if p not in exact_positions.values() and note in chords)
|
||||
if any(p.midi % 12 not in requirements[1] for p in added):
|
||||
compatible = False
|
||||
break
|
||||
if '_continue_from' in note:
|
||||
previous = {p for i, p in active.items() if i.startswith(note['_continue_from'] + '-s')}
|
||||
if previous != set(added):
|
||||
compatible = False
|
||||
break
|
||||
if '_continue_from' in note:
|
||||
previous = {p for i, p in active.items() if i.startswith(note['_continue_from'] + '-s')}
|
||||
if previous != set(added):
|
||||
compatible = False
|
||||
break
|
||||
assignment.update((f'{note["id"]}-s{p.string_number}', p) for p in added)
|
||||
choices.append(EventPlacement(note, tuple((p, exact_positions[p['id']]) for p in note['pitches']), added))
|
||||
if not compatible:
|
||||
continue
|
||||
found = True
|
||||
cost = voicing_cost(positions, profile, requirements[1], root, melody) if fresh else VoicingCost()
|
||||
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost)
|
||||
else:
|
||||
shape = tuple(p for _, p in exact) + positions
|
||||
if len({p.string_number for p in shape}) != len(shape) or not within_fret_span(shape):
|
||||
continue
|
||||
assignment = tuple(sorted((*exact, *((f'{chord["id"]}-s{p.string_number}', p) for p in positions)))) if chord else exact
|
||||
choices = tuple(EventPlacement(n, tuple((p, exact_positions[p['id']]) for p in n['pitches']), positions if n is chord else ()) for n in notes)
|
||||
found = True
|
||||
cost = voicing_cost(shape, profile, requirements[1], root, melody) if fresh else VoicingCost()
|
||||
yield AttackCandidate(assignment, choices, shape, cost)
|
||||
if found:
|
||||
break
|
||||
assignment.update((f'{note["id"]}-s{p.string_number}', p) for p in added)
|
||||
choices.append(EventPlacement(note, tuple((p, exact_positions[p['id']]) for p in note['pitches']), added))
|
||||
if not compatible:
|
||||
continue
|
||||
cost = voicing_cost(positions, profile, requirements[1], root, melody) if fresh else VoicingCost()
|
||||
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost)
|
||||
else:
|
||||
shape = tuple(p for _, p in exact) + positions
|
||||
if len({p.string_number for p in shape}) != len(shape) or not within_fret_span(shape):
|
||||
continue
|
||||
assignment = tuple(sorted((*exact, *((f'{chord["id"]}-s{p.string_number}', p) for p in positions)))) if chord else exact
|
||||
choices = tuple(EventPlacement(n, tuple((p, exact_positions[p['id']]) for p in n['pitches']), positions if n is chord else ()) for n in notes)
|
||||
cost = voicing_cost(shape, profile, requirements[1], root, melody) if fresh else VoicingCost()
|
||||
yield AttackCandidate(assignment, choices, shape, cost)
|
||||
|
||||
def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
|
||||
events, tones = [], {}
|
||||
@@ -357,6 +356,17 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
|
||||
def retain_tone(tone: GuitarTone, onset: fractions.Fraction) -> bool:
|
||||
return tone.end > onset or tone.end == onset and bool(tone.pitch.get('tie_to'))
|
||||
|
||||
def advance_attacks(candidates, states: dict) -> dict:
|
||||
nearest = nearest_positions(states)
|
||||
result = {}
|
||||
for candidate in candidates:
|
||||
x, y = position_center(candidate.shape)
|
||||
cost, _, previous = nearest[x][y]
|
||||
cost = cost.plus(candidate.cost)
|
||||
if candidate.active not in result or cost < result[candidate.active].cost:
|
||||
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
|
||||
return result
|
||||
|
||||
def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False) -> dict:
|
||||
continued = continued_sources(notes)
|
||||
fresh = has_attack(notes)
|
||||
@@ -369,11 +379,7 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
|
||||
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
|
||||
|
||||
if not held_ids and not continued:
|
||||
nearest = nearest_positions(states)
|
||||
for candidate in attack_candidates(notes, tones, {}, {}, preserve_strings, fallback):
|
||||
x, y = position_center(candidate.shape)
|
||||
cost, _, previous = nearest[x][y]
|
||||
keep(candidate, previous, cost)
|
||||
result = advance_attacks(attack_candidates(notes, tones, {}, {}, preserve_strings, fallback), states)
|
||||
else:
|
||||
for previous in states.values():
|
||||
active = dict(previous.active)
|
||||
@@ -388,14 +394,20 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
|
||||
return result
|
||||
|
||||
def place_guitar(document: dict) -> None:
|
||||
"""Choose a minimum-cost path, retaining held strings in each search state."""
|
||||
"""Choose acoustic grips per harmony span, or exact/held melody placements."""
|
||||
events, tones = guitar_events(document)
|
||||
groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])]
|
||||
states = {(): Placement(VoicingCost(), (), None, ())}
|
||||
for index, (onset, notes) in enumerate(groups):
|
||||
next_onset = groups[index + 1][0] if index + 1 < len(groups) else None
|
||||
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes)
|
||||
states = advance_positions(notes, onset, tones, states, preserve_strings)
|
||||
if document['staves'][0].get('arrangement_part_type') == 'acoustic':
|
||||
for span in generator.acoustic.harmony_spans(document):
|
||||
states = advance_attacks(acoustic_candidates(span), states)
|
||||
if not states:
|
||||
raise ValueError(f'{span.location}: no acoustic grip within frets {MIN_FRET}-{MAX_FRET} and four-fret span for {span.harmony.get("symbol", span.harmony["kind"])}')
|
||||
else:
|
||||
groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])]
|
||||
for index, (onset, notes) in enumerate(groups):
|
||||
next_onset = groups[index + 1][0] if index + 1 < len(groups) else None
|
||||
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes)
|
||||
states = advance_positions(notes, onset, tones, states, preserve_strings)
|
||||
selected = min(states.values(), key=lambda s: (s.cost, s.active))
|
||||
skipped = []
|
||||
while selected.previous is not None:
|
||||
@@ -412,7 +424,7 @@ def place_guitar(document: dict) -> None:
|
||||
for measure in document['staves'][0]['measures']:
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
for field in ('_guitar_profile', '_guitar_lane', '_bass_pc', '_harmonize', '_continue_from'):
|
||||
for field in ('_guitar_profile', '_guitar_lane', '_harmonize', '_continue_from'):
|
||||
note.pop(field, None)
|
||||
if skipped:
|
||||
logging.getLogger(__name__).warning('%s: %s melody harmonizations kept as single notes; first event %s', document['metadata']['title'], len(skipped), skipped[-1])
|
||||
|
||||
Reference in New Issue
Block a user