|
|
|
@@ -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])
|
|
|
|
|