diff --git a/tests/test_scale.py b/tests/test_scale.py index 0458666..23f765b 100644 --- a/tests/test_scale.py +++ b/tests/test_scale.py @@ -1,6 +1,7 @@ """ Tests for the scale module """ -from ziffers import scale import pytest +from ziffers import scale + @pytest.mark.parametrize( diff --git a/ziffers/classes.py b/ziffers/classes.py index c72069a..13a293d 100644 --- a/ziffers/classes.py +++ b/ziffers/classes.py @@ -5,7 +5,6 @@ import operator import random from .defaults import DEFAULT_OPTIONS - @dataclass class Meta: """Abstract class for all Ziffers items""" @@ -30,6 +29,7 @@ class Item(Meta): text: str + @dataclass class Whitespace(Item): """Class for whitespace""" @@ -76,8 +76,8 @@ class Pitch(Event): """Class for pitch in time""" pitch_class: int = field(default=None) - duration: float = field(default=None) octave: int = field(default=None) + note: int = field(default=None) @dataclass @@ -101,6 +101,14 @@ class Chord(Event): pitch_classes: list[Pitch] = field(default=None) +@dataclass +class RomanNumeral(Event): + """Class for roman numbers""" + + value: str = field(default=None) + chord_type: str = field(default=None) + + @dataclass class Function(Event): """Class for functions""" @@ -129,9 +137,9 @@ class Sequence(Meta): next_item = self.values[self.local_index] self.local_index += 1 return next_item - else: - self.local_index = 0 - raise StopIteration + + self.local_index = 0 + raise StopIteration def update_values(self, new_values): """Update value attributes from dict""" @@ -165,7 +173,9 @@ class Ziffers(Sequence): options: dict = field(default_factory=DEFAULT_OPTIONS) loop_i: int = 0 iterator: iter = field(default=None, repr=False) - current: Whitespace|DurationChange|OctaveChange|OctaveAdd = field(default=None) + current: Whitespace | DurationChange | OctaveChange | OctaveAdd = field( + default=None + ) def __post_init__(self): super().__post_init__() @@ -193,7 +203,7 @@ class Ziffers(Sequence): return self.current def take(self, num: int) -> list[Pitch]: - """ Take number of pitch classes from the parsed sequence. Cycles from the beginning. + """Take number of pitch classes from the parsed sequence. Cycles from the beginning. Args: num (int): Number of pitch classes to take from the sequence @@ -204,7 +214,7 @@ class Ziffers(Sequence): return list(itertools.islice(itertools.cycle(self), num)) def set_defaults(self, options: dict): - """ Sets options for the parser + """Sets options for the parser Args: options (dict): Options as a dict @@ -213,19 +223,21 @@ class Ziffers(Sequence): # TODO: Refactor these def pitch_classes(self) -> list[int]: - """ Return list of pitch classes as ints """ + """Return list of pitch classes as ints""" return [val.pitch_class for val in self.values if isinstance(val, Pitch)] def durations(self) -> list[float]: - """ Return list of pitch durations as floats""" + """Return list of pitch durations as floats""" return [val.dur for val in self.values if isinstance(val, Pitch)] def pairs(self) -> list[tuple]: - """ Return list of pitches and durations """ - return [(val.pitch_class, val.dur) for val in self.values if isinstance(val, Pitch)] + """Return list of pitches and durations""" + return [ + (val.pitch_class, val.dur) for val in self.values if isinstance(val, Pitch) + ] def octaves(self) -> list[int]: - """ Return list of octaves """ + """Return list of octaves""" return [val.octave for val in self.values if isinstance(val, Pitch)] @@ -258,7 +270,7 @@ class RandomInteger(Item): self.max = new_max def value(self): - """ Evaluate the random value for the generator """ + """Evaluate the random value for the generator""" return random.randint(self.min, self.max) @@ -292,11 +304,11 @@ class Cyclic(Sequence): self.values = [val for val in self.values if isinstance(val, Whitespace)] def value(self): - """ Get the value for the current cycle """ + """Get the value for the current cycle""" return self.values[self.cycle] def next_cycle(self, cycle: int): - """ Evaluate next cycle """ + """Evaluate next cycle""" self.cycle = self.cycle + 1 @@ -332,7 +344,7 @@ class ListOperation(Sequence): """Class for list operations""" def run(self): - """ Run operations """ + """Run operations""" pass diff --git a/ziffers/defaults.py b/ziffers/defaults.py index c0727e5..e2f02ba 100644 --- a/ziffers/defaults.py +++ b/ziffers/defaults.py @@ -51,6 +51,16 @@ MODIFIERS = { "s": 1, } +ROMANS = { + 'i': 1, + 'v': 5, + 'x': 10, + 'l': 50, + 'c': 100, + 'd': 500, + 'm': 1000 + } + # pylint: disable=locally-disabled, too-many-lines SCALES = { diff --git a/ziffers/mapper.py b/ziffers/mapper.py index 364daa0..160c245 100644 --- a/ziffers/mapper.py +++ b/ziffers/mapper.py @@ -1,4 +1,5 @@ """ Lark transformer for mapping Lark tokens to Ziffers objects """ +from typing import Optional from lark import Transformer from .classes import ( Ziffers, @@ -10,6 +11,7 @@ from .classes import ( RandomPitch, RandomPercent, Chord, + RomanNumeral, Sequence, ListSequence, RepeatedListSequence, @@ -28,13 +30,18 @@ from .classes import ( ) from .common import flatten, sum_dict from .defaults import DEFAULT_DURS +from .scale import note_from_pc, parse_roman # pylint: disable=locally-disabled, unused-argument, too-many-public-methods, invalid-name class ZiffersTransformer(Transformer): """Rules for transforming Ziffers expressions into tree.""" - def start(self, items): + def __init__(self, options: Optional[dict]=None): + super().__init__() + self.options = options + + def start(self, items) -> Ziffers: """Root for the rules""" seq = Sequence(values=items[0]) return Ziffers(values=seq, options={}) @@ -43,17 +50,17 @@ class ZiffersTransformer(Transformer): """Flatten sequence""" return flatten(items) - def random_integer(self, item): + def random_integer(self, item) -> RandomInteger: """Parses random integer syntax""" val = item[0][1:-1].split(",") return RandomInteger(min=val[0], max=val[1], text=item[0].value) - def range(self, item): + def range(self, item) -> Range: """Parses range syntax""" val = item[0].split("..") return Range(start=val[0], end=val[1], text=item[0]) - def cycle(self, items): + def cycle(self, items) -> Cyclic: """Parses cycle""" values = items[0] return Cyclic(values=values) @@ -102,6 +109,22 @@ class ZiffersTransformer(Transformer): """Parses chord""" return Chord(pitch_classes=items, text="".join([val.text for val in items])) + def named_roman(self,items) -> RomanNumeral: + """Parse chord from roman numeral""" + numeral = items[0].value + if len(items)>1: + name = items[1] + return RomanNumeral(text=numeral, value=parse_roman(numeral), chord_type=name) + return RomanNumeral(value=parse_roman(numeral), text=numeral) + + def chord_name(self,item): + """Return name for chord""" + return item[0].value + + def roman_number(self,item): + """Return roman numeral""" + return item.value + def dur_change(self, items): """Parses duration change""" durs = items[0] diff --git a/ziffers/scale.py b/ziffers/scale.py index 6541ae8..2489807 100644 --- a/ziffers/scale.py +++ b/ziffers/scale.py @@ -3,10 +3,11 @@ # pylint: disable=locally-disabled, no-name-in-module import re from math import floor -from .defaults import SCALES, MODIFIERS, NOTE_TO_INTERVAL +from .defaults import SCALES, MODIFIERS, NOTE_TO_INTERVAL, ROMANS + def note_to_midi(name: str) -> int: - """ Parse note name to midi + """Parse note name to midi Args: name (str): Note name in scientific notation: [a-gA-G][#bs][1-9] @@ -25,7 +26,7 @@ def note_to_midi(name: str) -> int: def get_scale(name: str) -> list[int]: - """ Get a scale from the global scale list + """Get a scale from the global scale list Args: name (str): Name of the scale as named in https://allthescales.org/ @@ -36,6 +37,7 @@ def get_scale(name: str) -> list[int]: scale = SCALES.get(name.lower().capitalize(), SCALES["Chromatic"]) return list(map(int, str(scale))) + # pylint: disable=locally-disabled, too-many-arguments def note_from_pc( root: int | str, @@ -45,7 +47,7 @@ def note_from_pc( octave: int = 0, modifier: int = 0, ) -> int: - """ Resolve a pitch class into a note from a scale + """Resolve a pitch class into a note from a scale Args: root (int | str): Root of the scale in MIDI or scientific pitch notation @@ -80,3 +82,18 @@ def note_from_pc( note = root + sum(intervals[0:pitch_class]) return note + (octave * sum(intervals)) + modifier + + +def parse_roman(numeral: str): + """Parse roman numeral from string""" + values = [ROMANS[val] for val in numeral] + result = 0 + i = 0 + while i < len(values): + if i < len(values) - 1 and values[i + 1] > values[i]: + result += values[i + 1] - values[i] + i += 2 + else: + result += values[i] + i += 1 + return result diff --git a/ziffers/ziffers.lark b/ziffers/ziffers.lark index 980bc6d..c8310d1 100644 --- a/ziffers/ziffers.lark +++ b/ziffers/ziffers.lark @@ -1,6 +1,6 @@ // Root for the rules ?root: sequence -> start - sequence: (pitch_class | dur_change | oct_mod | oct_change | WS | chord | cycle | random_integer | random_pitch | random_percent | range | list | repeated_list | lisp_operation | list_op | subdivision | eval | euclid | repeat)* + sequence: (pitch_class | dur_change | oct_mod | oct_change | WS | chord | named_roman | cycle | random_integer | random_pitch | random_percent | range | list | repeated_list | lisp_operation | list_op | subdivision | eval | euclid | repeat)* // Pitch classes pitch_class: prefix* pitch @@ -12,6 +12,9 @@ // Chords chord: pitch_class pitch_class+ + named_roman: roman_number (("^" chord_name) | ("+" number))? + chord_name: /[a-zA-Z0-9]+/ + ?roman_number: /iv|v|v?i{1,3}/ // Valid as integer ?number: SIGNED_NUMBER | random_integer | cyclic_number