96 lines
4.4 KiB
Python
96 lines
4.4 KiB
Python
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
|
|
|
|
@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) or staff.get('tuplets'):
|
|
raise ValueError(f'{path}: rhythm snippets require one or two bars without tuplets')
|
|
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')
|
|
if 'ratio' in note['written']:
|
|
raise ValueError(f'{path}, measure {measure["number"]}: rhythm snippets do not support time modifications')
|
|
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'])))
|
|
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))
|