Spaces:
Sleeping
Sleeping
# encoding: utf-8 | |
""" | |
This module contains chord evaluation functionality. | |
It provides the evaluation measures used for the MIREX ACE task, and | |
tries to follow [1]_ and [2]_ as closely as possible. | |
Notes | |
----- | |
This implementation tries to follow the references and their implementation | |
(e.g., https://github.com/jpauwels/MusOOEvaluator for [2]_). However, there | |
are some known (and possibly some unknown) differences. If you find one not | |
listed in the following, please file an issue: | |
- Detected chord segments are adjusted to fit the length of the annotations. | |
In particular, this means that, if necessary, filler segments of 'no chord' | |
are added at beginnings and ends. This can result in different segmentation | |
scores compared to the original implementation. | |
References | |
---------- | |
.. [1] Christopher Harte, "Towards Automatic Extraction of Harmony Information | |
from Music Signals." Dissertation, | |
Department for Electronic Engineering, Queen Mary University of London, | |
2010. | |
.. [2] Johan Pauwels and Geoffroy Peeters. | |
"Evaluating Automatically Estimated Chord Sequences." | |
In Proceedings of ICASSP 2013, Vancouver, Canada, 2013. | |
""" | |
import numpy as np | |
import pandas as pd | |
CHORD_DTYPE = [('root', np.int_), | |
('bass', np.int_), | |
('intervals', np.int_, (12,)), | |
('is_major',np.bool_)] | |
CHORD_ANN_DTYPE = [('start', np.float32), | |
('end', np.float32), | |
('chord', CHORD_DTYPE)] | |
NO_CHORD = (-1, -1, np.zeros(12, dtype=np.int_), False) | |
UNKNOWN_CHORD = (-1, -1, np.ones(12, dtype=np.int_) * -1, False) | |
PITCH_CLASS = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] | |
def idx_to_chord(idx): | |
if idx == 24: | |
return "-" | |
elif idx == 25: | |
return u"\u03B5" | |
minmaj = idx % 2 | |
root = idx // 2 | |
return PITCH_CLASS[root] + ("M" if minmaj == 0 else "m") | |
class Chords: | |
def __init__(self): | |
self._shorthands = { | |
'maj': self.interval_list('(1,3,5)'), | |
'min': self.interval_list('(1,b3,5)'), | |
'dim': self.interval_list('(1,b3,b5)'), | |
'aug': self.interval_list('(1,3,#5)'), | |
'maj7': self.interval_list('(1,3,5,7)'), | |
'min7': self.interval_list('(1,b3,5,b7)'), | |
'7': self.interval_list('(1,3,5,b7)'), | |
'6': self.interval_list('(1,6)'), # custom | |
'5': self.interval_list('(1,5)'), | |
'4': self.interval_list('(1,4)'), # custom | |
'1': self.interval_list('(1)'), | |
'dim7': self.interval_list('(1,b3,b5,bb7)'), | |
'hdim7': self.interval_list('(1,b3,b5,b7)'), | |
'minmaj7': self.interval_list('(1,b3,5,7)'), | |
'maj6': self.interval_list('(1,3,5,6)'), | |
'min6': self.interval_list('(1,b3,5,6)'), | |
'9': self.interval_list('(1,3,5,b7,9)'), | |
'maj9': self.interval_list('(1,3,5,7,9)'), | |
'min9': self.interval_list('(1,b3,5,b7,9)'), | |
'add9': self.interval_list('(1,3,5,9)'), # custom | |
'sus2': self.interval_list('(1,2,5)'), | |
'sus4': self.interval_list('(1,4,5)'), | |
'7sus2': self.interval_list('(1,2,5,b7)'), # custom | |
'7sus4': self.interval_list('(1,4,5,b7)'), # custom | |
'11': self.interval_list('(1,3,5,b7,9,11)'), | |
'min11': self.interval_list('(1,b3,5,b7,9,11)'), | |
'13': self.interval_list('(1,3,5,b7,13)'), | |
'maj13': self.interval_list('(1,3,5,7,13)'), | |
'min13': self.interval_list('(1,b3,5,b7,13)') | |
} | |
def chords(self, labels): | |
""" | |
Transform a list of chord labels into an array of internal numeric | |
representations. | |
Parameters | |
---------- | |
labels : list | |
List of chord labels (str). | |
Returns | |
------- | |
chords : numpy.array | |
Structured array with columns 'root', 'bass', and 'intervals', | |
containing a numeric representation of chords. | |
""" | |
crds = np.zeros(len(labels), dtype=CHORD_DTYPE) | |
cache = {} | |
for i, lbl in enumerate(labels): | |
cv = cache.get(lbl, None) | |
if cv is None: | |
cv = self.chord(lbl) | |
cache[lbl] = cv | |
crds[i] = cv | |
return crds | |
def label_error_modify(self, label): | |
if label == 'Emin/4': label = 'E:min/4' | |
elif label == 'A7/3': label = 'A:7/3' | |
elif label == 'Bb7/3': label = 'Bb:7/3' | |
elif label == 'Bb7/5': label = 'Bb:7/5' | |
elif label.find(':') == -1: | |
if label.find('min') != -1: | |
label = label[:label.find('min')] + ':' + label[label.find('min'):] | |
return label | |
def chord(self, label): | |
""" | |
Transform a chord label into the internal numeric represenation of | |
(root, bass, intervals array). | |
Parameters | |
---------- | |
label : str | |
Chord label. | |
Returns | |
------- | |
chord : tuple | |
Numeric representation of the chord: (root, bass, intervals array). | |
""" | |
is_major = False | |
if label == 'N': | |
return NO_CHORD | |
if label == 'X': | |
return UNKNOWN_CHORD | |
label = self.label_error_modify(label) | |
c_idx = label.find(':') | |
s_idx = label.find('/') | |
if c_idx == -1: | |
quality_str = 'maj' | |
if s_idx == -1: | |
root_str = label | |
bass_str = '' | |
else: | |
root_str = label[:s_idx] | |
bass_str = label[s_idx + 1:] | |
else: | |
root_str = label[:c_idx] | |
if s_idx == -1: | |
quality_str = label[c_idx + 1:] | |
bass_str = '' | |
else: | |
quality_str = label[c_idx + 1:s_idx] | |
bass_str = label[s_idx + 1:] | |
root = self.pitch(root_str) | |
bass = self.interval(bass_str) if bass_str else 0 | |
ivs = self.chord_intervals(quality_str) | |
ivs[bass] = 1 | |
if 'min' in quality_str: | |
is_major = False | |
else: | |
is_major = True | |
return root, bass, ivs, is_major | |
_l = [0, 1, 1, 0, 1, 1, 1] | |
_chroma_id = (np.arange(len(_l) * 2) + 1) + np.array(_l + _l).cumsum() - 1 | |
def modify(self, base_pitch, modifier): | |
""" | |
Modify a pitch class in integer representation by a given modifier string. | |
A modifier string can be any sequence of 'b' (one semitone down) | |
and '#' (one semitone up). | |
Parameters | |
---------- | |
base_pitch : int | |
Pitch class as integer. | |
modifier : str | |
String of modifiers ('b' or '#'). | |
Returns | |
------- | |
modified_pitch : int | |
Modified root note. | |
""" | |
for m in modifier: | |
if m == 'b': | |
base_pitch -= 1 | |
elif m == '#': | |
base_pitch += 1 | |
else: | |
raise ValueError('Unknown modifier: {}'.format(m)) | |
return base_pitch | |
def pitch(self, pitch_str): | |
""" | |
Convert a string representation of a pitch class (consisting of root | |
note and modifiers) to an integer representation. | |
Parameters | |
---------- | |
pitch_str : str | |
String representation of a pitch class. | |
Returns | |
------- | |
pitch : int | |
Integer representation of a pitch class. | |
""" | |
return self.modify(self._chroma_id[(ord(pitch_str[0]) - ord('C')) % 7], | |
pitch_str[1:]) % 12 | |
def interval(self, interval_str): | |
""" | |
Convert a string representation of a musical interval into a pitch class | |
(e.g. a minor seventh 'b7' into 10, because it is 10 semitones above its | |
base note). | |
Parameters | |
---------- | |
interval_str : str | |
Musical interval. | |
Returns | |
------- | |
pitch_class : int | |
Number of semitones to base note of interval. | |
""" | |
for i, c in enumerate(interval_str): | |
if c.isdigit(): | |
return self.modify(self._chroma_id[int(interval_str[i:]) - 1], | |
interval_str[:i]) % 12 | |
def interval_list(self, intervals_str, given_pitch_classes=None): | |
""" | |
Convert a list of intervals given as string to a binary pitch class | |
representation. For example, 'b3, 5' would become | |
[0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0]. | |
Parameters | |
---------- | |
intervals_str : str | |
List of intervals as comma-separated string (e.g. 'b3, 5'). | |
given_pitch_classes : None or numpy array | |
If None, start with empty pitch class array, if numpy array of length | |
12, this array will be modified. | |
Returns | |
------- | |
pitch_classes : numpy array | |
Binary pitch class representation of intervals. | |
""" | |
if given_pitch_classes is None: | |
given_pitch_classes = np.zeros(12, dtype=np.int_) | |
for int_def in intervals_str[1:-1].split(','): | |
int_def = int_def.strip() | |
if int_def[0] == '*': | |
given_pitch_classes[self.interval(int_def[1:])] = 0 | |
else: | |
given_pitch_classes[self.interval(int_def)] = 1 | |
return given_pitch_classes | |
# mapping of shorthand interval notations to the actual interval representation | |
def chord_intervals(self, quality_str): | |
""" | |
Convert a chord quality string to a pitch class representation. For | |
example, 'maj' becomes [1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0]. | |
Parameters | |
---------- | |
quality_str : str | |
String defining the chord quality. | |
Returns | |
------- | |
pitch_classes : numpy array | |
Binary pitch class representation of chord quality. | |
""" | |
list_idx = quality_str.find('(') | |
if list_idx == -1: | |
return self._shorthands[quality_str].copy() | |
if list_idx != 0: | |
ivs = self._shorthands[quality_str[:list_idx]].copy() | |
else: | |
ivs = np.zeros(12, dtype=np.int_) | |
return self.interval_list(quality_str[list_idx:], ivs) | |
def load_chords(self, filename): | |
""" | |
Load chords from a text file. | |
The chord must follow the syntax defined in [1]_. | |
Parameters | |
---------- | |
filename : str | |
File containing chord segments. | |
Returns | |
------- | |
crds : numpy structured array | |
Structured array with columns "start", "end", and "chord", | |
containing the beginning, end, and chord definition of chord | |
segments. | |
References | |
---------- | |
.. [1] Christopher Harte, "Towards Automatic Extraction of Harmony | |
Information from Music Signals." Dissertation, | |
Department for Electronic Engineering, Queen Mary University of | |
London, 2010. | |
""" | |
start, end, chord_labels = [], [], [] | |
with open(filename, 'r') as f: | |
for line in f: | |
if line: | |
splits = line.split() | |
if len(splits) == 3: | |
s = splits[0] | |
e = splits[1] | |
l = splits[2] | |
start.append(float(s)) | |
end.append(float(e)) | |
chord_labels.append(l) | |
crds = np.zeros(len(start), dtype=CHORD_ANN_DTYPE) | |
crds['start'] = start | |
crds['end'] = end | |
crds['chord'] = self.chords(chord_labels) | |
return crds | |
def reduce_to_triads(self, chords, keep_bass=False): | |
""" | |
Reduce chords to triads. | |
The function follows the reduction rules implemented in [1]_. If a chord | |
chord does not contain a third, major second or fourth, it is reduced to | |
a power chord. If it does not contain neither a third nor a fifth, it is | |
reduced to a single note "chord". | |
Parameters | |
---------- | |
chords : numpy structured array | |
Chords to be reduced. | |
keep_bass : bool | |
Indicates whether to keep the bass note or set it to 0. | |
Returns | |
------- | |
reduced_chords : numpy structured array | |
Chords reduced to triads. | |
References | |
---------- | |
.. [1] Johan Pauwels and Geoffroy Peeters. | |
"Evaluating Automatically Estimated Chord Sequences." | |
In Proceedings of ICASSP 2013, Vancouver, Canada, 2013. | |
""" | |
unison = chords['intervals'][:, 0].astype(bool) | |
maj_sec = chords['intervals'][:, 2].astype(bool) | |
min_third = chords['intervals'][:, 3].astype(bool) | |
maj_third = chords['intervals'][:, 4].astype(bool) | |
perf_fourth = chords['intervals'][:, 5].astype(bool) | |
dim_fifth = chords['intervals'][:, 6].astype(bool) | |
perf_fifth = chords['intervals'][:, 7].astype(bool) | |
aug_fifth = chords['intervals'][:, 8].astype(bool) | |
no_chord = (chords['intervals'] == NO_CHORD[-1]).all(axis=1) | |
reduced_chords = chords.copy() | |
ivs = reduced_chords['intervals'] | |
ivs[~no_chord] = self.interval_list('(1)') | |
ivs[unison & perf_fifth] = self.interval_list('(1,5)') | |
ivs[~perf_fourth & maj_sec] = self._shorthands['sus2'] | |
ivs[perf_fourth & ~maj_sec] = self._shorthands['sus4'] | |
ivs[min_third] = self._shorthands['min'] | |
ivs[min_third & aug_fifth & ~perf_fifth] = self.interval_list('(1,b3,#5)') | |
ivs[min_third & dim_fifth & ~perf_fifth] = self._shorthands['dim'] | |
ivs[maj_third] = self._shorthands['maj'] | |
ivs[maj_third & dim_fifth & ~perf_fifth] = self.interval_list('(1,3,b5)') | |
ivs[maj_third & aug_fifth & ~perf_fifth] = self._shorthands['aug'] | |
if not keep_bass: | |
reduced_chords['bass'] = 0 | |
else: | |
# remove bass notes if they are not part of the intervals anymore | |
reduced_chords['bass'] *= ivs[range(len(reduced_chords)), | |
reduced_chords['bass']] | |
# keep -1 in bass for no chords | |
reduced_chords['bass'][no_chord] = -1 | |
return reduced_chords | |
def convert_to_id(self, root, is_major): | |
if root == -1: | |
return 24 | |
else: | |
if is_major: | |
return root * 2 | |
else: | |
return root * 2 + 1 | |
def get_converted_chord(self, filename): | |
loaded_chord = self.load_chords(filename) | |
triads = self.reduce_to_triads(loaded_chord['chord']) | |
df = self.assign_chord_id(triads) | |
df['start'] = loaded_chord['start'] | |
df['end'] = loaded_chord['end'] | |
return df | |
def assign_chord_id(self, entry): | |
# maj, min chord only | |
# if you want to add other chord, change this part and get_converted_chord(reduce_to_triads) | |
df = pd.DataFrame(data=entry[['root', 'is_major']]) | |
df['chord_id'] = df.apply(lambda row: self.convert_to_id(row['root'], row['is_major']), axis=1) | |
return df | |
def convert_to_id_voca(self, root, quality): | |
if root == -1: | |
return 169 | |
else: | |
if quality == 'min': | |
return root * 14 | |
elif quality == 'maj': | |
return root * 14 + 1 | |
elif quality == 'dim': | |
return root * 14 + 2 | |
elif quality == 'aug': | |
return root * 14 + 3 | |
elif quality == 'min6': | |
return root * 14 + 4 | |
elif quality == 'maj6': | |
return root * 14 + 5 | |
elif quality == 'min7': | |
return root * 14 + 6 | |
elif quality == 'minmaj7': | |
return root * 14 + 7 | |
elif quality == 'maj7': | |
return root * 14 + 8 | |
elif quality == '7': | |
return root * 14 + 9 | |
elif quality == 'dim7': | |
return root * 14 + 10 | |
elif quality == 'hdim7': | |
return root * 14 + 11 | |
elif quality == 'sus2': | |
return root * 14 + 12 | |
elif quality == 'sus4': | |
return root * 14 + 13 | |
else: | |
return 168 | |
def lab_file_error_modify(self, ref_labels): | |
for i in range(len(ref_labels)): | |
if ref_labels[i][-2:] == ':4': | |
ref_labels[i] = ref_labels[i].replace(':4', ':sus4') | |
elif ref_labels[i][-2:] == ':6': | |
ref_labels[i] = ref_labels[i].replace(':6', ':maj6') | |
elif ref_labels[i][-4:] == ':6/2': | |
ref_labels[i] = ref_labels[i].replace(':6/2', ':maj6/2') | |
elif ref_labels[i] == 'Emin/4': | |
ref_labels[i] = 'E:min/4' | |
elif ref_labels[i] == 'A7/3': | |
ref_labels[i] = 'A:7/3' | |
elif ref_labels[i] == 'Bb7/3': | |
ref_labels[i] = 'Bb:7/3' | |
elif ref_labels[i] == 'Bb7/5': | |
ref_labels[i] = 'Bb:7/5' | |
elif ref_labels[i].find(':') == -1: | |
if ref_labels[i].find('min') != -1: | |
ref_labels[i] = ref_labels[i][:ref_labels[i].find('min')] + ':' + ref_labels[i][ref_labels[i].find('min'):] | |
return ref_labels | |