Generation

This commit is contained in:
khodowany
2026-09-30 01:38:16 -05:00
parent b1a7b44d7a
commit deaa0468a7
20 changed files with 417 additions and 198 deletions
+7
View File
@@ -33,6 +33,13 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
for source in item.sources:
primitives.setdefault(source, []).append(item)
for system in page.systems:
for role in ('staff', 'tab-staff'):
lines = [p for p in system.primitives if p.role == role]
if lines:
left = min(x for p in lines for x, _ in p.points)
right = max(x for p in lines for x, _ in p.points)
if abs(left - layout.style.margin_x - 1) > 0.01 or abs(right - (page.width - layout.style.margin_x - 1)) > 0.01:
raise generator.engraving.types.EngravingError('music systems must have consistent rendered staff widths')
if system.start != previous_end or system.end <= system.start:
raise generator.engraving.types.EngravingError('layout systems do not partition the score timeline')
previous_end = system.end
+35 -24
View File
@@ -78,7 +78,7 @@ def column_metrics(index, time, start, end, fonts, style, columns):
columns.popitem(last=False)
return result
def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, justify: bool = False, *, columns=None) -> generator.engraving.types.System:
def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, justify: bool = False, *, columns=None, staff_width: float | None = None) -> generator.engraving.types.System:
items, anchors, fragments = [], [], []
state = index.state(start)
measure = index.measure_at(start)[2]
@@ -118,7 +118,7 @@ def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions
cursor = x + max(style.space * 1.4, box.right) + spring
groups.append((item_start, len(items), anchor_start, len(anchors)))
width = cursor + 6
available = style.width - style.margin_x * 2
available = staff_width + 1 if staff_width is not None else style.width - style.margin_x * 2
if justify and width < available:
stretch = (available - width) / max(1, len(times))
for i, (first_item, last_item, first_anchor, last_anchor) in enumerate(groups):
@@ -126,6 +126,10 @@ def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions
anchors[first_anchor:last_anchor] = [a.moved(i * stretch, 0) for a in anchors[first_anchor:last_anchor]]
placements = [(time, x + i * stretch) for i, (time, x) in enumerate(placements)]
width = available
if staff_width is not None:
if width > available + 0.01:
raise generator.engraving.types.EngravingError(f'at musical offset {start}: content exceeds the final staff width')
width = available
right = width - 5
last_measure = index.measure_at(end, before=True)[2]
tab_items = [p for p in items if p.role.startswith('tab')]
@@ -155,8 +159,22 @@ def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions
items = generator.engraving.tablature.staff(index, items + tab_items, start, end, content_left, width - 1, fonts, style)
return generator.engraving.types.System(start, end, tuple(items), tuple(anchors), tuple(fragments), generator.engraving.types.bounds(items))
def paginate(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[generator.engraving.types.Page, ...]:
def fit_page_systems(systems, index, row_height, fonts, style):
scale = min(1, row_height / max(s.box.bottom - s.box.top for s in systems))
columns = collections.OrderedDict()
# Re-space columns at the final physical width; scale glyphs uniformly, not horizontally.
for _ in range(8):
width = (style.width - 2 * style.margin_x - 2) / scale
fitted = [make_system(index, s.start, s.end, fonts, style, justify=s.end < index.end, columns=columns, staff_width=width) for s in systems]
height = max(s.box.bottom - s.box.top for s in fitted)
if height * scale <= row_height + 0.001:
return [s.scaled(scale) for s in fitted]
scale = row_height / height
raise generator.engraving.types.EngravingError(f'at musical offset {systems[0].start}: page spacing did not converge')
def paginate(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, *, index=None) -> tuple[generator.engraving.types.Page, ...]:
pages = []
index = index or generator.engraving.notation.ScoreIndex(document)
guitar = document['staves'][0].get('tablature', False)
header_gap = 14 if guitar else 24
@@ -183,31 +201,29 @@ def paginate(systems: list[generator.engraving.types.System], document: dict, fo
row_height = (style.height - style.margin_y - 24 - top - style.system_gap) / 2
if row_height <= 0:
raise generator.engraving.types.EngravingError('page header leaves no room for two music systems')
pair = systems[first:first + 2]
scale = min(1, row_height / max(s.box.bottom - s.box.top for s in pair))
pair = fit_page_systems(systems[first:first + 2], index, row_height, fonts, style)
placed = []
for row, system in enumerate(pair):
system = system.scaled(scale)
dx, dy = style.margin_x + 1, top + row * (row_height + style.system_gap) - system.box.top
placed.append(dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy)))
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True)
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
return tuple(pages)
def layout_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> tuple[generator.engraving.types.System, ...]:
generator.model.validate_document(document)
index = generator.engraving.notation.ScoreIndex(document)
def layout_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts, *, index=None) -> tuple[generator.engraving.types.System, ...]:
if index is None:
generator.model.validate_document(document)
index = generator.engraving.notation.ScoreIndex(document)
if not index.measures:
raise generator.engraving.types.EngravingError('score has no measures')
systems = []
start = fractions.Fraction()
available = style.width - style.margin_x * 2
available = style.width - style.margin_x * 2 - 2
while start < index.end:
columns = collections.OrderedDict()
candidates = index.boundary_times[bisect.bisect_right(index.boundary_times, start):]
selected = None
# Reserve the same title/credit and footer bands when considering a split,
# so a wide pitch range can break vertically as well as horizontally.
# Reserve title/credit and footer bands before fitting complete measures.
height_limit = style.height - style.margin_y * 2 - 110
for end in candidates:
trial = make_system(index, start, end, fonts, style, columns=columns)
@@ -215,22 +231,17 @@ def layout_systems(document: dict, style: generator.engraving.types.Style, fonts
break
selected = trial
if selected is None:
measure_end = candidates[0]
for end in index.column_times[bisect.bisect_right(index.column_times, start):bisect.bisect_left(index.column_times, measure_end)]:
trial = make_system(index, start, end, fonts, style, columns=columns)
if trial.box.right - trial.box.left <= available and trial.box.bottom - trial.box.top <= height_limit:
selected = trial
else:
break
if selected is None:
raise generator.engraving.types.EngravingError(f'at musical offset {start}: irreducible simultaneous content exceeds the page width or height')
systems.append(make_system(index, start, selected.end, fonts, style, justify=True, columns=columns) if selected.end < index.end else selected)
measure = index.measure_at(start)[2]
raise generator.engraving.types.EngravingError(f'measure {measure["number"]} ({measure["id"]}): complete measure exceeds the page width or height at readable spacing')
systems.append(selected)
start = selected.end
return tuple(systems)
def layout_score(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> generator.engraving.types.LayoutDocument:
generator.model.validate_document(document)
style = generator.engraving.types.score_style(document, style)
systems = layout_systems(document, style, fonts)
layout = generator.engraving.types.LayoutDocument(paginate(systems, document, fonts, style), style, fonts.fingerprint(style))
index = generator.engraving.notation.ScoreIndex(document)
systems = layout_systems(document, style, fonts, index=index)
layout = generator.engraving.types.LayoutDocument(paginate(systems, document, fonts, style, index=index), style, fonts.fingerprint(style))
generator.engraving.audit.audit_layout(document, layout, fonts)
return layout
+1
View File
@@ -60,6 +60,7 @@ class ScoreIndex:
time = end
self.end = time
self.measure_starts = [item[0] for item in self.measures]
self.measure_starts_by_id = {measure['id']: start for start, _, measure in self.measures}
self.events.sort(key=lambda e: (e.start, not bool(e.value.get('grace')), e.lane))
self.event_starts = []
self.event_max_ends = []
+6 -2
View File
@@ -17,6 +17,7 @@ import generator.engraving.audit
import generator.engraving.cache
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.notation
import generator.engraving.raster
import generator.engraving.types
import generator.engraving.validation
@@ -111,6 +112,7 @@ def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, prog
family = generator.engraving.fonts.Fonts(font_path)
style = generator.engraving.types.Style()
systems = None
index = None
scores = {}
audited = set()
result = GroupResult()
@@ -132,10 +134,12 @@ def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, prog
else:
if systems is None:
started = time.monotonic()
systems = generator.engraving.layout.layout_systems(document, style, family)
generator.model.validate_document(document)
index = generator.engraving.notation.ScoreIndex(document)
systems = generator.engraving.layout.layout_systems(document, style, family, index=index)
result.timings['layout'] += time.monotonic() - started
started = time.monotonic()
pages_layout = generator.engraving.layout.paginate(systems, document, family, style)
pages_layout = generator.engraving.layout.paginate(systems, document, family, style, index=index)
placement = tuple((page.width, page.height, tuple((system.start, system.end, system.box) for system in page.systems)) for page in pages_layout)
if placement not in audited:
layout = generator.engraving.types.LayoutDocument(pages_layout, style, fingerprint)
+1 -1
View File
@@ -17,7 +17,7 @@ def beam_groups(index: generator.engraving.notation.ScoreIndex) -> tuple[tuple[s
for event in (e for e in index.events if e.voice == voice):
note = event.value
primary = next((b['type'] for b in note.get('beams', []) if b['level'] == 1), None)
measure_start = next(start for start, _, measure in index.measures if measure['id'] == event.measure['id'])
measure_start = index.measure_starts_by_id[event.measure['id']]
beats = generator.model.beat_lengths(index.state(event.start)['meter'])
offset = event.start - measure_start
beat = 0
+17 -13
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import generator.engraving.notes
import generator.engraving.types
import generator.model
@@ -40,19 +39,24 @@ def staff(index, items, start, end, left, right, fonts, style):
for bar in (p for p in notation if p.role == 'barline'):
drawing.append(generator.engraving.types.line('tab-barline', bar.sources, tuple((x, offset + y * 5 * style.tab_space / (4 * style.space)) for x, y in bar.points), bar.thickness))
by_source = {source: p for p in numbers if p.role == 'tab' for source in p.sources[1:]}
for identifier, (event, tone) in index.tones.items():
target = tone.get('tie_to')
finish = event.start + generator.model.fraction(tone['duration'])
if not target and not (event.start < start < finish or event.start < end < finish):
continue
if target:
finish = index.tones[target][0].start
if finish < start or event.start >= end:
continue
first, last = by_source.get(identifier), by_source.get(target)
x1 = first.box.right + 2 if first else left
x2 = last.box.left - 2 if last else right
def tie(identifier, tone, x1, x2):
y = offset - (6 - tone['tablature']['string']) * style.tab_space + 6
if x2 > x1:
drawing.append(generator.engraving.types.curve('tab-tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y + 5), (x2 - (x2 - x1) / 3, y + 5), (x2, y))))
for identifier, (event, tone) in index.tones.items():
first = by_source.get(identifier)
finish = event.start + generator.model.fraction(tone['duration'])
if first and event.start < start < finish:
tie(identifier, tone, left, first.box.left - 2)
if first and finish > end:
tie(identifier, tone, first.box.right + 2, right)
elif target := tone.get('tie_to'):
destination = index.tones[target][0].start
if event.start >= end or destination < start:
continue
last = by_source.get(target)
if first or last:
tie(identifier, tone, first.box.right + 2 if first else left, last.box.left - 2 if last else right)
return notation + drawing
+59 -43
View File
@@ -13,7 +13,7 @@ import generator.model
import generator.pitch
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
MIN_FRET = 0
MIN_FRET = 1
MAX_FRET = 20
PREFERRED_MAX_FRET = 12
MAX_FRET_SPAN = 4
@@ -30,8 +30,8 @@ class VoicingCost(typing.NamedTuple):
string_shortfall: int = 0
covered_melody: int = 0
covering_tones: int = 0
missing_root: int = 0
effort: int = 0
simplicity: int = 0
missing_tones: int = 0
def plus(self, other: VoicingCost) -> VoicingCost:
return VoicingCost(*(a + b for a, b in zip(self, other)))
@@ -40,17 +40,18 @@ class VoicingCost(typing.NamedTuple):
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:
def voicing_cost(positions: tuple[TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), 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(0, max(frets, default=0) - PREFERRED_MAX_FRET) + 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))
effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET)
if profile in ('acoustic', 'electric'):
effort += max(frets, default=0)
simplicity = 3 * span + 2 * max(0, len(frets) - 1) if profile == 'acoustic' else 0
if profile != 'acoustic':
effort += span
return VoicingCost(max(0, 4 - len(positions)) if profile == 'electric' else 0, int(bool(above)), above, effort, simplicity, len(set(allowed) - classes))
def within_fret_span(positions) -> bool:
frets = [position.fret for position in positions]
@@ -70,15 +71,27 @@ 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, profile: str = '') -> tuple[tuple[int, ...], tuple[int, ...], int | None]:
@dataclasses.dataclass(frozen=True)
class ChordRequirements:
required: tuple[int, ...] = ()
allowed: tuple[int, ...] = ()
foundation: tuple[int, ...] = ()
basses: tuple[int, ...] = ()
def covers(self, classes: set[int]) -> bool:
return set(self.required) <= classes and bool(set(self.foundation) & classes)
def chord_requirements(definition: dict, profile: str = '') -> ChordRequirements:
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))}
optional = {d for d, a in degrees.items() if not a and d not in explicit and 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 and not (profile == 'electric' and d == 1)}
required = {value for d, value in classes.items() if d not in optional and (d not in (1, 5) or degrees[d])}
allowed = set(classes.values())
foundation = {value for d, value in classes.items() if d in (1, 5)}
if not foundation:
raise ValueError('generated guitar chords require a root or fifth in the harmony')
bass = definition.get('bass')
bass_pc = (generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12 if bass else None
if bass_pc is None and 'inversion' in definition:
@@ -92,20 +105,19 @@ def chord_requirements(definition: dict, profile: str = '') -> tuple[tuple[int,
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
basses = {bass_pc} if bass_pc is not None else foundation if profile == 'acoustic' else set()
return ChordRequirements(tuple(sorted(required)), tuple(sorted(allowed)), tuple(sorted(foundation)), tuple(sorted(basses)))
@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, ...] = (), root: int | None = None) -> tuple[tuple[TabPosition, ...], ...]:
def chord_positions(requirements: ChordRequirements, 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 or open_midi + fret in melody) 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 requirements.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:
if len(set(requirements.required) - covered) > len(options) - index:
return
if index == len(options):
if selected and set(required) <= covered and (bass is None or min(selected, key=lambda p: p.midi).midi % 12 == bass):
if selected and requirements.covers(covered) and (not requirements.basses or min(selected, key=lambda p: p.midi).midi % 12 in requirements.basses):
result.append(tuple(selected))
return
visit(index + 1, selected, covered)
@@ -117,21 +129,21 @@ def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: i
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}:
if not within_fret_span(selected) or not requirements.covers({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)):
if requirements.basses and (selected[0].midi % 12 not in requirements.basses or selected[0].midi != min(p.midi for p in selected)):
continue
result.append(selected)
else:
visit(0, (), set())
if not prune:
if not prune or profile == 'acoustic':
return tuple(sorted(result))
# Independent attacks with equal centers have identical movement costs.
# Held strings require the full candidate set instead of this reduction.
best = {}
for positions in result:
center = position_center(positions)
rank = (voicing_cost(positions, profile, allowed, root, melody), positions)
rank = (voicing_cost(positions, profile, requirements.allowed, 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()))
@@ -157,6 +169,14 @@ def movement(previous: tuple[TabPosition, ...], current: tuple[TabPosition, ...]
return 0
return sum(abs(a - b) for a, b in zip(position_center(previous), position_center(current)))
@functools.lru_cache(maxsize=16384)
def acoustic_movement(previous: tuple[TabPosition, ...], current: tuple[TabPosition, ...]) -> int:
if not previous or not current:
return 0
before = {p.string_number: p.fret for p in previous}
after = {p.string_number: p.fret for p in current}
return sum(abs(before[s] - after[s]) for s in before.keys() & after.keys()) + len(before.keys() ^ after.keys())
@functools.lru_cache(maxsize=4096)
def position_center(positions: tuple[TabPosition, ...]) -> tuple[int, int]:
frets = [p.fret for p in positions] or [0]
@@ -236,16 +256,11 @@ class EventPlacement:
added: tuple[TabPosition, ...] = ()
def acoustic_candidates(span: generator.acoustic.HarmonySpan):
required, allowed, bass = chord_requirements(span.harmony, 'acoustic')
basses = {bass}
degrees = generator.harmony.degrees(span.harmony)
if 'bass' not in span.harmony and 'inversion' not in span.harmony and 5 in degrees:
basses.add((bass + generator.harmony.degree_semitones(5, degrees[5])) % 12)
shapes = sorted({shape for value in sorted(basses) for shape in chord_positions(required, allowed, value, profile='acoustic')})
for shape in shapes:
requirements = chord_requirements(span.harmony, 'acoustic')
for shape in chord_positions(requirements, 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))
yield AttackCandidate(active, choices, shape, voicing_cost(shape, 'acoustic', requirements.allowed))
def continued_sources(notes):
result = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p}
@@ -270,16 +285,10 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
pending.append((tone['id'], options))
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
requirements = chord_requirements(chord['_harmony'], profile) if chord else ChordRequirements()
melody = tuple(sorted({p.midi for _, options in pending for p in options})) if profile == 'electric' else ()
fresh = has_attack(notes)
candidates = chord_positions(*requirements, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody, root=root) if chord else [()]
candidates = chord_positions(requirements, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody) 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:
@@ -291,7 +300,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
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):
if any(p.midi % 12 not in requirements.allowed for p in added):
compatible = False
break
if '_continue_from' in note:
@@ -303,7 +312,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
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()
cost = voicing_cost(positions, profile, requirements.allowed, melody) if fresh else VoicingCost()
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost)
else:
shape = tuple(p for _, p in exact) + positions
@@ -311,7 +320,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
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()
cost = voicing_cost(shape, profile, requirements.allowed, melody) if fresh else VoicingCost()
yield AttackCandidate(assignment, choices, shape, cost)
def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
@@ -373,6 +382,13 @@ def advance_attacks(candidates, states: dict) -> dict:
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
return result
def advance_acoustic(candidates, states: dict) -> dict:
result = {}
for candidate in candidates:
cost, _, previous = min((p.cost.moved(acoustic_movement(tuple(position for _, position in p.active), candidate.shape)), i, p) for i, p in enumerate(states.values()))
result[candidate.active] = Placement(cost.plus(candidate.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)
@@ -405,7 +421,7 @@ def place_guitar(document: dict) -> None:
states = {(): Placement(VoicingCost(), (), None, ())}
if document['staves'][0].get('arrangement_part_type') == 'acoustic':
for span in generator.acoustic.harmony_spans(document):
states = advance_attacks(acoustic_candidates(span), states)
states = advance_acoustic(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: