93 lines
4.0 KiB
Python
93 lines
4.0 KiB
Python
from __future__ import annotations
|
|
|
|
import fractions
|
|
import pathlib
|
|
|
|
import generator.fretboard
|
|
import generator.model
|
|
import generator.pitch
|
|
import generator.score
|
|
|
|
PART_TYPES = ('acoustic', 'electric', 'vocal')
|
|
|
|
def source_paths(directory: pathlib.Path) -> list[pathlib.Path]:
|
|
if not directory.is_dir():
|
|
raise ValueError(f'performance source directory not found: {directory}')
|
|
paths = sorted(directory.rglob('*.xml'))
|
|
identities = {}
|
|
for path in paths:
|
|
if path.parent != directory:
|
|
raise ValueError(f'{path}: performance sources belong directly under data/songs')
|
|
identity = generator.score.slugify(path.stem)
|
|
if identity in identities:
|
|
raise ValueError(f'{identities[identity]} and {path}: conflicting output path {identity}_acoustic.json')
|
|
identities[identity] = path
|
|
return paths
|
|
|
|
def prepare_document(document: dict) -> dict:
|
|
validate_source(document)
|
|
return generator.pitch.concert_document(document, octave_transposing=True)
|
|
|
|
def generated_voice(measure: dict, role: str) -> list[dict]:
|
|
length = generator.model.fraction(measure['duration'])
|
|
if role == 'rest':
|
|
return [generator.model.make_event('rest', fractions.Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'measure_rest': True}]
|
|
if role != 'slash':
|
|
raise ValueError(f'{role} requires a preplanned rhythm')
|
|
events = []
|
|
states = [(fractions.Fraction(), measure['meter'])]
|
|
states.extend((generator.model.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 = generator.model.beat_lengths(meter)
|
|
beat_index = 0
|
|
while onset < end:
|
|
duration = min(beats[beat_index % len(beats)] if beats else end - onset, end - onset)
|
|
event = generator.model.make_event(f'slash_{len(events)}', onset, duration, 'note')
|
|
event['notehead'] = 'slash'
|
|
events.append(event)
|
|
onset += duration
|
|
beat_index += 1
|
|
return events
|
|
|
|
def validate_source(document: dict) -> None:
|
|
for measure in generator.score.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:
|
|
generator.fretboard.validate_tuning(state, measure['id'])
|
|
for voice in measure['voices']:
|
|
for note in voice['notes']:
|
|
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')
|
|
|
|
def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '') -> dict:
|
|
voices = generator.score.copy_source_voices(source, strip_lyrics=role == 'melody') if role in ('vocal', 'melody') else None
|
|
if voices:
|
|
notes = [note for voice in voices for note in voice['notes']]
|
|
else:
|
|
notes = generated_voice(source, role)
|
|
if role in ('melody', 'vocal'):
|
|
for note in notes:
|
|
octaves = 2 if role == 'melody' else 1
|
|
note['pitches'] = [generator.pitch.shift_pitch(p, 7 * octaves, 12 * octaves) for p in note['pitches']]
|
|
if role == 'melody':
|
|
note['_melody'] = True
|
|
result = generator.score.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
|