import pytest from chordparser.analysers.chords_analyser import ChordAnalyser from chordparser.editors.chords_editor import ChordEditor from chordparser.editors.keys_editor import KeyEditor from chordparser.editors.notes_editor import NoteEditor from chordparser.editors.scales_editor import ScaleEditor NE = NoteEditor() KE = KeyEditor() SE = ScaleEditor() CE = ChordEditor() CA = ChordAnalyser() @pytest.mark.parametrize( "chord, mode, incl_submodes, result", [ ("C", "major", True, [('I', 'major', None)]), ("C7", "major", False, [('I7', 'major', None)]), ("Cm", "minor", False, [('i', 'minor', 'natural')]), ("Cm", "minor", True, [('i', 'minor', 'natural'), ('i', 'minor', 'harmonic'), ('i', 'minor', 'melodic')]), ] ) def test_diatonic(chord, mode, incl_submodes, result): c = CE.create_chord(chord) s = SE.create_scale("C", mode) assert CA.analyse_diatonic(c, s, incl_submodes) == result @pytest.mark.parametrize(
class ChordRomanConverter: """A `Chord`-`Roman` converter. The `ChordRomanConverter` can convert `Chords` to `Romans` based on a `Scale` or `Key`. """ _symbols = { -1: '\u266d', -2: '\U0001D12B', +1: '\u266f', +2: '\U0001D12A', 0: '', } _inversions = { 3: (6, ), 5: (6, 4), } _inversions_ext = { 3: (6, 5), 5: (4, 3), 7: (4, 2), } _roman_deg = { 1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V', 6: 'VI', 7: 'VII', } _q_dict = { 'diminished': '\u00B0', 'augmented': '+', 'half-diminished': '\u00f8', } _NE = NoteEditor() _CE = ChordEditor() _SE = ScaleEditor() def to_roman(self, chord, scale_key): """Converts a `Chord` to `Roman`. Creates the `Roman` based on the `Chord` and a `Scale` or `Key`. Parameters ---------- chord : Chord The `Chord` to be converted. scale_key : Scale or Key The `Scale` or `Key` to base the `Roman` on. Returns ------- Roman The `Roman` of the `Chord`. Warns ----- UserWarning If the `Chord` is a power or sus chord. Examples -------- >>> KE = KeyEditor() >>> SE = ScaleEditor() >>> CE = ChordEditor() >>> CRC = ChordRomanConverter() >>> c_key = KE.create_key("C") >>> c_scale = SE.create_scale(c_key) >>> d = CE.create_diatonic(c_scale, 2) >>> CRC.to_roman(d, c_key) ii roman chord >>> f = CE.create_diatonic(c_scale, 4) >>> CRC.to_roman(f, c_scale) IV roman chord """ if chord.quality.value in {"power", "sus2", "sus4"}: warnings.warn( "Warning: Power and sus chords are defaulted to major chords", UserWarning) chord = self._CE.change_chord(chord, quality="Maj", inplace=False) if isinstance(scale_key, Scale): scale_root = scale_key.key.root else: scale_root = scale_key.root scale = self._SE.create_scale(scale_root, "major") root = self._get_roman_root(chord, scale) quality = self._get_roman_quality(chord) inversion = self._get_roman_inversion(chord) return Roman(root, quality, inversion) def _get_roman_root(self, chord, scale): """Get Roman notation of the chord root.""" degree = min( scale.notes.index(x) for x in scale.notes if x.letter == chord.root.letter) + 1 # lower/uppercase numeral if chord.quality.value in {"major", "augmented", "dominant"}: quality_fn = str.upper else: quality_fn = str.lower root = quality_fn(ChordRomanConverter._roman_deg[degree]) (shift, ) = self._NE.get_min_intervals(scale.notes[degree - 1], chord.root) sym = ChordRomanConverter._symbols[shift] # accidental of root return sym + root def _get_roman_inversion(self, chord): """Get Roman inversion notation of the chord.""" notes = len(chord.base_notes) if chord.inversion and notes <= 4: # get inversion notation if chord.quality.ext: inv_dict = ChordRomanConverter._inversions_ext else: inv_dict = ChordRomanConverter._inversions return inv_dict.get(chord.inversion, (notes * 2 - 1, )) elif notes > 3: return (notes * 2 - 1, ) # chord extension return () def _get_roman_quality(self, chord): """Get Roman notation for chord quality.""" q_str = "" if chord.quality.ext and re.match("major", chord.quality.ext): q_str += "M" q_str += ChordRomanConverter._q_dict.get(chord.quality.value, "") return q_str
class Chord: """A musical class representing a chord. The `Chord` is composed of a `root` `Note`, `quality`, optional `add` `Notes` and an optional `bass` `Note`. It automatically builds its `notes` from these components. When printed, a standardised short notation meant for chord sheets is displayed. Parameters ---------- root : Note The root note. quality : Quality The `Chord` quality. add : list of (str, int), Optional List of added notes. The `str` is the accidental and the `int` is the scale degree of each added note. bass : Note, Optional Bass note. string : str, Optional The `Chord` notation string input. Attributes ---------- root : Note The root note. quality : Quality The `Chord` quality. add : list of (str, int), Optional List of added notes. bass : Note, Optional Bass note. string : str, Optional The `Chord` notation string input. base_intervals : tuple of int The intervals of the `Chord` solely based on its `quality`. base_degrees : tuple of int The scale degrees of the `Chord` solely based on its `quality`. base_symbols : tuple of str The accidentals of the `Chord` solely based on its `quality`. intervals : tuple of int The intervals of the `Chord`. degrees : tuple of int The scale degrees of the `Chord`. symbols : tuple of str The accidentals of the `Chord`. notes : tuple of Note The tuple of `Notes` in the `Chord`. """ _SE = ScaleEditor() _NE = NoteEditor() def __init__(self, root, quality, add=None, bass=None, string=None): self.root = root self.quality = quality self.add = add self.bass = bass self.string = string self.build() def build(self): """Build the `Chord` from its attributes. This method does not need to be used if `Chord` adjustments are done through the proper channels (i.e. `ChordEditor` or using other `Chord` methods), since those would build the `Chord` automatically. """ self._build_base_chord() self._build_full_chord() self._build_notation() def _build_base_chord(self): """Build the chord without any added or bass notes.""" self._base_scale = self._SE.create_scale(self.root.value) self.base_intervals = self.quality.intervals self.base_degrees = self.quality.degrees self.base_symbols = self.quality.symbols # get a copy of the root root = self._NE.create_note(self.root.value) notes = [root] idx = len(self.base_intervals) for i in range(idx): new = self._NE.create_note(notes[-1].value) new.transpose( self.base_intervals[i], self.base_degrees[i+1] - self.base_degrees[i] ) notes.append(new) self.base_notes = tuple(notes) def _build_full_chord(self): self.notes = list(self.base_notes) self.degrees = list(self.base_degrees) self.symbols = list(self.base_symbols) self._build_add() self._build_bass_note() self.notes = tuple(self.notes) self.degrees = tuple(self.degrees) self.symbols = tuple(self.symbols) self.intervals = self._NE.get_intervals(*self.notes) def _build_add(self): """Add notes for chords with added notes.""" if not self.add: return symbols = { '\u266d': -1, '\U0001D12B': -2, '\u266f': +1, '\U0001D12A': +2, '': 0, } for each in self.add: sym = each[0] tone = each[1] shift = symbols[sym] pos = max( self.degrees.index(i) for i in self.degrees if i < tone ) + 1 self.symbols.insert(pos, sym) self.degrees.insert(pos, tone) # copy the note new_note = self._NE.create_note(self._base_scale.notes[tone-1].value) new_note.shift_s(shift) self.notes.insert(pos, new_note) def _build_bass_note(self): """Build the bass note.""" self.inversion = None if not self.bass: return if self.bass in self.notes: idx = self.notes.index(self.bass) self.notes.insert(0, self.notes.pop(idx)) self.symbols.insert(0, self.symbols.pop(idx)) self.degrees.insert(0, self.degrees.pop(idx)) self.inversion = self.degrees[0] return self.notes.insert(0, self.bass) degree = min( self._base_scale.notes.index(x) for x in self._base_scale.notes if x.letter == self.bass.letter ) + 1 self.degrees.insert(0, degree) (shift,) = self._NE.get_min_intervals( self._base_scale.notes[degree-1], self.bass ) symbols = { -1: '\u266d', -2: '\U0001D12B', +1: '\u266f', +2: '\U0001D12A', 0: '', } sym = symbols[shift] self.symbols.insert(0, sym) def _build_notation(self): """Build a standardised chord notation.""" add = "" if self.add: for each in self.add: if not add and not str(self.quality): string = 'add' # when quality is blank elif not each[0]: string = 'add' # when there's no accidental else: string = '' add += string + each[0] + str(each[1]) if self.bass: bass = '/'+str(self.bass) else: bass = '' self._notation = str(self.root) + str(self.quality) + add + bass if not self.string: self.string = self._notation def transpose(self, semitones, letter): """Transpose a `Chord` according to semitone and letter intervals. Parameters ---------- semitones The difference in semitones to the new transposed `root` of the `Chord`. letters The difference in scale degrees to the new transposed `root` of the `Chord`. Examples -------- >>> CE = ChordEditor() >>> c = CE.create_chord("Csus") >>> c.transpose(6, 3) F\u266fsus chord >>> c.transpose(0, 1) G\u266dsus chord """ self.root.transpose(semitones, letter) if self.bass: self.bass.transpose(semitones, letter) self.build() return self def transpose_simple(self, semitones, use_flats=False): """Transpose a `Chord` according to semitone intervals. Parameters ---------- semitones : int The difference in semitones to the new transposed `root` of the `Chord`. use_flats : boolean, Optional Selector to use flats or sharps for black keys. Default False when optional. Examples -------- >>> CE = ChordEditor() >>> c = CE.create_chord("Cm") >>> c.transpose_simple(6) F\u266fm chord >>> c.transpose(2, use_flats=True) A\u266dm chord """ prev = self._NE.create_note(self.root.value) self.root.transpose_simple(semitones, use_flats) if self.bass: # bass has to be transposed exact! (diff,) = self._NE.get_tone_letter(prev, self.root) self.bass.transpose(*diff) self.build() return self def __repr__(self): return f'{self._notation} chord' def __str__(self): return self._notation def __eq__(self, other): """Compare between other `Chords`. Checks if the other `Chord` has the same attributes. Since the attributes and not the notation is being compared, `Chords` with different notation but same structure are equal (see ``Examples``). Parameters ---------- other The object to be compared with. Returns ------- boolean The outcome of the `value` comparison. Examples -------- >>> CE = ChordEditor() >>> d = CE.create_chord("Dsus") >>> d2 = CE.create_chord("Dsus4") >>> d == d2 True >>> d3 = CE.create_chord("Dsus2") >>> d == d3 False Another example of the same `Chord` with different notation: >>> CE = ChordEditor() >>> e = CE.create_chord("Eaug7") >>> e2 = CE.create_chord("E7#5") >>> e == e2 True """ if not isinstance(other, Chord): return NotImplemented return ( self.root == other.root and self.quality == other.quality and self.add == other.add and self.bass == other.bass )
class ChordAnalyser: """A `Chord` analyser. The ChordAnalyser can analyse `Chords` with reference to `Scales` and other `Chords` and find their relationships or functions. """ _mode_list = [ 'major', 'minor', 'dorian', 'mixolydian', 'lydian', 'phrygian', 'locrian', ] _CE = ChordEditor() _SE = ScaleEditor() _CRC = ChordRomanConverter() def analyse_diatonic( self, chord, scale, incl_submodes=False, allow_power_sus=False, default_power_sus="M", ): """Analyse if a `Chord` is diatonic to a `Scale`. There may be multiple tuples in the returned list if submodes are included. Parameters ---------- chord : Chord The `Chord` to be analysed. scale : Scale The `Scale` to check against. incl_submodes : boolean, Optional Selector to include the minor submodes if `scale` is minor. Default False when optional. allow_power_sus : boolean, Optional Selector to allow power and sus chords when analysing them. Default False when optional. default_power_sus : {"M", "m"}, Optional The default quality to convert power and sus chords to if analysing them. "M" is major and "m" is minor. Returns ------- list of (Roman, str, str) A list of information on the `Chord` if it is diatonic. The first `str` is the `Scale`'s `mode` and the second `str` is the `Scale`'s `submode`. The list is empty if the `Chord` is not diatonic. Examples -------- >>> CE = ChordEditor() >>> SE = ScaleEditor() >>> CA = ChordAnalyser() Diatonic chords >>> c_scale = SE.create_scale("C", "minor") >>> degree_1 = CE.create_diatonic(c_scale, 1) >>> CA.analyse_diatonic(degree_1, c_scale) [(i roman chord, 'minor', 'natural')] Checking against minor submodes >>> degree_7 = CE.create_chord("G") >>> CA.analyse_diatonic(degree_7, c_scale) [] >>> CA.analyse_diatonic(degree_7, c_scale, incl_submodes=True) [(VII roman chord, 'minor', 'harmonic'), (VII roman chord, 'minor', 'melodic')] Analysing power/sus chords >>> power = CE.create_chord("C5") >>> CA.analyse_diatonic(power, c_scale) [] >>> CA.analyse_diatonic(power, c_scale, allow_power_sus=True, default_power_sus="m") [(i roman chord, 'minor', 'natural')] """ if chord.quality.value in {"power", "sus2", "sus4"}: if not allow_power_sus: return [] chord = self._CE.change_chord(chord, quality=default_power_sus, inplace=False) if not incl_submodes: j = [scale.key.submode] elif scale.key.mode in {'minor', 'aeolian'}: j = ['natural', 'harmonic', 'melodic'] j.remove(scale.key.submode) j.insert(0, scale.key.submode) # shift to the front else: j = [None] chords = [] for submode in j: for i in range(7): nscale = self._SE.create_scale(scale.key.root, scale.key.mode, submode) diatonic = self._CE.create_diatonic(nscale, i + 1) if chord.base_notes[0:3] == diatonic.base_notes: chords.append( (self._CRC.to_roman(chord, nscale), nscale.key.mode, submode)) return chords def analyse_all( self, chord, scale, incl_submodes=False, allow_power_sus=False, default_power_sus="M", ): """Analyse if a `Chord` is diatonic to a `Scale` for any mode. The `Chord` is analysed against the `Scale` as well as the other modes of the `Scale`. Parameters ---------- chord : Chord The `Chord` to be analysed. scale : Scale The `Scale` to check against. incl_submodes : boolean, Optional Selector to include the minor submodes if `scale` is minor. Default False when optional. allow_power_sus : boolean, Optional Selector to allow power and sus chords when analysing them. Default False when optional. default_power_sus : {"M", "m"}, Optional The default quality to convert power and sus chords to if analysing them. "M" is major and "m" is minor. Returns ------- list of (Roman, str, str) A list of information on the `Chord` if it is diatonic. The first `str` is the `mode` of the scale it is diatonic to and the second `str` is the `submode`. The list is empty if the `Chord` is not diatonic. Examples -------- >>> CE = ChordEditor() >>> SE = ScaleEditor() >>> CA = ChordAnalyser() Diatonic chords >>> c_scale = SE.create_scale("C", "minor") >>> degree_1 = CE.create_diatonic(c_scale, 1) >>> CA.analyse_all(degree_1, c_scale) [(i roman chord, 'minor', 'natural'), (i roman chord, 'dorian', None), (i roman chord, 'phrygian', None)] Checking against minor submodes >>> degree_7 = CE.create_chord("G") >>> CA.analyse_diatonic(degree_7, c_scale) [(V roman chord, 'major', None), (V roman chord, 'lydian', None)] >>> CA.analyse_diatonic(degree_7, c_scale, incl_submodes=True) [(V roman chord, 'minor', 'harmonic'), (V roman chord, 'minor', 'melodic'), (V roman chord, 'major', None), (V roman chord, 'lydian', None)] Analysing power/sus chords >>> power = CE.create_chord("C5") >>> CA.analyse_diatonic(power, c_scale) [] >>> CA.analyse_diatonic(power, c_scale, allow_power_sus=True, default_power_sus="m") [(I roman chord, 'major', None), (I roman chord, 'mixolydian', None), (I roman chord, 'lydian', None)] """ self._mode_list.remove(scale.key.mode) self._mode_list.insert(0, scale.key.mode) # shift to the front chords = [] for mode in self._mode_list: nscale = self._SE.create_scale(scale.key.root, mode) result = self.analyse_diatonic(chord, nscale, incl_submodes, allow_power_sus, default_power_sus) if result: chords += result return chords def analyse_secondary( self, prev_chord, next_chord, scale, incl_submodes=False, allow_power_sus=False, default_power_sus="M", limit=True, ): """Analyse if a `Chord` has a secondary function. Check if a `Chord` is a secondary chord. By default, only secondary dominant and secondary leading tone chords are checked for. Parameters ---------- prev_chord : Chord The `Chord` to be analysed for secondary function. next_chord : Chord The `Chord` to be tonicised. scale : Scale The `Scale` to check against. incl_submodes : boolean, Optional Selector to include the minor submodes if `scale` is minor. Default False when optional. allow_power_sus : boolean, Optional Selector to allow power and sus chords when analysing them. Default False when optional. default_power_sus : {"M", "m"}, Optional The default quality to convert power and sus chords to if analysing them. "M" is major and "m" is minor. limit : boolean, Optional Selector to only check for secondary dominant and leading tone chords. Default True when optional. Returns ------- str The secondary chord notation ``prev_roman``/``next_roman``. Examples -------- >>> CE = ChordEditor() >>> SE = ScaleEditor() >>> CA = ChordAnalyser() Diatonic chords >>> c_scale = SE.create_scale("C") >>> g = CE.create_diatonic(c_scale, 5) >>> d = CE.create_chord("D") >>> CA.analyse_secondary(d, g, c_scale) 'V/V' Checking against minor submodes >>> vii = CE.create_chord("C#dim7") >>> degree_2 = CE.create_diatonic(c_scale, 2) >>> CA.analyse_secondary(vii, degree_2, c_scale) '' >>> CA.analyse_secondary(vii, degree_2, c_scale, incl_submodes=True) 'vii\u00B07/ii' Analysing power/sus chords >>> power = CE.create_chord("D5") >>> g = CE.create_diatonic(c_scale, 5) >>> CA.analyse_secondary(power, g, c_scale) '' >>> CA.analyse_secondary(power, g, c_scale, allow_power_sus=True) 'V/V' Analysing other secondary chords >>> e = CE.create_chord("Em") >>> CA.analyse_secondary(e, g, c_scale) '' >>> CA.analyse_secondary(e, g, c_scale, limit=False) 'vi/V' """ # We only care about chords leading to major/minor/dominant chords if next_chord.quality.value not in {"major", "minor", "dominant"}: return "" next_roman = self._CRC.to_roman(next_chord, scale) if next_roman.root in {"i", "I"}: # ignore tonic next chords return "" if next_chord.quality.value == "dominant": next_scale = self._SE.create_scale(next_chord.root, "major") else: next_scale = self._SE.create_scale(next_chord.root, next_chord.quality.value) results = self.analyse_diatonic(prev_chord, next_scale, incl_submodes, allow_power_sus, default_power_sus) if results and (not limit or results[0][0].root in {"V", "vii"}): return "{}/{}".format(results[0][0], next_roman.root) return ""