from __future__ import annotations import fractions import pathlib import generator.accompaniment import generator.fretboard import generator.model import generator.pitch import generator.rhythm 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 = '', rhythm: generator.rhythm.Bar | tuple[generator.rhythm.Bar, generator.rhythm.Bar] | None = None) -> dict: voices = generator.score.copy_source_voices(source, strip_lyrics=role == 'melody') if role in ('vocal', 'melody') else None if role == 'accompaniment' and part == 'acoustic': voices = [{'id': lane, 'notes': generator.accompaniment.realize(source, bar, previous, lane=lane)} for lane, bar in zip(('upper', 'bass'), (rhythm[1], rhythm[0]))] for voice in voices: for note in voice['notes']: note.update(voice=voice['id'], stem='down' if voice['id'] == 'bass' else 'up') if voices: notes = [note for voice in voices for note in voice['notes']] elif rhythm is not None: notes = generator.accompaniment.realize(source, rhythm, previous, lane='electric') 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