diff --git a/mir_eval/chord.py b/mir_eval/chord.py index 50360b05..65c2abba 100644 --- a/mir_eval/chord.py +++ b/mir_eval/chord.py @@ -177,6 +177,9 @@ def scale_degree_to_semitone(scale_degree): semitone : int Relative semitone of the scale degree, wrapped to a single octave + Raises + ------ + InvalidChordException if `scale_degree` is invalid. """ semitone = 0 offset = 0 @@ -190,11 +193,12 @@ def scale_degree_to_semitone(scale_degree): semitone = SCALE_DEGREES.get(scale_degree, None) if semitone is None: raise InvalidChordException( - "Scale degree improperly formed: %s" % scale_degree) + "Scale degree improperly formed: {}, expected one of {}." + .format(scale_degree, list(SCALE_DEGREES.keys()))) return semitone + offset -def scale_degree_to_bitmap(scale_degree): +def scale_degree_to_bitmap(scale_degree, modulo=False, length=BITMAP_LENGTH): """Create a bitmap representation of a scale degree. Note that values in the bitmap may be negative, indicating that the @@ -204,21 +208,25 @@ def scale_degree_to_bitmap(scale_degree): ---------- scale_degree : str Spelling of a relative scale degree, e.g. 'b3', '7', '#5' + modulo : bool, default=True + If a scale degree exceeds the length of the bit-vector, modulo the + scale degree back into the bit-vector; otherwise it is discarded. + length : int, default=12 + Length of the bit-vector to produce Returns ------- - bitmap : np.ndarray, in [-1, 0, 1] - Bitmap representation of this scale degree (12-dim). - + bitmap : np.ndarray, in [-1, 0, 1], len=`length` + Bitmap representation of this scale degree. """ sign = 1 if scale_degree.startswith("*"): sign = -1 scale_degree = scale_degree.strip("*") - edit_map = [0] * BITMAP_LENGTH + edit_map = [0] * length sd_idx = scale_degree_to_semitone(scale_degree) - if sd_idx < BITMAP_LENGTH: - edit_map[sd_idx % BITMAP_LENGTH] = sign + if sd_idx < length or modulo: + edit_map[sd_idx % length] = sign return np.array(edit_map) @@ -491,7 +499,8 @@ def encode(chord_label, reduce_extended_chords=False, semitone_bitmap[0] = 1 for scale_degree in scale_degrees: - semitone_bitmap += scale_degree_to_bitmap(scale_degree) + semitone_bitmap += scale_degree_to_bitmap(scale_degree, + reduce_extended_chords) semitone_bitmap = (semitone_bitmap > 0).astype(np.int) if not semitone_bitmap[bass_number] and strict_bass_intervals: diff --git a/tests/test_chord.py b/tests/test_chord.py index 9ca43a13..df85cf95 100644 --- a/tests/test_chord.py +++ b/tests/test_chord.py @@ -44,22 +44,47 @@ def test_pitch_class_to_semitone(): def test_scale_degree_to_semitone(): - valid_degrees = ['b7', '#3', '1', 'b1', '#7', 'bb5'] - valid_semitones = [10, 5, 0, -1, 12, 5] + valid_degrees = ['b7', '#3', '1', 'b1', '#7', 'bb5', '11', '#13'] + valid_semitones = [10, 5, 0, -1, 12, 5, 17, 22] for scale_degree, semitone in zip(valid_degrees, valid_semitones): yield (__check_valid, mir_eval.chord.scale_degree_to_semitone, (scale_degree,), semitone) - invalid_degrees = ['7b', '4#', '77'] + invalid_degrees = ['7b', '4#', '77', '15'] for scale_degree in invalid_degrees: yield (__check_exception, mir_eval.chord.scale_degree_to_semitone, (scale_degree,), mir_eval.chord.InvalidChordException) +def test_scale_degree_to_bitmap(): + + def __check_bitmaps(function, parameters, result): + actual = function(*parameters) + assert np.all(actual == result), (actual, result) + + valid_degrees = ['3', '*3', 'b1', '9'] + valid_bitmaps = [[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]] + + for scale_degree, bitmap in zip(valid_degrees, valid_bitmaps): + yield (__check_bitmaps, mir_eval.chord.scale_degree_to_bitmap, + (scale_degree, True, 12), np.array(bitmap)) + + yield (__check_bitmaps, mir_eval.chord.scale_degree_to_bitmap, + ('9', False, 12), np.array([0] * 12)) + + yield (__check_bitmaps, mir_eval.chord.scale_degree_to_bitmap, + ('9', False, 15), + np.array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1])) + + def test_validate_chord_label(): - valid_labels = ['C', 'Eb:min/5', 'A#:dim7', 'B:maj(*1,*5)/3', 'A#:sus4'] + valid_labels = ['C', 'Eb:min/5', 'A#:dim7', 'B:maj(*1,*5)/3', + 'A#:sus4', 'A:(9,11)'] # For valid labels, calling the function without an error = pass for chord_label in valid_labels: yield (mir_eval.chord.validate_chord_label, chord_label) @@ -139,33 +164,44 @@ def __check_bitmaps(bitmaps, roots, expected_bitmaps): def test_encode(): def __check_encode(label, expected_root, expected_intervals, - expected_bass): + expected_bass, reduce_extended_chords, + strict_bass_intervals): ''' Helper function for checking encode ''' - root, intervals, bass = mir_eval.chord.encode(label) - assert root == expected_root - assert np.all(intervals == expected_intervals) - assert bass == expected_bass - - labels = ['B:maj(*1,*3)/5', 'G:dim', 'C:(3)/3'] - expected_roots = [11, 7, 0] + root, intervals, bass = mir_eval.chord.encode( + label, reduce_extended_chords=reduce_extended_chords, + strict_bass_intervals=strict_bass_intervals) + assert root == expected_root, (root, expected_root) + assert np.all(intervals == expected_intervals), (intervals, + expected_intervals) + assert bass == expected_bass, (bass, expected_bass) + + labels = ['B:maj(*1,*3)/5', 'G:dim', 'C:(3)/3', 'A:9/b3'] + expected_roots = [11, 7, 0, 9] expected_intervals = [[0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0], [1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0], - [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]] - expected_bass = [7, 0, 4] + [1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0], + # Note that extended scale degrees are dropped. + [1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0]] + expected_bass = [7, 0, 4, 3] - for label, e_root, e_interval, e_bass in zip(labels, - expected_roots, - expected_intervals, - expected_bass): - yield (__check_encode, label, e_root, e_interval, e_bass) + args = zip(labels, expected_roots, expected_intervals, expected_bass) + for label, e_root, e_interval, e_bass in args: + yield (__check_encode, label, e_root, e_interval, e_bass, False, False) # Non-chord bass notes *must* be explicitly named as extensions when # strict_bass_intervals == True yield (__check_exception, mir_eval.chord.encode, ('G:dim(4)/6', False, True), mir_eval.chord.InvalidChordException) + # Otherwise, we can cut a little slack. yield (__check_encode, 'G:dim(4)/6', 7, - [1, 0, 0, 1, 0, 1, 1, 0, 0, 1, 0, 0], 9) + [1, 0, 0, 1, 0, 1, 1, 0, 0, 1, 0, 0], 9, + False, False) + + # Check that extended scale degrees are mapped back into pitch classes. + yield (__check_encode, 'A:9', 9, + [1, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0], 0, + True, False) def test_encode_many():