Generation
ci / checks (push) Successful in 1h10m52s

This commit is contained in:
khodowany
2026-09-26 17:26:22 -05:00
parent 11222a9f8c
commit c8581ea51a
11 changed files with 343 additions and 186 deletions
+4 -15
View File
@@ -63,7 +63,7 @@ def context(measure: dict, previous: dict | None) -> Context:
@functools.lru_cache(maxsize=4096)
def sounding_rhythm(bar, offset, intervals):
attacks = tuple(t - offset for t in bar.attacks() if any(a <= t - offset < b for a, b in intervals))
spans = tuple((max(event.onset - offset, a), min(event.onset + event.duration - offset, b)) for event in bar.events if event.sounding and event.onset + event.duration > offset for a, b in intervals if a <= max(event.onset - offset, 0) < b)
spans = tuple((max(event.onset - offset, a), min(event.onset + event.duration - offset, b)) for event in bar.events if event.sounding and not event.continuation and event.onset + event.duration > offset for a, b in intervals if a <= max(event.onset - offset, 0) < b)
return attacks, spans
def sounding_attacks(bar: generator.rhythm.Bar, item: Context) -> tuple[fractions.Fraction, ...]:
@@ -186,20 +186,11 @@ def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None, *,
continue
chord = active_chord(measure, start, previous) if source.sounding else None
continuation = source.continuation and source.onset - item.offset == start
preceding = previous
if continuation:
for change in measure['harmonies']:
if generator.model.fraction(change['onset']) >= start:
break
preceding = change
if not same_chord(chord, preceding):
chord = None
chord = None
sounding_end = end
if chord and lane == 'electric':
if continuation:
chord = None
else:
sounding_end = min(end, start + 1)
sounding_end = min(end, start + 1)
for change in measure['harmonies']:
time = generator.model.fraction(change['onset'])
if chord and start < time < sounding_end and not same_chord(chord, active_chord(measure, time, previous)):
@@ -210,8 +201,6 @@ def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None, *,
event['_harmony'] = chord
if lane:
event.update(_guitar_profile='electric' if lane == 'electric' else 'acoustic', _guitar_lane=lane)
if continuation:
event['_rhythm_continuation'] = True
if source.beams and written and chord:
event['beams'] = [{'level': level, 'type': kind} for level, kind in source.beams]
events.append(event)
@@ -257,7 +246,7 @@ def prepare_basses(document: dict) -> None:
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' and not n.get('_rhythm_continuation') for n in group):
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
+56 -49
View File
@@ -1,71 +1,78 @@
from __future__ import annotations
import dataclasses
import fractions
import itertools
import generator.accompaniment
import generator.model
@dataclasses.dataclass
class SoundingTone:
pitch: dict
start: fractions.Fraction
end: fractions.Fraction
def written_durations(length: fractions.Fraction):
while length:
written = generator.model.written_for(length)
duration = length
# Binary spans such as five eighths need ties, not an invented tuplet.
if 'ratio' in written and length.denominator & (length.denominator - 1) == 0:
duration = next((size for size in generator.model.TYPE_LENGTHS.values() if size < length), length)
written = generator.model.written_for(duration)
yield duration, written
length -= duration
def combine(document: dict) -> None:
"""Consolidate generated acoustic lanes without changing their sounding notes."""
def measures(document: dict):
staff = document['staves'][0]
if staff.get('arrangement_part_type') != 'acoustic':
return
measures, sounds, roots = [], {}, {}
absolute = fractions.Fraction()
for measure in staff['measures']:
if staff.get('arrangement_part_type') == 'acoustic':
yield from (m for m in staff['measures'] if {v['id'] for v in m['voices']} == {'bass', 'upper'})
def prepare(document: dict) -> None:
"""Release each grip before the next attack; never require overlapping lanes."""
for measure in measures(document):
length = generator.model.fraction(measure['duration'])
if {voice['id'] for voice in measure['voices']} == {'bass', 'upper'}:
members = set()
for voice in measure['voices']:
for note in voice['notes']:
start = absolute + generator.model.fraction(note['onset'])
attacks = sorted((n for v in measure['voices'] for n in v['notes'] if n['pitches'] or '_harmony' in n), key=lambda n: generator.model.fraction(n['onset']))
groups = [(time, list(notes)) for time, notes in itertools.groupby(attacks, key=lambda n: generator.model.fraction(n['onset']))]
for index, (start, notes) in enumerate(groups):
end = min(start + generator.model.fraction(n['duration']) for n in notes)
end = min(end, groups[index + 1][0] if index + 1 < len(groups) else length)
# A duration needing ties becomes one shorter attack followed by silence.
duration, written = next(written_durations(end - start))
for note in notes:
if generator.model.fraction(note['duration']) != duration:
note.update(duration=generator.model.rational(duration), written=written)
note.pop('beams', None)
for pitch in note['pitches']:
end = start + generator.model.fraction(pitch['duration'])
if source := pitch.get('tie_from'):
root = roots[source]
sounds[root].end = end
else:
root = pitch['id']
sounds[root] = SoundingTone(pitch, start, end)
roots[pitch['id']] = root
members.add(root)
measures.append((measure, absolute, members))
absolute += length
previous = {}
for measure, absolute, members in measures:
length = generator.model.fraction(measure['duration'])
spans = [(root, max(fractions.Fraction(), sounds[root].start - absolute), min(length, sounds[root].end - absolute)) for root in sorted(members)]
boundaries = sorted({fractions.Fraction(), length} | {time for _, start, end in spans for time in (start, end)})
notes = []
for start, end in zip(boundaries, boundaries[1:]):
active = sorted((root for root, low, high in spans if low <= start and end <= high), key=lambda root: (generator.model.pitch_number(sounds[root].pitch), sounds[root].pitch['tablature']['string'], root))
for duration, written in written_durations(end - start):
pitches = [{key: value for key, value in sounds[root].pitch.items() if key not in ('tie_from', 'tie_to', 'stem')} for root in active]
note = generator.model.make_event(f'{measure["id"]}-acoustic-{len(notes) + 1}', start, duration, 'note' if active else 'rest', pitches, written)
note['voice'] = 'voice-arranged'
for root, pitch in zip(active, note['pitches']):
if root in previous:
previous[root]['tie_to'], pitch['tie_from'] = pitch['id'], previous[root]['id']
previous[root] = pitch
pitch.update(duration=note['duration'], written=written)
for voice in measure['voices']:
notes, cursor = [], fractions.Fraction()
for note in voice['notes']:
start = generator.model.fraction(note['onset'])
if start > cursor:
notes.append(generator.model.make_event(f'{note["id"]}-release', cursor, start - cursor, 'rest') | {'voice': voice['id']})
notes.append(note)
start += duration
if not members and length == generator.model.meter_length(measure['meter']):
cursor = start + generator.model.fraction(note['duration'])
if cursor < length:
notes.append(generator.model.make_event(f'{measure["id"]}-{voice["id"]}-release', cursor, length - cursor, 'rest') | {'voice': voice['id']})
voice['notes'] = notes
generator.accompaniment.normalize_beams(notes)
def combine(document: dict) -> None:
"""Write prepared bass/upper attacks as one voice with shared stems and rests."""
for measure in measures(document):
length = generator.model.fraction(measure['duration'])
attacks = sorted((n for v in measure['voices'] for n in v['notes'] if n['pitches']), key=lambda n: generator.model.fraction(n['onset']))
notes, cursor = [], fractions.Fraction()
def rest(end):
nonlocal cursor
for duration, written in written_durations(end - cursor):
notes.append(generator.model.make_event(f'{measure["id"]}-acoustic-{len(notes) + 1}', cursor, duration, 'rest', written=written) | {'voice': 'voice-arranged'})
cursor += duration
for start, group in itertools.groupby(attacks, key=lambda n: generator.model.fraction(n['onset'])):
rest(start)
group = list(group)
pitches = sorted((p for n in group for p in n['pitches']), key=lambda p: (generator.model.pitch_number(p), p['tablature']['string']))
pitches = [{k: v for k, v in p.items() if k != 'stem'} for p in pitches]
duration = generator.model.fraction(group[0]['duration'])
notes.append(generator.model.make_event(f'{measure["id"]}-acoustic-{len(notes) + 1}', start, duration, 'note', pitches, group[0]['written']) | {'voice': 'voice-arranged'})
cursor = start + duration
rest(length)
if not attacks and length == generator.model.meter_length(measure['meter']):
notes = [generator.model.make_event(f'{measure["id"]}-acoustic-1', fractions.Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'voice': 'voice-arranged', 'measure_rest': True}]
measure['voices'] = [{'id': 'voice-arranged', 'notes': notes}]
+1
View File
@@ -114,6 +114,7 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
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)
if part_type != 'vocal':
generator.acoustic.prepare(result)
generator.accompaniment.prepare_basses(result)
generator.melody.harmonize(result)
generator.guitar.place_guitar(result)
+54 -14
View File
@@ -24,6 +24,32 @@ class TabPosition(typing.NamedTuple):
fret: int
midi: int
class VoicingCost(typing.NamedTuple):
string_shortfall: int = 0
covered_melody: int = 0
covering_tones: int = 0
missing_root: int = 0
effort: int = 0
missing_tones: int = 0
def plus(self, other: VoicingCost) -> VoicingCost:
return VoicingCost(*(a + b for a, b in zip(self, other)))
@functools.lru_cache(maxsize=16384)
def moved(self, distance: int) -> VoicingCost:
return self._replace(effort=self.effort + distance)
@functools.lru_cache(maxsize=8192)
def voicing_cost(positions: tuple[TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), root: int | None = None, melody: tuple[int, ...] = ()) -> VoicingCost:
frets = {p.fret for p in positions}
classes = {p.midi % 12 for p in positions}
above = sum(p.midi > max(melody) for p in positions) if melody else 0
span = compactness(positions)
effort = 2 * max(frets, default=0) + span
if profile == 'acoustic':
effort += 2 * span + 2 * max(0, len(frets) - 1)
return VoicingCost(max(0, 4 - len(positions)) if profile == 'electric' else 0,
int(bool(above)), above, int(profile == 'electric' and root is not None and root not in classes),
effort, len(set(allowed) - classes))
def within_fret_span(positions) -> bool:
frets = [position.fret for position in positions]
return not frets or max(frets) - min(frets) <= MAX_FRET_SPAN
@@ -69,7 +95,7 @@ def chord_requirements(definition: dict, profile: str = '') -> tuple[tuple[int,
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, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[TabPosition, ...], ...]:
def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True, profile: str = '', melody: tuple[int, ...] = (), root: int | None = None) -> 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 or open_midi + fret in melody) for string, open_midi in STANDARD_TUNING]
@@ -103,7 +129,7 @@ def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: i
best = {}
for positions in result:
center = position_center(positions)
rank = (compactness(positions), len(set(allowed) - {p.midi % 12 for p in positions}), positions)
rank = (voicing_cost(positions, profile, allowed, root, melody), positions)
if center not in best or rank < best[center][0]:
best[center] = (rank, positions)
return tuple(sorted(item[1] for item in best.values()))
@@ -151,17 +177,18 @@ def extend_positions(held, pending):
@dataclasses.dataclass
class Placement:
cost: tuple[int, int, int]
cost: VoicingCost
active: tuple
previous: Placement | None
choices: tuple
def nearest_positions(states: dict) -> list[list[tuple | None]]:
"""Exact Manhattan distance transform on the small fret/string position grid."""
grid = [[None for _ in range(11)] for _ in range(2 * MAX_FRET + 1)]
initial = min(((p.cost, i, p) for i, p in enumerate(states.values()) if not p.active), key=lambda item: item[:2], default=None)
grid = [[initial for _ in range(11)] for _ in range(2 * MAX_FRET + 1)]
for index, previous in enumerate(states.values()):
if not previous.active:
return [[(previous.cost, index, previous) for _ in row] for row in grid]
continue
x, y = position_center(tuple(p for _, p in previous.active))
candidate = (previous.cost, index, previous)
if grid[x][y] is None or candidate[:2] < grid[x][y][:2]:
@@ -171,7 +198,7 @@ def nearest_positions(states: dict) -> list[list[tuple | None]]:
source = grid[a][b]
if source is not None:
cost, index, previous = source
candidate = ((cost[0] + 1, cost[1], cost[2]), index, previous)
candidate = (cost.moved(1), index, previous)
if grid[x][y] is None or candidate[:2] < grid[x][y][:2]:
grid[x][y] = candidate
@@ -198,7 +225,7 @@ class AttackCandidate:
active: tuple
choices: tuple
shape: tuple[TabPosition, ...]
missing: int
cost: VoicingCost
@dataclasses.dataclass(frozen=True)
class EventPlacement:
@@ -211,6 +238,9 @@ def continued_sources(notes):
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 has_attack(notes: list[dict]) -> bool:
return any('_harmony' in n and '_continue_from' not in n or any('tie_from' not in p for p in n['pitches']) for n in notes)
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]
@@ -227,12 +257,19 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
if fallback and chord and chord.get('_harmonize'):
chords, chord, profile = [], None, ''
requirements = chord_requirements(chord['_harmony'], profile) if chord else ((), (), None)
root = None
if chord and 1 in generator.harmony.degrees(chord['_harmony']):
pitch = chord['_harmony']['root']
value = int((generator.model.NATURAL[pitch['letter']] + generator.model.alteration(pitch['alter'])) % 12)
if value in requirements[1]:
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) if chord else [()]
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)
@@ -258,7 +295,8 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
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}))
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):
@@ -266,7 +304,8 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
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}))
cost = voicing_cost(shape, profile, requirements[1], root, melody) if fresh else VoicingCost()
yield AttackCandidate(assignment, choices, shape, cost)
if found:
break
@@ -320,11 +359,12 @@ def retain_tone(tone: GuitarTone, onset: fractions.Fraction) -> bool:
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)
held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)}
result = {}
def keep(candidate, previous, cost):
cost = (cost[0], cost[1] + compactness(candidate.shape), cost[2] + candidate.missing)
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)
@@ -339,8 +379,8 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
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, 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]))
distance = movement(tuple(p for _, p in previous.active), candidate.shape) if fresh else 0
keep(candidate, previous, previous.cost.moved(distance))
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)
@@ -351,7 +391,7 @@ def place_guitar(document: dict) -> None:
"""Choose a minimum-cost path, retaining held strings in each search state."""
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((0, 0, 0), (), None, ())}
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)
+1
View File
@@ -34,6 +34,7 @@ def _practice_document(document: dict, part_type: str, plan: generator.accompani
if part_type == 'vocal':
generator.scat.add_scat(result)
else:
generator.acoustic.prepare(result)
generator.accompaniment.prepare_basses(result)
generator.melody.harmonize(result)
generator.guitar.place_guitar(result)