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
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
from fractions import Fraction
from generator.guitar import validate_tuning
from generator.model import beat_lengths, fraction, make_event
from generator.pitch import shift_pitch
from generator.score import copy_source_voices, make_measure, measure_notes, source_staff
PART_TYPES = ('acoustic', 'electric', 'vocal')
def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> list[dict | None]:
result = []
current = None
for measure in measures:
result.append(current)
for h in measure.get('harmonies', []):
if include_optional or not h.get('chord_optional'):
current = h
return result
def active_chord(measure: dict, onset: Fraction, previous: dict | None) -> dict | None:
active = previous
for h in measure['harmonies']:
if fraction(h['onset']) > onset:
break
active = h
return active if active and active['kind'] != 'none' else None
def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dict]:
length = fraction(measure['duration'])
if role == 'rest':
return [make_event('rest', Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'measure_rest': True}]
common_time = measure['time_signature'] == '4/4' and not any('meter' in a for a in measure.get('attributes', []))
if role == 'rhythm' and common_time:
attacks = [(Fraction(n, 2), Fraction(1, 2)) for n in (0, 3, 4, 5, 7)]
elif role == 'accompaniment' and common_time:
attacks = [(Fraction(1, 2), Fraction(1, 2))]
else:
attacks = []
states = [(Fraction(), measure['meter'])]
states.extend((fraction(a['onset']), a['meter']) for a in measure.get('attributes', []) if 'meter' in a)
for index, (onset, meter) in enumerate(states):
end = states[index + 1][0] if index + 1 < len(states) else length
beats = beat_lengths(meter)
beat_index = 0
while onset < end:
beat = min(beats[beat_index % len(beats)] if beats else end - onset, end - onset)
duration = beat if role == 'slash' or not beats else min(beat / 2, Fraction(1, 2))
attacks.append((onset, duration))
onset += beat
beat_index += 1
events = []
cursor = Fraction()
for onset, duration in attacks:
if onset >= length:
break
if onset > cursor:
events.append(make_event(f'rest-{len(events)}', cursor, onset - cursor, 'rest'))
duration = min(duration, length - onset)
chord = active_chord(measure, onset, previous)
source_attack = any(fraction(n['onset']) == onset and n['kind'] == 'note' and not n.get('grace') and any(not p.get('tie_from') for p in n['pitches']) for n in measure_notes(measure))
if role == 'slash':
event = make_event(f'slash-{len(events)}', onset, duration, 'note')
event['notehead'] = 'slash'
elif not chord or role == 'accompaniment' and source_attack:
event = make_event(f'rest-{len(events)}', onset, duration, 'rest')
else:
event = make_event(f'chord-{len(events)}', onset, duration, 'note')
event['_harmony'] = chord
events.append(event)
cursor = onset + duration
if cursor < length:
events.append(make_event(f'rest-{len(events)}', cursor, length - cursor, 'rest'))
return events
def validate_source(document: dict, *, practice: bool = False) -> None:
voices = set()
for measure in source_staff(document)['measures']:
for state in [measure, *measure.get('attributes', [])]:
if state.get('clef', {}).get('sign') in ('TAB', 'percussion'):
raise ValueError(f'{measure["id"]}: parts require a pitched, non-TAB source')
if 'staff_details' in state:
validate_tuning(state, measure['id'])
for voice in measure['voices']:
voices.add(voice['id'])
for note in voice['notes']:
if practice and len(note['pitches']) > 1:
raise ValueError(f'{measure["id"]}: practice requires a monophonic melody, not chord stacks')
if any('tablature' in p or 'unpitched' in p for p in note['pitches']):
raise ValueError(f'{measure["id"]}: authored TAB and percussion are unsupported')
if practice and len(voices) > 1:
raise ValueError('practice requires one monophonic voice')
def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '') -> dict:
voices = copy_source_voices(source, strip_lyrics=role == 'melody') if role in ('vocal', 'melody') else None
notes = [n for v in voices for n in v['notes']] if voices else generated_voice(source, role, previous)
if role in ('melody', 'vocal'):
for note in notes:
note['pitches'] = [shift_pitch(p, 7, 12) for p in note['pitches']]
result = make_measure(identifier, source, [] if voices else notes, section_label=label)
if voices:
result['voices'] = voices
result['transpose'] = {'diatonic': 0, 'chromatic': 0, 'octave_change': -1, 'explicit': True}
result['clef']['octave_change'] = 0
for change in result.get('attributes', []):
change.pop('transpose', None)
if 'clef' in change:
change['clef']['octave_change'] = 0
if part != 'vocal':
result['clef'] = {'sign': 'G', 'line': 2, 'octave_change': 0}
result['staff_details'] = {'lines': 5, 'tunings': [], 'capo': 0}
for change in result.get('attributes', []):
change.pop('clef', None)
change.pop('staff_details', None)
return result