Generation

This commit is contained in:
khodowany
2026-09-26 10:12:51 -05:00
parent 9fd5abd075
commit 1d5f3a9372
20 changed files with 681 additions and 155 deletions
+118 -27
View File
@@ -4,6 +4,7 @@ import dataclasses
import fractions
import functools
import itertools
import logging
import typing
import generator.harmony
@@ -15,6 +16,8 @@ MIN_FRET = 0
MAX_FRET = 20
MAX_FRET_SPAN = 4
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
ACOUSTIC_STRINGS = ((6, 4, 3), (6, 4, 3, 2), (5, 4, 3), (5, 3, 2), (5, 4, 3, 2))
ELECTRIC_STRINGS = ((3, 2, 1), (4, 3, 2, 1))
class TabPosition(typing.NamedTuple):
string_number: int
@@ -39,14 +42,14 @@ def validate_tuning(state: dict, location: str) -> None:
if tunings and (len(tunings) != 6 or {t['line']: generator.model.pitch_number(t) for t in tunings} != expected):
raise ValueError(f'{location}: guitar generation requires standard six-string tuning')
def chord_requirements(definition: dict) -> tuple[tuple[int, ...], tuple[int, ...], int | None]:
def chord_requirements(definition: dict, profile: str = '') -> tuple[tuple[int, ...], tuple[int, ...], int | None]:
degrees = generator.harmony.degrees(definition)
root = definition['root']
root_pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
explicit = {d['value'] for d in definition.get('degrees', []) if d['type'] != 'subtract'}
optional = {d for d, a in degrees.items() if not a and d not in explicit and (d == 5 and len(degrees) > 3 or d in (9, 11) and d < max(degrees))}
classes = {d: (root_pc + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
required = {value for d, value in classes.items() if d not in optional}
required = {value for d, value in classes.items() if d not in optional and not (profile == 'electric' and d == 1)}
allowed = set(classes.values())
bass = definition.get('bass')
bass_pc = (generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12 if bass else None
@@ -56,15 +59,19 @@ def chord_requirements(definition: dict) -> tuple[tuple[int, ...], tuple[int, ..
if not 0 <= inversion < len(ordered):
raise ValueError('harmony inversion exceeds its chord degrees')
bass_pc = classes[ordered[inversion]]
if profile == 'electric':
bass_pc = None
if bass_pc is not None:
allowed.add(bass_pc)
required.add(bass_pc)
if profile == 'acoustic' and bass_pc is None:
bass_pc = root_pc % 12
return tuple(sorted(required)), tuple(sorted(allowed)), bass_pc
@functools.lru_cache(maxsize=256)
def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True) -> tuple[tuple[TabPosition, ...], ...]:
def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[TabPosition, ...], ...]:
result = []
options = [tuple(TabPosition(string, fret, open_midi + fret) for fret in range(MIN_FRET, MAX_FRET + 1) if (open_midi + fret) % 12 in allowed) for string, open_midi in STANDARD_TUNING]
options = [tuple(TabPosition(string, fret, open_midi + fret) for fret in range(MIN_FRET, MAX_FRET + 1) if (open_midi + fret) % 12 in allowed or open_midi + fret in melody) for string, open_midi in STANDARD_TUNING]
def visit(index, selected, covered):
if len(set(required) - covered) > len(options) - index:
@@ -78,7 +85,17 @@ def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: i
if position.midi % 12 not in covered and within_fret_span((*selected, position)):
visit(index + 1, (*selected, position), covered | {position.midi % 12})
visit(0, (), set())
if profile:
by_string = {string: options[i] for i, (string, _) in enumerate(STANDARD_TUNING)}
for strings in ACOUSTIC_STRINGS if profile == 'acoustic' else ELECTRIC_STRINGS:
for selected in itertools.product(*(by_string[s] for s in strings)):
if not within_fret_span(selected) or not set(required) <= {p.midi % 12 for p in selected} or not set(melody) <= {p.midi for p in selected}:
continue
if bass is not None and (selected[0].midi % 12 != bass or selected[0].midi != min(p.midi for p in selected)):
continue
result.append(selected)
else:
visit(0, (), set())
if not prune:
return tuple(sorted(result))
# Independent attacks with equal centers have identical movement costs.
@@ -183,34 +200,83 @@ class AttackCandidate:
shape: tuple[TabPosition, ...]
missing: int
def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: dict, held: dict, preserve_strings: bool):
@dataclasses.dataclass(frozen=True)
class EventPlacement:
note: dict
exact: tuple
added: tuple[TabPosition, ...] = ()
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)
return result
def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: dict, held: dict, preserve_strings: bool, fallback: bool = False):
pending = []
chords = [n for n in notes if '_harmony' in n]
if len(chords) > 1:
raise ValueError('generated accompaniment requires one harmonic event per onset')
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')
for note in notes:
for tone in note['pitches']:
options = tones[tone['id']].positions
if source := tone.get('tie_from'):
options = tuple(p for p in options if p == active.get(source))
pending.append((tone['id'], options))
requirements = chord_requirements(chord['_harmony']) if chord else ((), (), None)
candidates = chord_positions(*requirements, prune=not (preserve_strings or held or pending)) if chord else [()]
for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))):
for positions in candidates:
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
if fallback and chord and chord.get('_harmonize'):
chords, chord, profile = [], None, ''
requirements = chord_requirements(chord['_harmony'], profile) if chord else ((), (), None)
melody = tuple(sorted({p.midi for _, options in pending for p in options})) if profile == 'electric' else ()
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) if chord else [()]
found = False
for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))):
exact_positions = dict(exact)
choices = tuple((n, positions if n is chord else tuple((p, exact_positions[p['id']]) for p in n['pitches'])) for n in notes)
yield AttackCandidate(assignment, choices, shape, len(set(requirements[1]) - {p.midi % 12 for p in positions}))
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):
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
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, len(set(requirements[1]) - {p.midi % 12 for p in positions}))
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
yield AttackCandidate(assignment, choices, shape, len(set(requirements[1]) - {p.midi % 12 for p in positions}))
if found:
break
def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
events, tones = [], {}
absolute = fractions.Fraction()
previous = {}
for measure in document['staves'][0]['measures']:
if measure.get('section_label'):
previous.clear()
changes = sorted(measure.get('attributes', []), key=lambda a: generator.model.fraction(a['onset']))
state = {k: measure[k] for k in ('transpose', 'staff_details')}
for change in [state, *changes]:
@@ -218,6 +284,16 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
validate_tuning(state, measure['id'])
for voice in measure['voices']:
for order, note in enumerate(voice['notes']):
if note.get('_rhythm_continuation'):
source = previous.get(voice['id'])
if source and '_harmony' in source:
note['_continue_from'] = source['id']
for string, _ in STANDARD_TUNING:
tones[f'{source["id"]}-s{string}'].pitch['tie_to'] = f'{note["id"]}-s{string}'
elif not note['pitches']:
note.pop('_harmony', None)
note['kind'] = 'rest'
previous[voice['id']] = note
onset = generator.model.fraction(note['onset'])
state = {k: measure[k] for k in ('transpose', 'staff_details')}
for change in changes:
@@ -226,6 +302,8 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
for tone in note['pitches']:
sounding = generator.pitch.sounding_pitch(tone, state)
options = positions_for_pitch(sounding)
if tab := tone.get('tablature'):
options = tuple(p for p in options if (p.string_number, p.fret) == (tab['string'], tab['fret']))
if not options:
raise ValueError(f'{measure["id"]}, event {note["id"]}: sounding pitch {sounding} has no string/fret in {MIN_FRET}-{MAX_FRET}')
tones[tone['id']] = GuitarTone(tone, absolute + onset + generator.model.fraction(tone['duration']), options)
@@ -240,8 +318,8 @@ 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_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool) -> dict:
continued = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p}
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)
held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)}
result = {}
@@ -252,7 +330,7 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
if not held_ids and not continued:
nearest = nearest_positions(states)
for candidate in attack_candidates(notes, tones, {}, {}, preserve_strings):
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)
@@ -260,10 +338,12 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
for previous in states.values():
active = dict(previous.active)
held = {i: p for i, p in previous.active if i not in continued and i in held_ids}
for candidate in attack_candidates(notes, tones, active, held, preserve_strings):
for candidate in attack_candidates(notes, tones, active, held, preserve_strings, fallback):
distance = movement(tuple(p for _, p in previous.active), candidate.shape)
keep(candidate, previous, (previous.cost[0] + distance, previous.cost[1], previous.cost[2]))
if not result:
if not fallback and any(n.get('_harmonize') for n in notes):
return advance_positions(notes, onset, tones, states, preserve_strings, True)
raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement within frets {MIN_FRET}-{MAX_FRET} and four-fret span; held/tied strings must remain fixed')
return result
@@ -277,12 +357,23 @@ def place_guitar(document: dict) -> 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:
for note, choices in selected.choices:
for choice in selected.choices:
note = choice.note
for tone, position in choice.exact:
tone['tablature'] = {'string': position.string_number, 'fret': position.fret}
if definition := note.pop('_harmony', None):
note['pitches'] = generator.model.make_event(note['id'], generator.model.fraction(note['onset']), generator.model.fraction(note['duration']), 'note', chord_pitches(definition, choices))['pitches']
else:
for tone, position in choices:
tone['tablature'] = {'string': position.string_number, 'fret': position.fret}
for pitch in chord_pitches(definition, choice.added):
note['pitches'].append({**pitch, 'id': f'{note["id"]}-s{pitch["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']})
if note.get('_harmonize') and not choice.added and not note.get('_rhythm_continuation'):
skipped.append(note['id'])
selected = selected.previous
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'):
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])
document['staves'][0]['tablature'] = True