From 8d49e5d5c21ab508bbda5ba6b41ba8a0106aa121 Mon Sep 17 00:00:00 2001 From: Miika Alonen Date: Wed, 8 Feb 2023 20:43:57 +0200 Subject: [PATCH] Added chord names Chord names, parsing notes from roman numerals, method for resolving pitch classes from midi notes (not in use yet). --- ziffers/__init__.py | 1 + ziffers/classes.py | 24 +++++- ziffers/defaults.py | 134 ++++++++++++++++++++++++++++---- ziffers/mapper.py | 7 +- ziffers/scale.py | 177 +++++++++++++++++++++++++++++++++++++++++-- ziffers/ziffers.lark | 2 +- 6 files changed, 318 insertions(+), 27 deletions(-) diff --git a/ziffers/__init__.py b/ziffers/__init__.py index ebecbbc..313cce0 100644 --- a/ziffers/__init__.py +++ b/ziffers/__init__.py @@ -3,3 +3,4 @@ from .mapper import * from .classes import * from .common import * from .defaults import * +from .scale import * diff --git a/ziffers/classes.py b/ziffers/classes.py index 8a18810..611a47b 100644 --- a/ziffers/classes.py +++ b/ziffers/classes.py @@ -6,6 +6,7 @@ import random from .defaults import DEFAULT_OPTIONS from .scale import note_from_pc + @dataclass class Meta: """Abstract class for all Ziffers items""" @@ -80,8 +81,9 @@ class Pitch(Event): octave: int = field(default=None) note: int = field(default=None) - def set_note(self,note: int): + def set_note(self, note: int): self.note = note + return note @dataclass @@ -103,7 +105,11 @@ class Chord(Event): """Class for chords""" pitch_classes: list[Pitch] = field(default=None) + notes: list[int] = field(default=None) + def set_notes(self, notes: list[int]): + """Set notes to the class""" + self.notes = notes @dataclass class RomanNumeral(Event): @@ -111,6 +117,7 @@ class RomanNumeral(Event): value: str = field(default=None) chord_type: str = field(default=None) + notes: list[int] = field(default_factory=[]) @dataclass @@ -206,10 +213,16 @@ class Ziffers(Sequence): self.current.update_new(self.options) # Resolve note from scale - if set(("key","scale")) <= self.options.keys(): - if isinstance(self.current,(Pitch,RandomPitch)): - note = note_from_pc(self.options["key"],self.current.pitch_class,self.options["scale"]) + if set(("key", "scale")) <= self.options.keys(): + key = self.options["key"] + scale = self.options["scale"] + if isinstance(self.current, (Pitch, RandomPitch)): + note = note_from_pc(key,self.current.pitch_class,scale) self.current.set_note(note) + elif isinstance(self.current,Chord): + pcs = self.current.pitch_classes + notes = [pc.set_note(note_from_pc(key, pc.pitch_class, scale)) for pc in pcs] + self.current.set_notes(notes) self.loop_i += 1 return self.current @@ -225,6 +238,9 @@ class Ziffers(Sequence): """ return list(itertools.islice(itertools.cycle(self), num)) + def loop(self) -> iter: + return itertools.cycle(self.iterator) + def set_defaults(self, options: dict): """Sets options for the parser diff --git a/ziffers/defaults.py b/ziffers/defaults.py index e2f02ba..e4062db 100644 --- a/ziffers/defaults.py +++ b/ziffers/defaults.py @@ -38,12 +38,41 @@ DEFAULT_DURS = { "z": 0.0, # 0 } -DEFAULT_OPTIONS = { - "octave": 0, - "duration": 0.25 +DEFAULT_OCTAVE = 4 + +DEFAULT_OPTIONS = {"octave": 0, "duration": 0.25} + +NOTES_TO_INTERVALS = { + 'C': 0, + 'Cs': 1, + 'D': 2, + 'Eb': 3, + 'E': 4, + 'F': 5, + 'Fs': 6, + 'G': 7, + 'Ab': 8, + 'A': 9, + 'Bb': 10, + 'B': 11 + } + +INTERVALS_TO_NOTES = { + 0: 'C', + 1: 'Cs', + 2: 'D', + 3: 'Eb', + 4: 'E', + 5: 'F', + 6: 'Fs', + 7: 'G', + 8: 'Ab', + 9: 'A', + 10: 'Bb', + 11: 'B' } -NOTE_TO_INTERVAL = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11} +CIRCLE_OF_FIFTHS = ['Gb', 'Cs', 'Ab', 'Eb', 'Bb', 'F', 'C', 'G', 'D', 'A', 'E', 'B', 'Fs'] MODIFIERS = { "#": 1, @@ -51,15 +80,7 @@ MODIFIERS = { "s": 1, } -ROMANS = { - 'i': 1, - 'v': 5, - 'x': 10, - 'l': 50, - 'c': 100, - 'd': 500, - 'm': 1000 - } +ROMANS = {"i": 1, "v": 5, "x": 10, "l": 50, "c": 100, "d": 500, "m": 1000} # pylint: disable=locally-disabled, too-many-lines @@ -1555,3 +1576,90 @@ SCALES = { "Thydatic": 12111111111, "Chromatic": 111111111111, } + +def __build_chords(): + major = [0, 4, 7] + minor = [0, 3, 7] + major7 = [0, 4, 7, 11] + dom7 = [0, 4, 7, 10] + minor7 = [0, 3, 7, 10] + aug = [0, 4, 8] + dim = [0, 3, 6] + dim7 = [0, 3, 6, 9] + halfdim = [0, 3, 6, 10] + all_chords = { + "1": [0], + "5": [0, 7], + "+5": [0, 4, 8], + "m+5": [0, 3, 8], + "sus2": [0, 2, 7], + "sus4": [0, 5, 7], + "6": [0, 4, 7, 9], + "m6": [0, 3, 7, 9], + "7sus2": [0, 2, 7, 10], + "7sus4": [0, 5, 7, 10], + "7-5": [0, 4, 6, 10], + "7+5": [0, 4, 8, 10], + "m7+5": [0, 3, 8, 10], + "9": [0, 4, 7, 10, 14], + "m9": [0, 3, 7, 10, 14], + "m7+9": [0, 3, 7, 10, 14], + "maj9": [0, 4, 7, 11, 14], + "9sus4": [0, 5, 7, 10, 14], + "6*9": [0, 4, 7, 9, 14], + "m6*9": [0, 3, 7, 9, 14], + "7-9": [0, 4, 7, 10, 13], + "m7-9": [0, 3, 7, 10, 13], + "7-10": [0, 4, 7, 10, 15], + "7-11": [0, 4, 7, 10, 16], + "7-13": [0, 4, 7, 10, 20], + "9+5": [0, 10, 13], + "m9+5": [0, 10, 14], + "7+5-9": [0, 4, 8, 10, 13], + "m7+5-9": [0, 3, 8, 10, 13], + "11": [0, 4, 7, 10, 14, 17], + "m11": [0, 3, 7, 10, 14, 17], + "maj11": [0, 4, 7, 11, 14, 17], + "11+": [0, 4, 7, 10, 14, 18], + "m11+": [0, 3, 7, 10, 14, 18], + "13": [0, 4, 7, 10, 14, 17, 21], + "m13": [0, 3, 7, 10, 14, 17, 21], + "add2": [0, 2, 4, 7], + "add4": [0, 4, 5, 7], + "add9": [0, 4, 7, 14], + "add11": [0, 4, 7, 17], + "add13": [0, 4, 7, 21], + "madd2": [0, 2, 3, 7], + "madd4": [0, 3, 5, 7], + "madd9": [0, 3, 7, 14], + "madd11": [0, 3, 7, 17], + "madd13": [0, 3, 7, 21], + "major": major, + "maj": major, + "M": major, + "minor": minor, + "min": minor, + "m": minor, + "major7": major7, + "dom7": dom7, + "7": dom7, + "M7": major7, + "minor7": minor7, + "m7": minor7, + "augmented": aug, + "a": aug, + "aug": aug, + "diminished": dim, + "dim": dim, + "i": dim, + "diminished7": dim7, + "dim7": dim7, + "i7": dim7, + "halfdim": halfdim, + "m7b5": halfdim, + "m7-5": halfdim, + } + all_chords_names = list(all_chords.keys()) + return (all_chords, all_chords_names) + +CHORDS, CHORD_NAMES = __build_chords() diff --git a/ziffers/mapper.py b/ziffers/mapper.py index f0c7db2..312ee7b 100644 --- a/ziffers/mapper.py +++ b/ziffers/mapper.py @@ -30,7 +30,7 @@ from .classes import ( ) from .common import flatten, sum_dict from .defaults import DEFAULT_DURS -from .scale import parse_roman +from .scale import parse_roman, chord_from_roman_numeral # pylint: disable=locally-disabled, unused-argument, too-many-public-methods, invalid-name @@ -114,8 +114,9 @@ class ZiffersTransformer(Transformer): 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) + notes = chord_from_roman_numeral(numeral,name) + return RomanNumeral(text=numeral, value=parse_roman(numeral), chord_type=name, notes=notes) + return RomanNumeral(value=parse_roman(numeral), text=numeral, notes=chord_from_roman_numeral(numeral)) def chord_name(self,item): """Return name for chord""" diff --git a/ziffers/scale.py b/ziffers/scale.py index 2489807..ef43b8f 100644 --- a/ziffers/scale.py +++ b/ziffers/scale.py @@ -3,10 +3,49 @@ # pylint: disable=locally-disabled, no-name-in-module import re from math import floor -from .defaults import SCALES, MODIFIERS, NOTE_TO_INTERVAL, ROMANS +from .defaults import ( + DEFAULT_OCTAVE, + SCALES, + MODIFIERS, + NOTES_TO_INTERVALS, + INTERVALS_TO_NOTES, + ROMANS, + CIRCLE_OF_FIFTHS, + CHORDS, +) -def note_to_midi(name: str) -> int: +def midi_to_note_name(midi: int) -> str: + """Creates note name from midi number + + Args: + midi (int): Mii number + + Returns: + str: Note name + """ + return INTERVALS_TO_NOTES[midi % 12] + + +def note_name_to_interval(name: str) -> int: + """Parse note name to interval + + Args: + name (str): Note name as: [a-gA-G][#bs] + + Returns: + int: Interval of the note name [-1 - 11] + """ + items = re.match(r"^([a-gA-G])([#bs])?$", name) + if items is None: + return 0 + values = items.groups() + modifier = MODIFIERS[values[1]] if values[1] else 0 + interval = NOTES_TO_INTERVALS[values[0].capitalize()] + return interval + modifier + + +def note_name_to_midi(name: str) -> int: """Parse note name to midi Args: @@ -21,7 +60,7 @@ def note_to_midi(name: str) -> int: values = items.groups() octave = int(values[2]) if values[2] else 4 modifier = MODIFIERS[values[1]] if values[1] else 0 - interval = NOTE_TO_INTERVAL[values[0].capitalize()] + interval = NOTES_TO_INTERVALS[values[0].capitalize()] return 12 + octave * 12 + interval + modifier @@ -62,7 +101,7 @@ def note_from_pc( """ # Initialization - root = note_to_midi(root) if isinstance(root, str) else root + root = note_name_to_midi(root) if isinstance(root, str) else root intervals = get_scale(intervals) if isinstance(intervals, str) else intervals intervals = list(map(lambda x: x / 100), intervals) if cents else intervals scale_length = len(intervals) @@ -84,8 +123,15 @@ def note_from_pc( return note + (octave * sum(intervals)) + modifier -def parse_roman(numeral: str): - """Parse roman numeral from string""" +def parse_roman(numeral: str) -> int: + """Parse roman numeral from string + + Args: + numeral (str): Roman numeral as string + + Returns: + int: Integer parsed from roman numeral + """ values = [ROMANS[val] for val in numeral] result = 0 i = 0 @@ -97,3 +143,122 @@ def parse_roman(numeral: str): result += values[i] i += 1 return result + + +def accidentals_from_note_name(name: str) -> int: + """Generates number of accidentals from name of the note. + + Args: + name (str): Name of the note + + Returns: + int: Integer representing number of flats or sharps: -7 flat to 7 sharp. + """ + idx = CIRCLE_OF_FIFTHS.index(name.upper()) + return idx - 6 + + +def accidentals_from_midi_note(note: int) -> int: + """Generates number of accidentals from name of the note. + + Args: + note (int): Note as midi number + + Returns: + int: Integer representing number of flats or sharps: -7 flat to 7 sharp. + """ + name = midi_to_note_name(note) + return accidentals_from_note_name(name) + + +def midi_to_tpc(note: int, key: str | int): + """Return Tonal Pitch Class value for the note + + Args: + note (int): MIDI note + key (str | int): Key as a string (A-G) or a MIDI note. + + Returns: + _type_: Tonal Pitch Class value for the note + """ + if isinstance(key, str): + acc = accidentals_from_note_name(key) + else: + acc = accidentals_from_midi_note(key) + return (note * 7 + 26 - (11 + acc)) % 12 + (11 + acc) + + +def midi_to_pitch_class(note: int) -> int: + """Return pitch class from midi + + Args: + note (int): Note in midi + + Returns: + int: Returns note % 12 + """ + return note % 12 + + +def midi_to_octave(note: int) -> int: + """Return octave for the midi note + + Args: + note (int): Note in midi + + Returns: + int: Returns default octave in Ziffers where C4 is in octave 0 + """ + return 0 if note <= 0 else floor(note / 12) + + +def midi_to_pc(note: int, key: str | int, scale: str) -> tuple: + """Return pitch class and octave from given midi note, key and scale + + Args: + note (int): Note as MIDI number + key (str | int): Used key + scale (str): Used scale + + Returns: + tuple: Returns tuple containing (pitch class as string, pitch class, octave, optional modifier) + """ + sharps = ["0", "#0", "1", "#1", "2", "3", "#3", "4", "#4", "5", "#5", "6"] + flats = ["0", "b1", "1", "b2", "2", "3", "b4", "4", "b5", "5", "b6", "6"] + tpc = midi_to_tpc(note, key) + pitch_class = midi_to_pitch_class(note) + octave = midi_to_octave(note) - 5 + if scale.upper() == "CHROMATIC": + return (str(pitch_class), pitch_class, octave) + if tpc >= 6 and tpc <= 12 and len(flats[pitch_class]) == 2: + npc = flats[pitch_class] + elif tpc >= 20 and tpc <= 26 and len(sharps[pitch_class]) == 2: + npc = sharps[pitch_class] + else: + npc = sharps[pitch_class] + + if len(npc) > 1: + return (npc, int(npc[1]), octave, 1 if (npc[0] == "#") else -1) + + return (npc, int(npc), octave) + + +def chord_from_roman_numeral(roman: str, name: str = "major", num_octaves: int = 1) -> list[int]: + """Generates chord from given roman numeral and chord name + + Args: + roman (str): Roman numeral + name (str, optional): Chord name. Defaults to "major". + num_octaves (int, optional): Number of octaves for the chord. Defaults to 1. + + Returns: + list[int]: _description_ + """ + root = parse_roman(roman) - 1 + tonic = (DEFAULT_OCTAVE * 12) + root + 12 + intervals = CHORDS.get(name,CHORDS["major"]) + notes = [] + for cur_oct in range(num_octaves): + for iterval in intervals: + notes.append(tonic + iterval + (cur_oct * 12)) + return notes diff --git a/ziffers/ziffers.lark b/ziffers/ziffers.lark index c8310d1..a2de728 100644 --- a/ziffers/ziffers.lark +++ b/ziffers/ziffers.lark @@ -12,7 +12,7 @@ // Chords chord: pitch_class pitch_class+ - named_roman: roman_number (("^" chord_name) | ("+" number))? + named_roman: roman_number (("^" chord_name))? // TODO: Add | ("+" number) chord_name: /[a-zA-Z0-9]+/ ?roman_number: /iv|v|v?i{1,3}/