from __future__ import annotations import dataclasses import fractions import functools import itertools import logging import typing import generator.acoustic import generator.harmony import generator.model import generator.pitch STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64)) MIN_FRET = 0 MAX_FRET = 20 PREFERRED_MAX_FRET = 12 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 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(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)) def within_fret_span(positions) -> bool: frets = [position.fret for position in positions] return not frets or max(frets) - min(frets) <= MAX_FRET_SPAN def positions_for_pitch(midi: fractions.Fraction) -> tuple[TabPosition, ...]: if midi.denominator != 1: return () return tuple(TabPosition(string, int(midi - open_midi), int(midi)) for string, open_midi in STANDARD_TUNING if MIN_FRET <= midi - open_midi <= MAX_FRET) def validate_tuning(state: dict, location: str) -> None: details = state['staff_details'] if details.get('capo', 0): raise ValueError(f'{location}: guitar generation does not support capo configurations') tunings = details.get('tunings', []) expected = {7 - string: midi for string, midi in STANDARD_TUNING} 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]: 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 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 if bass_pc is None and 'inversion' in definition: ordered = sorted(degrees) inversion = definition['inversion'] 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, 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] def visit(index, selected, covered): if len(set(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): result.append(tuple(selected)) return visit(index + 1, selected, covered) for position in options[index]: if position.midi % 12 not in covered and within_fret_span((*selected, position)): visit(index + 1, (*selected, position), covered | {position.midi % 12}) 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. # 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) if center not in best or rank < best[center][0]: best[center] = (rank, positions) return tuple(sorted(item[1] for item in best.values())) def chord_pitches(definition: dict, positions: tuple[TabPosition, ...]) -> list[dict]: spellings = {} for degree, alter in generator.harmony.degrees(definition).items(): value = generator.pitch.shift_pitch(definition['root'], degree - 1, generator.harmony.degree_semitones(degree, alter)) spellings[(generator.model.NATURAL[value['letter']] + generator.model.alteration(value['alter'])) % 12] = value if bass := definition.get('bass'): spellings[(generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12] = bass result = [] for position in sorted(positions, key=lambda p: p.midi): value = spellings[position.midi % 12].copy() value['octave'] = int((position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET - generator.model.NATURAL[value['letter']] - generator.model.alteration(value['alter'])) // 12 - 1) value['tablature'] = {'string': position.string_number, 'fret': position.fret} result.append(value) return result @functools.lru_cache(maxsize=16384) def movement(previous: tuple[TabPosition, ...], current: tuple[TabPosition, ...]) -> int: if not previous or not current: return 0 return sum(abs(a - b) for a, b in zip(position_center(previous), position_center(current))) @functools.lru_cache(maxsize=4096) def position_center(positions: tuple[TabPosition, ...]) -> tuple[int, int]: frets = [p.fret for p in positions] or [0] strings = [p.string_number for p in positions] return min(frets) + max(frets), min(strings) + max(strings) - 2 def compactness(positions) -> int: frets = [p.fret for p in positions] return max(frets) - min(frets) if frets else 0 def extend_positions(held, pending): if not pending: yield tuple(sorted(held.items())) return identifier, options = pending[0] occupied = {p.string_number for p in held.values()} for position in options: if position.string_number not in occupied and within_fret_span((*held.values(), position)): yield from extend_positions({**held, identifier: position}, pending[1:]) @dataclasses.dataclass class Placement: 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.""" 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: 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]: grid[x][y] = candidate def relax(x, y, a, b): source = grid[a][b] if source is not None: cost, index, previous = source candidate = (cost.moved(1), index, previous) if grid[x][y] is None or candidate[:2] < grid[x][y][:2]: grid[x][y] = candidate for y in range(11): for x in range(1, len(grid)): relax(x, y, x - 1, y) for x in range(len(grid) - 2, -1, -1): relax(x, y, x + 1, y) for x in range(len(grid)): for y in range(1, 11): relax(x, y, x, y - 1) for y in range(9, -1, -1): relax(x, y, x, y + 1) return grid @dataclasses.dataclass(frozen=True) class GuitarTone: pitch: dict end: fractions.Fraction positions: tuple[TabPosition, ...] @dataclasses.dataclass(frozen=True) class AttackCandidate: active: tuple choices: tuple shape: tuple[TabPosition, ...] cost: VoicingCost @dataclasses.dataclass(frozen=True) class EventPlacement: note: dict exact: tuple 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: 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) 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] chord = chords[0] if chords else None profile = chord.get('_guitar_profile', '') if chord else '' 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 if source := tone.get('tie_from'): options = tuple(p for p in options if p == active.get(source)) 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 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 [()] 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 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 = [], {} 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]: state.update({k: change[k] for k in state if k in change}) 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: if generator.model.fraction(change['onset']) <= onset: state.update({k: change[k] for k in state if k in change}) 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) if '_harmony' in note: for string, _ in STANDARD_TUNING: tones[f'{note["id"]}-s{string}'] = GuitarTone({}, absolute + onset + generator.model.fraction(note['duration']), ()) if note['pitches'] or '_harmony' in note: events.append((absolute + onset, not bool(note.get('grace')), order if note.get('grace') else -1, note)) absolute += generator.model.fraction(measure['duration']) return sorted(events, key=lambda e: e[:3]), tones 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) 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.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) if not held_ids and not continued: result = advance_attacks(attack_candidates(notes, tones, {}, {}, preserve_strings, fallback), states) else: 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, fallback): 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) 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 def place_guitar(document: dict) -> None: """Choose acoustic grips per harmony span, or exact/held melody placements.""" events, tones = guitar_events(document) 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) 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: 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): 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', '_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