@@ -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
@@ -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}]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user