diff --git a/tests/test_parser.py b/tests/test_parser.py index 40eb8dd..7a882c9 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -157,4 +157,24 @@ def test_rest(pattern: str, expected: list): ] ) def test_ranges(pattern: str, expected: list): - assert collect(zparse(pattern),len(expected)*2,"note") == expected*2 \ No newline at end of file + assert collect(zparse(pattern),len(expected)*2,"note") == expected*2 + +@pytest.mark.parametrize( + "pattern,expected", + [ + ("i ii iii iv v vi vii", [[60, 64, 67], [62, 65, 69], [64, 67, 71], [65, 69, 72], [67, 71, 74], [69, 72, 76], [71, 74, 77]]) + ] +) +def test_romans(pattern: str, expected: list): + assert collect(zparse(pattern),len(expected)*2,"note") == expected*2 + +@pytest.mark.parametrize( + "pattern,expected", + [ + ("[: i vi v :]", [[0, 2, 4], [5, 0, 2], [4, 6, 1], [0, 2, 4], [5, 0, 2], [4, 6, 1]]), + ("i ii iii iv v vi vii", [[0, 2, 4], [1, 3, 5], [2, 4, 6], [3, 5, 0], [4, 6, 1], [5, 0, 2], [6, 1, 3]]), + ("i^7 i^min iv^6", [[0, 2, 4, 6], [0, 2, 4], [3, 5, 0, 1]]) + ] +) +def test_romans_pcs(pattern: str, expected: list): + assert collect(zparse(pattern),len(expected)*2,"pitches") == expected*2 diff --git a/ziffers/classes.py b/ziffers/classes.py index 2f6420b..266d95d 100644 --- a/ziffers/classes.py +++ b/ziffers/classes.py @@ -6,7 +6,14 @@ import operator import random from copy import deepcopy from .defaults import DEFAULT_OPTIONS -from .scale import note_from_pc, midi_to_pitch_class, midi_to_freq, get_scale_length +from .common import repeat_text +from .scale import ( + note_from_pc, + midi_to_pitch_class, + midi_to_freq, + get_scale_length, + chord_from_degree, +) from .common import euclidian_rhythm @@ -39,7 +46,7 @@ class Meta: setattr(self, key, oct_change) elif local_value: setattr(self, key, value + local_value) - else: + elif getattr(self, key) is None: setattr(self, key, value) elif getattr(self, key) is None: local_value = self.local_options.get(key, False) @@ -172,6 +179,13 @@ class Pitch(Event): if self.text is None: self.text = str(self.pitch_class) self.update_note() + #self._update_text() + + def _update_text(self): + if self.octave is not None: + self.text = repeat_text("^","_",self.octave) + self.text + if self.modifier is not None: + self.text = repeat_text("#","b",self.modifier) + self.text def get_note(self): """Getter for note""" @@ -295,6 +309,11 @@ class Chord(Event): if self.inversions is not None: self.invert(self.inversions) + @property + def note(self): + """Synonym for notes""" + return self.notes + def set_notes(self, notes: list[int]): """Set notes to the class""" self.notes = notes @@ -336,7 +355,7 @@ class Chord(Event): self.pitch_classes = new_pitches - def update_notes(self, options): + def update_notes(self, options, force=False): """Update notes""" pitches, notes, freqs, octaves, durations, beats = ([] for _ in range(6)) @@ -371,9 +390,9 @@ class RomanNumeral(Event): value: str = field(default=None) chord_type: str = field(default=None) - notes: list[int] = field(default_factory=[]) - pitch_classes: list = None - inversions: int = None + notes: list[int] = field(default=None, init=False) + pitch_classes: list = field(default=None, init=False) + inversions: int = field(default=None) def set_notes(self, chord_notes: list[int]): """Set notes to roman numeral @@ -623,30 +642,24 @@ class Sequence(Meta): scale = options["scale"] pitch_text = "" pitch_classes = [] - chord_notes = [] + current.notes = chord_from_degree( + current.value, current.chord_type, options["scale"], options["key"] + ) for note in current.notes: pitch_dict = midi_to_pitch_class(note, key, scale) pitch_classes.append( Pitch( pitch_class=pitch_dict["pitch_class"], - kwargs=(pitch_dict | options), + note=note, + kwargs=(options | pitch_dict), ) ) pitch_text += pitch_dict["text"] - chord_notes.append( - note_from_pc( - root=key, - pitch_class=pitch_dict["pitch_class"], - intervals=scale, - modifier=pitch_dict.get("modifier", 0), - octave=pitch_dict.get("octave", 0), - ) - ) chord = Chord( text=pitch_text, pitch_classes=pitch_classes, - notes=chord_notes, + notes=current.notes, kwargs=options, inversions=current.inversions, ) @@ -776,16 +789,24 @@ class Ziffers(Sequence): def notes(self) -> list[int]: """Return list of midi notes""" return [ - val.get_note() for val in self.evaluated_values if isinstance(val, (Pitch, Chord)) + val.get_note() + for val in self.evaluated_values + if isinstance(val, (Pitch, Chord)) ] def durations(self) -> list[float]: """Return list of pitch durations as floats""" - return [val.get_duration() for val in self.evaluated_values if isinstance(val, Event)] + return [ + val.get_duration() + for val in self.evaluated_values + if isinstance(val, Event) + ] def beats(self) -> list[float]: """Return list of pitch durations as floats""" - return [val.get_beat() for val in self.evaluated_values if isinstance(val, Event)] + return [ + val.get_beat() for val in self.evaluated_values if isinstance(val, Event) + ] def pairs(self) -> list[tuple]: """Return list of pitches and durations""" @@ -806,7 +827,9 @@ class Ziffers(Sequence): def freqs(self) -> list[int]: """Return list of octaves""" return [ - val.get_freq() for val in self.evaluated_values if isinstance(val, (Pitch, Chord)) + val.get_freq() + for val in self.evaluated_values + if isinstance(val, (Pitch, Chord)) ] @@ -1110,6 +1133,8 @@ class RepeatedSequence(Sequence): yield item.get_updated_item(self.local_options) elif isinstance(item, Range): yield from item.evaluate(self.local_options) + elif isinstance(item, (Pitch, Chord, RomanNumeral)): + yield item elif isinstance(item, (Event, RandomInteger)): yield Pitch( pitch_class=item.get_value(self.local_options), diff --git a/ziffers/common.py b/ziffers/common.py index b5d3578..83b026d 100644 --- a/ziffers/common.py +++ b/ziffers/common.py @@ -10,6 +10,7 @@ def flatten(arr: list) -> list: else [arr] ) + def rotate(arr, k): """Rotates array""" # Calculate the effective rotation amount (mod the array length) @@ -22,6 +23,13 @@ def rotate(arr, k): arr = arr[-k:] + arr[:-k] return arr +def repeat_text(pos,neg,times): + """Helper to repeat text""" + if times>0: + return pos*times + if times<0: + return neg*abs(times) + return "" def sum_dict(arr: list[dict]) -> dict: """Sums a list of dicts: [{a:3,b:3},{b:1}] -> {a:3,b:4}""" @@ -67,7 +75,7 @@ def string_rewrite(axiom: str, rules: dict): return pattern.sub(lambda m: next(_apply_rules(m)), axiom) -def euclidian_rhythm(pulses: int, length: int, rotate: int = 0): +def euclidian_rhythm(pulses: int, length: int, rot: int = 0): """Calculate Euclidean rhythms. Original algorithm by Thomas Morrill.""" def _starts_descent(arr, index): @@ -84,5 +92,4 @@ def euclidian_rhythm(pulses: int, length: int, rotate: int = 0): res_list = [pulses * t % length for t in range(-1, length - 1)] bool_list = [_starts_descent(res_list, index) for index in range(length)] - return rotation(bool_list, rotate) - + return rotation(bool_list, rot) diff --git a/ziffers/defaults.py b/ziffers/defaults.py index 6aae6e5..5bd8867 100644 --- a/ziffers/defaults.py +++ b/ziffers/defaults.py @@ -1729,7 +1729,6 @@ def __build_chords(): "a": aug, "aug": aug, "diminished": dim, - "dim": dim, "i": dim, "diminished7": dim7, "dim7": dim7, diff --git a/ziffers/mapper.py b/ziffers/mapper.py index a554068..638b3c4 100644 --- a/ziffers/mapper.py +++ b/ziffers/mapper.py @@ -29,11 +29,11 @@ from .classes import ( RepeatedSequence, VariableAssignment, Variable, - Measure + Measure, ) from .common import flatten, sum_dict from .defaults import DEFAULT_DURS, OPERATORS -from .scale import parse_roman, chord_from_roman_numeral +from .scale import parse_roman # pylint: disable=locally-disabled, unused-argument, too-many-public-methods, invalid-name @@ -60,7 +60,7 @@ class ZiffersTransformer(Transformer): def measure(self, items): """Return new measure""" return Measure() - + def random_integer(self, items) -> RandomInteger: """Parses random integer syntax""" if len(items) > 1: @@ -175,17 +175,15 @@ class ZiffersTransformer(Transformer): return Chord(pitch_classes=items, text="".join([val.text for val in items])) def invert(self, items): + """Return chord inversion""" return items[0] def named_roman(self, items) -> RomanNumeral: """Parse chord from roman numeral""" numeral = items[0].value + # TODO: Refactor this and the rule if len(items) == 1: - return RomanNumeral( - value=parse_roman(numeral), - text=numeral, - notes=chord_from_roman_numeral(numeral), - ) + return RomanNumeral(value=parse_roman(numeral), text=numeral) if len(items) > 2: name = items[1] inversions = int(items[-1].value[1:]) @@ -193,7 +191,6 @@ class ZiffersTransformer(Transformer): text=numeral, value=parse_roman(numeral), chord_type=name, - notes=chord_from_roman_numeral(numeral, name), inversions=inversions, ) elif len(items) == 2: @@ -202,15 +199,11 @@ class ZiffersTransformer(Transformer): return RomanNumeral( value=parse_roman(numeral), text=numeral, - notes=chord_from_roman_numeral(numeral), inversions=inversions, ) else: return RomanNumeral( - value=parse_roman(numeral), - text=numeral, - chord_type=items[1], - notes=chord_from_roman_numeral(numeral), + value=parse_roman(numeral), text=numeral, chord_type=items[1] ) def chord_name(self, item): @@ -219,7 +212,7 @@ class ZiffersTransformer(Transformer): def roman_number(self, item): """Return roman numeral""" - return item.value + return item[0] def dur_change(self, items): """Parses duration change""" diff --git a/ziffers/scale.py b/ziffers/scale.py index 1abde46..47a88d1 100644 --- a/ziffers/scale.py +++ b/ziffers/scale.py @@ -3,8 +3,8 @@ # pylint: disable=locally-disabled, no-name-in-module import re from math import floor +from .common import repeat_text from .defaults import ( - DEFAULT_OCTAVE, SCALES, MODIFIERS, NOTES_TO_INTERVALS, @@ -44,11 +44,13 @@ def note_name_to_interval(name: str) -> int: interval = NOTES_TO_INTERVALS[values[0].capitalize()] return interval + modifier + def midi_to_freq(note: int) -> float: - """Transform midi to frequency""" - freq = 440 # Frequency of A + """Transform midi to frequency""" + freq = 440 # Frequency of A return (freq / 32) * (2 ** ((note - 9) / 12)) + def note_name_to_midi(name: str) -> int: """Parse note name to midi @@ -80,6 +82,45 @@ def get_scale(name: str) -> list[int]: scale = SCALES.get(name.lower().capitalize(), SCALES["Ionian"]) return scale + +def get_scale_notes(name: str, root: int = 60, num_octaves: int = 1) -> list[int]: + """Return notes for the scale + + Args: + name (str): Name of the scale + root (int, optional): Root note. Defaults to 60. + num_octaves (int, optional): Number of octaves. Defaults to 1. + + Returns: + list[int]: List of notes + """ + scale = get_scale(name) + scale_notes = [root] + for _ in range(num_octaves): + scale_notes = scale_notes + [root := root + semitone for semitone in scale] + return scale_notes + + +def get_chord_from_scale( + degree: int, root: int = 60, scale: str = "Major", num_notes: int = 3, skip: int = 2 +) -> list[int]: + """Generate chord from the scale by skipping notes + + Args: + degree (int): Degree of scale to start on + root (int, optional): Root for the scale. Defaults to 60. + scale (str, optional): Name of the scale. Defaults to "Major". + num_notes (int, optional): Number of notes. Defaults to 3. + skip (int, optional): Takes every n from the scale. Defaults to 2. + + Returns: + list[int]: List of midi notes + """ + num_of_octaves = ((num_notes * skip + degree) // get_scale_length(scale)) + 1 + scale_notes = get_scale_notes(scale, root, num_of_octaves) + return scale_notes[degree - 1 :: skip][:num_notes] + + def get_scale_length(name: str) -> int: """Get length of the scale @@ -92,6 +133,7 @@ def get_scale_length(name: str) -> int: scale = SCALES.get(name.lower().capitalize(), SCALES["Ionian"]) return len(scale) + # pylint: disable=locally-disabled, too-many-arguments def note_from_pc( root: int | str, @@ -242,18 +284,33 @@ def midi_to_pitch_class(note: int, key: str | int, scale: str) -> dict: npc = sharps[pitch_class] if len(npc) > 1: + modifier = 1 if (npc[0] == "#") else -1 return { - "text": npc, + "text": repeat_text("^", "_", octave)+npc, "pitch_class": int(npc[1]), "octave": octave, - "modifier": 1 if (npc[0] == "#") else -1, + "modifier": modifier, } - return {"text": npc, "pitch_class": int(npc), "octave": octave} + return { + "text": repeat_text("^", "_", octave)+npc, + "pitch_class": int(npc), + "octave": octave, + } -def chord_from_roman_numeral( - roman: str, name: str = "major", num_octaves: int = 1 +def chord_from_degree( + degree: int, name: str, scale: str, root: str | int, num_octaves: int = 1 +): + root = note_name_to_midi(root) if isinstance(root, str) else root + if name: + return named_chord_from_degree(degree, name, root, scale, num_octaves) + else: + return get_chord_from_scale(degree, root, scale) + + +def named_chord_from_degree( + degree: int, name: str = "major", root: int = 60, scale: str="Major", num_octaves: int = 1 ) -> list[int]: """Generates chord from given roman numeral and chord name @@ -265,11 +322,10 @@ def chord_from_roman_numeral( Returns: list[int]: _description_ """ - root = parse_roman(roman) - 1 - tonic = (DEFAULT_OCTAVE * 12) + root + 12 intervals = CHORDS.get(name, CHORDS["major"]) + scale_degree = get_scale_notes(scale, root)[degree-1] notes = [] for cur_oct in range(num_octaves): - for iterval in intervals: - notes.append(tonic + iterval + (cur_oct * 12)) + for interval in intervals: + notes.append(scale_degree + interval + (cur_oct * 12)) return notes diff --git a/ziffers/spec/ziffers.lark b/ziffers/spec/ziffers.lark index acf2f30..28ee5e2 100644 --- a/ziffers/spec/ziffers.lark +++ b/ziffers/spec/ziffers.lark @@ -31,7 +31,7 @@ chord: pitch_class pitch_class+ invert? named_roman: roman_number (("^" chord_name))? invert? // TODO: Add | ("+" number) chord_name: /[a-zA-Z0-9]+/ - ?roman_number: /iv|v|v?i{1,3}/ + !roman_number: "i" | "ii" | "iii" | "iv" | "v" | "vi" | "vii" invert: /%-?[0-9][0-9]*/