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
+31 -54
View File
@@ -2,68 +2,45 @@ from __future__ import annotations
from pathlib import Path
from generator.score import copy_source_voices, make_arranged_document, make_measure, reidentify, slugify, source_staff, write_json
from generator.guitar import place_guitar
from generator.musicxml import load_musicxml
from generator.practice.guitar import transpose_guitar_practice
from generator.transposition import TARGET_KEYS
from generator.parts import PART_TYPES, build_measure, propagated_chords, validate_source
from generator.pitch import concert_document
from generator.scat import add_scat
from generator.score import make_arranged_document, reidentify, source_staff, write_json
def practice_document(document: dict) -> dict:
def practice_document(document: dict, part_type: str) -> dict:
if part_type not in PART_TYPES:
raise ValueError(f'unknown practice part: {part_type}')
validate_source(document, practice=True)
document = concert_document(document, octave_transposing=True)
staff = source_staff(document)
source_measures = staff.get("measures")
if not isinstance(source_measures, list):
raise ValueError("practice source must contain measures")
measures: list[dict] = []
for index, measure in enumerate(source_measure for source_measure in source_measures if isinstance(source_measure, dict)):
voices = copy_source_voices(measure, strip_lyrics=True)
output_measure = make_measure(
f"measure-{index + 1}",
measure,
[],
section_label=str(measure.get("section_label", "")),
)
output_measure['voices'] = voices
for chord in output_measure['harmonies']:
previous = propagated_chords(staff['measures'])
role = {'acoustic': 'accompaniment', 'electric': 'melody', 'vocal': 'vocal'}[part_type]
measures = [build_measure(m, f'measure-{i + 1}', role, part_type, previous[i], m.get('section_label', '')) for i, m in enumerate(staff['measures'])]
for measure in measures:
for chord in measure['harmonies']:
chord.pop('chord_optional', None)
measures.append(output_measure)
tuplets = reidentify(measures, staff.get('tuplets', []))
result = make_arranged_document(
document,
staff,
staff_id="staff-arranged-practice",
staff_name="Practice",
role="guitar",
arrangement_part_type="practice",
measures=measures,
tuplets=tuplets,
octave_shifts=staff.get('octave_shifts', []),
)
result['metadata'].setdefault('authored_key_signature', document['metadata']['key_signature'])
result = make_arranged_document(document, staff, staff_id='staff-practice-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=measures, tuplets=tuplets, octave_shifts=staff.get('octave_shifts', []) if role in ('melody', 'vocal') else [])
if part_type == 'vocal':
add_scat(result)
else:
place_guitar(result)
return result
def key_file_name(key: str) -> str:
return slugify(key.replace("#", "sharp")) + ".json"
def generate_key_variants(source_path: Path, output_dir: Path) -> int:
base_document = practice_document(load_musicxml(source_path))
count = 0
for key in TARGET_KEYS:
try:
variant = transpose_guitar_practice(base_document, key)
except ValueError as error:
raise ValueError(f'{source_path}, target key {key}: {error}') from error
write_json(output_dir / source_path.stem / key_file_name(key), variant)
count += 1
return count
def generate_practice_scores(source_dir: Path, output_dir: Path) -> int:
if not source_dir.is_dir():
raise ValueError(f'practice source directory not found: {source_dir}')
sources = sorted(source_dir.glob('*.xml'))
for path in source_dir.rglob('*.xml'):
parts = path.relative_to(source_dir).parts
if len(parts) != 1 and (len(parts) != 2 or parts[0] != 'songs'):
raise ValueError(f'{path}: practice sources belong directly under the data directory')
sources = sorted(source_dir.rglob('*.xml'))
for path in sources:
if len(path.stem) != 3 or not path.stem.isascii() or not path.stem.isdecimal() or int(path.stem) == 0:
raise ValueError(f'{path}: practice sources require a three-digit positive filename such as 001.xml')
return sum(generate_key_variants(path, output_dir) for path in sources)
if path.parent != source_dir or len(path.stem) != 3 or not path.stem.isascii() or not path.stem.isdecimal():
raise ValueError(f'{path}: practice sources require a three-digit filename directly under data/practice')
for path in sources:
document = load_musicxml(path)
for part in PART_TYPES:
try:
write_json(output_dir / path.stem / (part + '.json'), practice_document(document, part))
except ValueError as error:
raise ValueError(f'{path}, part {part}: {error}') from error
return len(sources) * len(PART_TYPES)
-137
View File
@@ -1,137 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from fractions import Fraction
from itertools import groupby
from math import ceil, floor
from generator.guitar import MAX_FRET, MIN_FRET, STANDARD_TUNING, TabPosition, WRITTEN_GUITAR_OCTAVE_OFFSET, positions_for_pitch, within_fret_span
from generator.model import fraction, pitch_number
from generator.transposition import transpose_document, transposition_interval
@dataclass(frozen=True)
class GuitarTone:
pitch: dict
onset: Fraction
end: Fraction
midi: Fraction
location: str
grace_order: int
def validate_staff_details(state: dict, location: str) -> None:
details = state['staff_details']
if details.get('capo', 0):
raise ValueError(f'{location}: guitar practice does not support capo configurations')
if state['clef']['sign'] == 'TAB' and details.get('lines') != 6:
raise ValueError(f'{location}: guitar practice requires six-string tablature')
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 practice requires standard six-string tuning')
def sounding_pitch(pitch: dict, state: dict) -> Fraction:
if 'unpitched' in pitch:
raise ValueError('unpitched notes cannot be checked on a fretted guitar')
transpose, clef = state['transpose'], state['clef']
offset = transpose['chromatic'] + 12 * transpose['octave_change']
if not any(transpose.values()) and clef['sign'] == 'G' and not clef['octave_change'] and 'tablature' not in pitch:
offset = -WRITTEN_GUITAR_OCTAVE_OFFSET
return pitch_number(pitch) + offset
def guitar_tones(document: dict) -> list[GuitarTone]:
tones = []
absolute = Fraction()
for measure in document['staves'][0]['measures']:
location = f'measure {measure.get("number", measure["id"])}'
state = {key: measure[key] for key in ('clef', 'transpose', 'staff_details')}
validate_staff_details(state, location)
changes = sorted(measure.get('attributes', []), key=lambda change: fraction(change['onset']))
for change in changes:
state.update({key: change[key] for key in state if key in change})
validate_staff_details(state, location)
for voice in measure['voices']:
state = {key: measure[key] for key in ('clef', 'transpose', 'staff_details')}
change_index = 0
for index, note in enumerate(voice['notes']):
onset = fraction(note['onset'])
while change_index < len(changes) and fraction(changes[change_index]['onset']) <= onset:
state.update({key: changes[change_index][key] for key in state if key in changes[change_index]})
change_index += 1
context = f'{location}, voice {voice["id"]}, event {note["id"]}, onset {onset}'
for pitch in note['pitches']:
try:
midi = sounding_pitch(pitch, state)
except ValueError as error:
raise ValueError(f'{context}: {error}') from error
tones.append(GuitarTone(pitch, absolute + onset, absolute + onset + fraction(pitch['duration']), midi, context, index if note.get('grace') else -1))
absolute += fraction(measure['duration'])
return tones
def tone_positions(tone: GuitarTone) -> tuple[TabPosition, ...]:
positions = positions_for_pitch(tone.midi)
if tab := tone.pitch.get('tablature'):
positions = tuple(p for p in positions if p.string_number == tab['string'] and p.fret == tab['fret'])
if not positions:
raise ValueError(f'{tone.location}: pitch {tone.midi} has no matching string/fret in {MIN_FRET}-{MAX_FRET}')
return positions
def extend_fingerings(held: dict[str, TabPosition], pending: list[tuple[str, tuple[TabPosition, ...]]]):
if not pending:
yield tuple(sorted(held.items()))
return
identifier, options = pending[0]
occupied = {position.string_number for position in held.values()}
for position in options:
if position.string_number not in occupied and within_fret_span((*held.values(), position)):
yield from extend_fingerings({**held, identifier: position}, pending[1:])
def validate_guitar_practice(document: dict) -> None:
tones = guitar_tones(document)
by_id = {tone.pitch['id']: tone for tone in tones}
options = {tone.pitch['id']: tone_positions(tone) for tone in tones}
states = {()}
# Keep every feasible assignment, so later entries cannot move a held or tied tone.
ordered = sorted(tones, key=lambda tone: (tone.onset, tone.grace_order < 0, tone.grace_order))
for (onset, _, _), group in groupby(ordered, key=lambda tone: (tone.onset, tone.grace_order < 0, tone.grace_order)):
attacks = list(group)
continued = {tone.pitch['tie_from'] for tone in attacks if 'tie_from' in tone.pitch}
next_states = set()
visited = set()
for previous in states:
previous = dict(previous)
held = {identifier: position for identifier, position in previous.items() if identifier not in continued and (by_id[identifier].end > onset or by_id[identifier].end == onset and by_id[identifier].pitch.get('tie_to'))}
pending = []
for tone in attacks:
positions = options[tone.pitch['id']]
if source := tone.pitch.get('tie_from'):
positions = tuple(p for p in positions if p == previous.get(source))
pending.append((tone.pitch['id'], positions))
pending.sort(key=lambda item: len(item[1]))
transition = (tuple(sorted(held.items())), tuple(pending))
if transition not in visited:
next_states.update(extend_fingerings(held, pending))
visited.add(transition)
if not next_states:
raise ValueError(f'{attacks[0].location}: no simultaneous fingering within frets {MIN_FRET}-{MAX_FRET} and the four-fret span; held/tied strings must remain fixed')
states = next_states
def transpose_guitar_practice(document: dict, target_key: str) -> dict:
_, semitones = transposition_interval(document, target_key)
tones = guitar_tones(document)
if not tones:
return transpose_document(document, target_key)
low = min(midi + MIN_FRET for _, midi in STANDARD_TUNING)
high = max(midi + MAX_FRET for _, midi in STANDARD_TUNING)
first = ceil((low - min(tone.midi for tone in tones) - semitones) / 12)
last = floor((high - max(tone.midi for tone in tones) - semitones) / 12)
candidates = sorted(range(first, last + 1), key=lambda octave: (abs(semitones + 12 * octave), semitones + 12 * octave < 0))
failures = []
for octave in candidates:
try:
result = transpose_document(document, target_key, octave_shift=octave)
validate_guitar_practice(result)
return result
except ValueError as error:
failures.append(f'octave {octave:+}: {error}')
detail = '; '.join(failures) if failures else f'{min(tones, key=lambda tone: tone.midi).location} through {max(tones, key=lambda tone: tone.midi).location}: pitch range exceeds the guitar range in every octave'
raise ValueError(f'no whole-exercise guitar register for {target_key}: {detail}')