Generation

This commit is contained in:
khodowany
2026-09-26 18:00:51 -05:00
parent c8581ea51a
commit 83154b1a01
10 changed files with 263 additions and 90 deletions
-26
View File
@@ -3,10 +3,7 @@ from __future__ import annotations
import dataclasses
import fractions
import functools
import itertools
import generator.guitar
import generator.harmony
import generator.model
import generator.rhythm
@@ -232,26 +229,3 @@ def connect_continuations(document: dict) -> bool:
changed = True
previous[voice['id']] = note
return changed
def prepare_basses(document: dict) -> None:
previous, attack, current_bass = None, 0, None
for measure in document['staves'][0]['measures']:
if measure.get('section_label'):
previous, attack = None, 0
notes = sorted((n for v in measure['voices'] for n in v['notes'] if n.get('_guitar_profile') == 'acoustic'), key=lambda n: generator.model.fraction(n['onset']))
for _, group in itertools.groupby(notes, key=lambda n: generator.model.fraction(n['onset'])):
group = list(group)
chord = group[0]['_harmony']
root = (generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter'])) % 12
if not same_chord(chord, previous):
attack, current_bass = 0, root
explicit = 'bass' in chord or 'inversion' in chord
if any(n['_guitar_lane'] == 'bass' for n in group):
degrees = generator.harmony.degrees(chord)
current_bass = (root + generator.harmony.degree_semitones(5, degrees[5])) % 12 if not explicit and attack % 2 and 5 in degrees else root
attack += 1
if explicit:
current_bass = generator.guitar.chord_requirements(chord, 'acoustic')[2]
for note in group:
note['_bass_pc'] = current_bass
previous = chord
+29
View File
@@ -1,11 +1,40 @@
from __future__ import annotations
import dataclasses
import fractions
import itertools
import generator.accompaniment
import generator.model
@dataclasses.dataclass
class HarmonySpan:
harmony: dict
location: str
notes: list[dict] = dataclasses.field(default_factory=list)
def harmony_spans(document: dict) -> list[HarmonySpan]:
"""Group attacks by continuous harmony, including changes during silence."""
spans, span, active = [], None, None
for measure in document['staves'][0]['measures']:
if {v['id'] for v in measure['voices']} != {'bass', 'upper'}:
span, active = None, None
continue
events = [(generator.model.fraction(h['onset']), 0, h) for h in measure['harmonies']]
events.extend((generator.model.fraction(n['onset']), 1, n) for v in measure['voices'] for n in v['notes'] if n.get('_guitar_profile') == 'acoustic')
for onset, kind, item in sorted(events, key=lambda event: event[:2]):
harmony = item if kind == 0 else item['_harmony']
if not generator.accompaniment.same_chord(active, harmony):
span = None
active = harmony
if kind == 0:
continue
if span is None:
span = HarmonySpan(harmony, f'measure {measure["number"]}, event {item["id"]}, onset {onset}')
spans.append(span)
span.notes.append(item)
return spans
def written_durations(length: fractions.Fraction):
while length:
written = generator.model.written_for(length)
-1
View File
@@ -115,7 +115,6 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
result['metadata'].setdefault('tempo', 124)
if part_type != 'vocal':
generator.acoustic.prepare(result)
generator.accompaniment.prepare_basses(result)
generator.melody.harmonize(result)
generator.guitar.place_guitar(result)
if generator.accompaniment.connect_continuations(result):
+67 -55
View File
@@ -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])
-1
View File
@@ -35,7 +35,6 @@ def _practice_document(document: dict, part_type: str, plan: generator.accompani
generator.scat.add_scat(result)
else:
generator.acoustic.prepare(result)
generator.accompaniment.prepare_basses(result)
generator.melody.harmonize(result)
generator.guitar.place_guitar(result)
if generator.accompaniment.connect_continuations(result):