Added chord names
Chord names, parsing notes from roman numerals, method for resolving pitch classes from midi notes (not in use yet).
This commit is contained in:
@ -3,3 +3,4 @@ from .mapper import *
|
|||||||
from .classes import *
|
from .classes import *
|
||||||
from .common import *
|
from .common import *
|
||||||
from .defaults import *
|
from .defaults import *
|
||||||
|
from .scale import *
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import random
|
|||||||
from .defaults import DEFAULT_OPTIONS
|
from .defaults import DEFAULT_OPTIONS
|
||||||
from .scale import note_from_pc
|
from .scale import note_from_pc
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Meta:
|
class Meta:
|
||||||
"""Abstract class for all Ziffers items"""
|
"""Abstract class for all Ziffers items"""
|
||||||
@ -80,8 +81,9 @@ class Pitch(Event):
|
|||||||
octave: int = field(default=None)
|
octave: int = field(default=None)
|
||||||
note: int = field(default=None)
|
note: int = field(default=None)
|
||||||
|
|
||||||
def set_note(self,note: int):
|
def set_note(self, note: int):
|
||||||
self.note = note
|
self.note = note
|
||||||
|
return note
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -103,7 +105,11 @@ class Chord(Event):
|
|||||||
"""Class for chords"""
|
"""Class for chords"""
|
||||||
|
|
||||||
pitch_classes: list[Pitch] = field(default=None)
|
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
|
@dataclass
|
||||||
class RomanNumeral(Event):
|
class RomanNumeral(Event):
|
||||||
@ -111,6 +117,7 @@ class RomanNumeral(Event):
|
|||||||
|
|
||||||
value: str = field(default=None)
|
value: str = field(default=None)
|
||||||
chord_type: str = field(default=None)
|
chord_type: str = field(default=None)
|
||||||
|
notes: list[int] = field(default_factory=[])
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -206,10 +213,16 @@ class Ziffers(Sequence):
|
|||||||
self.current.update_new(self.options)
|
self.current.update_new(self.options)
|
||||||
|
|
||||||
# Resolve note from scale
|
# Resolve note from scale
|
||||||
if set(("key","scale")) <= self.options.keys():
|
if set(("key", "scale")) <= self.options.keys():
|
||||||
if isinstance(self.current,(Pitch,RandomPitch)):
|
key = self.options["key"]
|
||||||
note = note_from_pc(self.options["key"],self.current.pitch_class,self.options["scale"])
|
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)
|
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
|
self.loop_i += 1
|
||||||
return self.current
|
return self.current
|
||||||
@ -225,6 +238,9 @@ class Ziffers(Sequence):
|
|||||||
"""
|
"""
|
||||||
return list(itertools.islice(itertools.cycle(self), num))
|
return list(itertools.islice(itertools.cycle(self), num))
|
||||||
|
|
||||||
|
def loop(self) -> iter:
|
||||||
|
return itertools.cycle(self.iterator)
|
||||||
|
|
||||||
def set_defaults(self, options: dict):
|
def set_defaults(self, options: dict):
|
||||||
"""Sets options for the parser
|
"""Sets options for the parser
|
||||||
|
|
||||||
|
|||||||
@ -38,12 +38,41 @@ DEFAULT_DURS = {
|
|||||||
"z": 0.0, # 0
|
"z": 0.0, # 0
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_OPTIONS = {
|
DEFAULT_OCTAVE = 4
|
||||||
"octave": 0,
|
|
||||||
"duration": 0.25
|
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 = {
|
MODIFIERS = {
|
||||||
"#": 1,
|
"#": 1,
|
||||||
@ -51,15 +80,7 @@ MODIFIERS = {
|
|||||||
"s": 1,
|
"s": 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
ROMANS = {
|
ROMANS = {"i": 1, "v": 5, "x": 10, "l": 50, "c": 100, "d": 500, "m": 1000}
|
||||||
'i': 1,
|
|
||||||
'v': 5,
|
|
||||||
'x': 10,
|
|
||||||
'l': 50,
|
|
||||||
'c': 100,
|
|
||||||
'd': 500,
|
|
||||||
'm': 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
# pylint: disable=locally-disabled, too-many-lines
|
# pylint: disable=locally-disabled, too-many-lines
|
||||||
|
|
||||||
@ -1555,3 +1576,90 @@ SCALES = {
|
|||||||
"Thydatic": 12111111111,
|
"Thydatic": 12111111111,
|
||||||
"Chromatic": 111111111111,
|
"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()
|
||||||
|
|||||||
@ -30,7 +30,7 @@ from .classes import (
|
|||||||
)
|
)
|
||||||
from .common import flatten, sum_dict
|
from .common import flatten, sum_dict
|
||||||
from .defaults import DEFAULT_DURS
|
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
|
# pylint: disable=locally-disabled, unused-argument, too-many-public-methods, invalid-name
|
||||||
@ -114,8 +114,9 @@ class ZiffersTransformer(Transformer):
|
|||||||
numeral = items[0].value
|
numeral = items[0].value
|
||||||
if len(items)>1:
|
if len(items)>1:
|
||||||
name = items[1]
|
name = items[1]
|
||||||
return RomanNumeral(text=numeral, value=parse_roman(numeral), chord_type=name)
|
notes = chord_from_roman_numeral(numeral,name)
|
||||||
return RomanNumeral(value=parse_roman(numeral), text=numeral)
|
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):
|
def chord_name(self,item):
|
||||||
"""Return name for chord"""
|
"""Return name for chord"""
|
||||||
|
|||||||
177
ziffers/scale.py
177
ziffers/scale.py
@ -3,10 +3,49 @@
|
|||||||
# pylint: disable=locally-disabled, no-name-in-module
|
# pylint: disable=locally-disabled, no-name-in-module
|
||||||
import re
|
import re
|
||||||
from math import floor
|
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
|
"""Parse note name to midi
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -21,7 +60,7 @@ def note_to_midi(name: str) -> int:
|
|||||||
values = items.groups()
|
values = items.groups()
|
||||||
octave = int(values[2]) if values[2] else 4
|
octave = int(values[2]) if values[2] else 4
|
||||||
modifier = MODIFIERS[values[1]] if values[1] else 0
|
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
|
return 12 + octave * 12 + interval + modifier
|
||||||
|
|
||||||
|
|
||||||
@ -62,7 +101,7 @@ def note_from_pc(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Initialization
|
# 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 = get_scale(intervals) if isinstance(intervals, str) else intervals
|
||||||
intervals = list(map(lambda x: x / 100), intervals) if cents else intervals
|
intervals = list(map(lambda x: x / 100), intervals) if cents else intervals
|
||||||
scale_length = len(intervals)
|
scale_length = len(intervals)
|
||||||
@ -84,8 +123,15 @@ def note_from_pc(
|
|||||||
return note + (octave * sum(intervals)) + modifier
|
return note + (octave * sum(intervals)) + modifier
|
||||||
|
|
||||||
|
|
||||||
def parse_roman(numeral: str):
|
def parse_roman(numeral: str) -> int:
|
||||||
"""Parse roman numeral from string"""
|
"""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]
|
values = [ROMANS[val] for val in numeral]
|
||||||
result = 0
|
result = 0
|
||||||
i = 0
|
i = 0
|
||||||
@ -97,3 +143,122 @@ def parse_roman(numeral: str):
|
|||||||
result += values[i]
|
result += values[i]
|
||||||
i += 1
|
i += 1
|
||||||
return result
|
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
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
// Chords
|
// Chords
|
||||||
chord: pitch_class pitch_class+
|
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]+/
|
chord_name: /[a-zA-Z0-9]+/
|
||||||
?roman_number: /iv|v|v?i{1,3}/
|
?roman_number: /iv|v|v?i{1,3}/
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user