@@ -0,0 +1,95 @@
|
||||
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'):
|
||||
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))
|
||||
Reference in New Issue
Block a user