from __future__ import annotations from fractions import Fraction from pathlib import Path from generator.guitar import validate_tuning from generator.model import beat_lengths, fraction, make_event from generator.pitch import concert_document, shift_pitch from generator.score import copy_source_voices, make_measure, slugify, source_staff PART_TYPES = ('acoustic', 'electric', 'vocal') def source_paths(directory: Path, *, practice: bool = False) -> list[Path]: if not directory.is_dir(): raise ValueError(f'{"practice" if practice else "performance"} source directory not found: {directory}') paths = sorted(directory.rglob('*.xml')) identities = {} for path in paths: if practice: if path.parent != directory 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') elif path.parent != directory: raise ValueError(f'{path}: performance sources belong directly under data/songs') identity = path.stem if practice else 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, *, practice: bool = False) -> dict: validate_source(document, practice=practice) return concert_document(document, octave_transposing=True) 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': boundaries = sorted({Fraction(), length} | {fraction(h['onset']) for h in measure['harmonies'] if fraction(h['onset']) < length}) attacks = [(start, end - start) for start, end in zip(boundaries, boundaries[1:])] 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) if role == 'slash': event = make_event(f'slash-{len(events)}', onset, duration, 'note') event['notehead'] = 'slash' elif not chord: 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