Tab Generation
ci / checks (push) Successful in 1h19m14s

This commit is contained in:
khodowany
2026-09-20 16:20:43 -05:00
parent 68ded4d8c8
commit cf7c0c010c
423 changed files with 1422 additions and 1332 deletions
+263
View File
@@ -1,8 +1,14 @@
from __future__ import annotations
from fractions import Fraction
from dataclasses import dataclass
from functools import lru_cache
from itertools import groupby
from typing import NamedTuple
from generator import harmony, pitch
from generator.model import NATURAL, alteration, fraction, make_event, pitch_number
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
MIN_FRET = 0
MAX_FRET = 20
@@ -22,3 +28,260 @@ def positions_for_pitch(midi: 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']: pitch_number(t) for t in tunings} != expected):
raise ValueError(f'{location}: guitar generation requires standard six-string tuning')
def chord_requirements(definition: dict) -> tuple[tuple[int, ...], tuple[int, ...], int | None]:
degrees = harmony.degrees(definition)
root = definition['root']
root_pc = NATURAL[root['letter']] + 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 + 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}
allowed = set(classes.values())
bass = definition.get('bass')
bass_pc = (NATURAL[bass['letter']] + 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 bass_pc is not None:
allowed.add(bass_pc)
required.add(bass_pc)
return tuple(sorted(required)), tuple(sorted(allowed)), bass_pc
@lru_cache(maxsize=256)
def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True) -> 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) 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})
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 = (compactness(positions), len(set(allowed) - {p.midi % 12 for p in positions}), 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 harmony.degrees(definition).items():
value = pitch.shift_pitch(definition['root'], degree - 1, harmony.degree_semitones(degree, alter))
spellings[(NATURAL[value['letter']] + alteration(value['alter'])) % 12] = value
if bass := definition.get('bass'):
spellings[(NATURAL[bass['letter']] + 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 - NATURAL[value['letter']] - alteration(value['alter'])) // 12 - 1)
value['tablature'] = {'string': position.string_number, 'fret': position.fret}
result.append(value)
return result
@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)))
@lru_cache(maxsize=4096)
def position_center(positions: tuple[TabPosition, ...]) -> tuple[int, int]:
frets = [p.fret for p in positions if p.fret] 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 if p.fret]
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:])
@dataclass
class Placement:
cost: tuple[int, int, int]
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)]
for index, previous in enumerate(states.values()):
if not previous.active:
return [[(previous.cost, index, previous) for _ in row] for row in grid]
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[0] + 1, cost[1], cost[2]), 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
@dataclass(frozen=True)
class GuitarTone:
pitch: dict
end: Fraction
positions: tuple[TabPosition, ...]
@dataclass(frozen=True)
class AttackCandidate:
active: tuple
choices: tuple
shape: tuple[TabPosition, ...]
missing: int
def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: dict, held: dict, preserve_strings: bool):
pending = []
chords = [n for n in notes if '_harmony' in n]
if len(chords) > 1:
raise ValueError('generated accompaniment requires one harmonic event per onset')
chord = chords[0] if chords else None
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))
requirements = chord_requirements(chord['_harmony']) if chord else ((), (), None)
candidates = chord_positions(*requirements, prune=not (preserve_strings or held or pending)) if chord else [()]
for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))):
for positions in candidates:
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
exact_positions = dict(exact)
choices = tuple((n, positions if n is chord else tuple((p, exact_positions[p['id']]) for p in n['pitches'])) for n in notes)
yield AttackCandidate(assignment, choices, shape, len(set(requirements[1]) - {p.midi % 12 for p in positions}))
def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
events, tones = [], {}
absolute = Fraction()
for measure in document['staves'][0]['measures']:
changes = sorted(measure.get('attributes', []), key=lambda a: 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']):
onset = fraction(note['onset'])
state = {k: measure[k] for k in ('transpose', 'staff_details')}
for change in changes:
if fraction(change['onset']) <= onset:
state.update({k: change[k] for k in state if k in change})
for tone in note['pitches']:
sounding = pitch.sounding_pitch(tone, state)
options = positions_for_pitch(sounding)
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 + fraction(tone['duration']), options)
if '_harmony' in note:
for string, _ in STANDARD_TUNING:
tones[f'{note["id"]}-s{string}'] = GuitarTone({}, absolute + onset + 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 += fraction(measure['duration'])
return sorted(events, key=lambda e: e[:3]), tones
def retain_tone(tone: GuitarTone, onset: Fraction) -> bool:
return tone.end > onset or tone.end == onset and bool(tone.pitch.get('tie_to'))
def advance_positions(notes: list[dict], onset: Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool) -> dict:
continued = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p}
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)
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:
nearest = nearest_positions(states)
for candidate in attack_candidates(notes, tones, {}, {}, preserve_strings):
x, y = position_center(candidate.shape)
cost, _, previous = nearest[x][y]
keep(candidate, previous, cost)
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):
distance = movement(tuple(p for _, p in previous.active), candidate.shape)
keep(candidate, previous, (previous.cost[0] + distance, previous.cost[1], previous.cost[2]))
if not result:
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 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 groupby(events, key=lambda e: e[:3])]
states = {(): Placement((0, 0, 0), (), 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 + 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))
while selected.previous is not None:
for note, choices in selected.choices:
if definition := note.pop('_harmony', None):
note['pitches'] = make_event(note['id'], fraction(note['onset']), fraction(note['duration']), 'note', chord_pitches(definition, choices))['pitches']
else:
for tone, position in choices:
tone['tablature'] = {'string': position.string_number, 'fret': position.fret}
selected = selected.previous
document['staves'][0]['tablature'] = True