from __future__ import annotations import dataclasses import fractions import pathlib import types import generator.model import generator.musicxml @dataclasses.dataclass(frozen=True) class Event: onset: fractions.Fraction duration: fractions.Fraction sounding: bool written_type: str dots: int = 0 beams: tuple[tuple[int, str], ...] = () continuation: bool = False ratio: fractions.Fraction = fractions.Fraction(1) @dataclasses.dataclass(frozen=True) class Bar: events: tuple[Event, ...] length: fractions.Fraction def attacks(self) -> tuple[fractions.Fraction, ...]: return tuple(e.onset for e in self.events if e.sounding and not e.continuation) def beat(self, number: int) -> tuple: return tuple((max(e.onset, number) - number, min(e.onset + e.duration, number + 1) - max(e.onset, number), e.sounding, e.continuation or e.onset < number) for e in self.events if e.onset < number + 1 and e.onset + e.duration > number) @dataclasses.dataclass(frozen=True) class Snippet: id: str meter: str bars: tuple[Bar, ...] @dataclasses.dataclass(frozen=True, init=False) class Library: snippets: types.MappingProxyType def __init__(self, snippets: tuple[Snippet, ...]): grouped = {} identities = set() for snippet in sorted(snippets, key=lambda s: s.id): if snippet.id in identities: raise ValueError(f'duplicate rhythm identity: {snippet.id}') identities.add(snippet.id) grouped.setdefault(snippet.meter, []).append(snippet) object.__setattr__(self, 'snippets', types.MappingProxyType({meter: tuple(values) for meter, values in grouped.items()})) def for_meter(self, meter: str) -> tuple[Snippet, ...]: if meter not in ('2/4', '3/4', '4/4', '5/4') or meter not in self.snippets: raise ValueError(f'accompaniment requires rhythm snippets for {meter}; supported meters are 2/4, 3/4, 4/4, and 5/4') return self.snippets[meter] def read_snippet(path: pathlib.Path, identifier: str) -> Snippet: document = generator.musicxml.load_musicxml(path) staff = document['staves'][0] if len(staff['measures']) not in (1, 2): raise ValueError(f'{path}: rhythm snippets require one or two bars') meter = staff['measures'][0]['time_signature'] bars = [] for measure in staff['measures']: length = generator.model.fraction(measure['duration']) if measure['time_signature'] != meter or length != generator.model.meter_length(measure['meter']): raise ValueError(f'{path}, measure {measure["number"]}: rhythm bars must be complete and use one meter') if len(measure['voices']) != 1 or any('meter' in a for a in measure.get('attributes', [])): raise ValueError(f'{path}: rhythm snippets require one voice without meter changes') events = [] cursor = fractions.Fraction() for note in measure['voices'][0]['notes']: onset, duration = generator.model.fraction(note['onset']), generator.model.fraction(note['duration']) if onset != cursor or duration <= 0 or note.get('grace') or note['kind'] not in ('note', 'rest') or note.get('tremolo'): raise ValueError(f'{path}, measure {measure["number"]}: rhythm events must fill the bar sequentially') events.append(Event(onset, duration, note['kind'] == 'note', note['written']['type'], note['written'].get('dots', 0), tuple((b['level'], b['type']) for b in note.get('beams', [])), any('tie_from' in p for p in note['pitches']), generator.model.fraction(note['written'].get('ratio',generator.model.rational(1))))) cursor += duration if cursor != length: raise ValueError(f'{path}: rhythm events do not fill the bar') bars.append(Bar(tuple(events), length)) return Snippet(identifier, meter, tuple(bars)) def load_library(root: pathlib.Path) -> Library: if not root.is_dir(): raise ValueError(f'{root}: rhythm library not found') snippets = [] for path in sorted(root.rglob('*.xml')): relative = path.relative_to(root) if len(relative.parts) != 3 or relative.parts[0] not in ('2_4', '3_4', '4_4', '5_4') or relative.parts[1] not in ('1', '2', '3'): raise ValueError(f'{path}: rhythm sources belong under meter/series') snippet = read_snippet(path, relative.as_posix()) if snippet.meter != relative.parts[0].replace('_', '/'): raise ValueError(f'{path}: rhythm meter disagrees with its directory') snippets.append(snippet) if not snippets: raise ValueError(f'{root}: rhythm library is empty') return Library(tuple(snippets))