From a4e0115df0e644eafd2d903416c365bf5eec606f Mon Sep 17 00:00:00 2001 From: Alexey Strelkov Date: Thu, 6 Jan 2022 04:24:31 +0300 Subject: [PATCH 01/10] intervals stub --- README.md | 1 + pyscales/scales.py | 48 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c5a4c89..ab14fc3 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ True - intervals - addition and subtraction in scale - finding qualities of interval in scale (notation - https://en.wikipedia.org/wiki/Interval_(music)#Shorthand_notation, quality - https://en.wikipedia.org/wiki/Interval_(music)#Main_intervals) + - check interval between two notes in scale - add and subtract intervals within a context of a scale - chords with scales, chord names and notes they consist of (don't forget octaves and root notes) - https://en.wikipedia.org/wiki/List_of_chords - chords in given scales (similar to notes_in_scale method) diff --git a/pyscales/scales.py b/pyscales/scales.py index cbe252a..3c9768c 100644 --- a/pyscales/scales.py +++ b/pyscales/scales.py @@ -1,4 +1,5 @@ from copy import copy +from enum import Enum from .primitives import Note, NoteArray @@ -87,4 +88,49 @@ def scale_name(self): def __str__(self): # TODO: change flats and sharps so note names will be unique across scale - return str(self.notes_in_scale()) \ No newline at end of file + return str(self.notes_in_scale()) + + +class IntervalQuality(Enum): + PERFECT = 0 + MINOR = 1 + MAJOR = 2 + DIMINISHED = 3 + AUGMENTED = 4 + +# todo: also add scientific notations +INTERVAL_QUALITY_NAMES = { + IntervalQuality.PERFECT: "Perfect", + IntervalQuality.MINOR: "Minor", + IntervalQuality.MAJOR: "Major", + IntervalQuality.DIMINISHED: "Diminished", + IntervalQuality.AUGMENTED: "Augmented", +} + +# TODO: interval names. They depend on staff poisition and quality + + +class Interval: + + # see https://en.wikipedia.org/wiki/Interval_(music)#Main_intervals + + def __init__(self, staff_positions:int, scale: Scale): + """ + Staff positions - number of positions in scale between notes. + see. https://en.wikipedia.org/wiki/Staff_(music)#Staff_positions + """ + self.staff_positions: int = staff_positions + self.scale: Scale = scale + + def __add__(self, other): + pass + + def __sub__(self, other): + pass + + def __str__(self): + return f"Unidentified quality {self.staff_positions}th" + + @property + def quality(self) -> IntervalQuality: + pass \ No newline at end of file From a5a7fcf92940d087915408d6f3041cd2d4e98a5d Mon Sep 17 00:00:00 2001 From: Alexey Strelkov Date: Thu, 6 Jan 2022 22:17:22 +0300 Subject: [PATCH 02/10] intervals support, not yet tested properly --- README.md | 4 +- pyscales/scales.py | 168 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 163 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ab14fc3..38d4712 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,8 @@ True - finding qualities of interval in scale (notation - https://en.wikipedia.org/wiki/Interval_(music)#Shorthand_notation, quality - https://en.wikipedia.org/wiki/Interval_(music)#Main_intervals) - check interval between two notes in scale - add and subtract intervals within a context of a scale -- chords with scales, chord names and notes they consist of (don't forget octaves and root notes) - https://en.wikipedia.org/wiki/List_of_chords -- chords in given scales (similar to notes_in_scale method) +- chords with scales, chord names and notes they consist of (don't forget octaves and root notes) - https://en.wikipedia.org/wiki/List_of_chords - also https://en.wikipedia.org/wiki/Interval_(music)#Chord_qualities_and_interval_qualities +- chords in given scales (similar to notes_in_scale method) - chord transposing by adding or subtracting tonedeltas - init chord from given notes (check if matches any formula) - find nearest note to a given frequency diff --git a/pyscales/scales.py b/pyscales/scales.py index 3c9768c..835eafb 100644 --- a/pyscales/scales.py +++ b/pyscales/scales.py @@ -1,5 +1,6 @@ from copy import copy from enum import Enum +from typing import Optional from .primitives import Note, NoteArray @@ -90,6 +91,10 @@ def __str__(self): # TODO: change flats and sharps so note names will be unique across scale return str(self.notes_in_scale()) + @property + def all_notes(self): + return self._all_notes + class IntervalQuality(Enum): PERFECT = 0 @@ -98,6 +103,15 @@ class IntervalQuality(Enum): DIMINISHED = 3 AUGMENTED = 4 + def name(self): + return INTERVAL_QUALITY_NAMES[self] + + def short_name(self): + return INTERVAL_QUALITY_SHORT_NAME[self] + + def notation(self): + return INTERVAL_QUALITY_NOTATION[self] + # todo: also add scientific notations INTERVAL_QUALITY_NAMES = { IntervalQuality.PERFECT: "Perfect", @@ -107,30 +121,170 @@ class IntervalQuality(Enum): IntervalQuality.AUGMENTED: "Augmented", } -# TODO: interval names. They depend on staff poisition and quality +INTERVAL_QUALITY_SHORT_NAME = { + IntervalQuality.PERFECT: "perf", + IntervalQuality.MINOR: "min", + IntervalQuality.MAJOR: "maj", + IntervalQuality.DIMINISHED: "dim", + IntervalQuality.AUGMENTED: "aug", +} + + +# see https://en.wikipedia.org/wiki/Interval_(music)#Shorthand_notation +INTERVAL_QUALITY_NOTATION = { + IntervalQuality.PERFECT: "P", + IntervalQuality.MINOR: "m", + IntervalQuality.MAJOR: "M", + IntervalQuality.DIMINISHED: "d", + IntervalQuality.AUGMENTED: "A", +} + +# interval quality map +# based on https://en.wikipedia.org/wiki/Interval_(music)#Main_intervals +# They depend on staff position and quality +# there is logic behind it but its kind of complicated so it's easier to use this map +# to determine quality from staff positions and semitone difference. + +# anyway, I would be glad if there was a more elegant way to determine interval quality. +# if an interval is compound, subtract 12 from semitones and 7 from staff position difference +# to use this map (test this!) +# map usage: +# INTERVAL_QUALITY_MAP[semitone_difference][staff_position_difference] -> IntervalQuality +INTERVAL_QUALITY_MAP = { + # semitone difference as main dictionary key + 0: { + # staff position _difference_, zero-based (0 for unison, 1 for second degree, 7 for octave, etc.) + 0: IntervalQuality.PERFECT, # P1 - perfect unison + 1: IntervalQuality.DIMINISHED # d2 - diminished second + }, + 1: { + 1: IntervalQuality.MINOR, # m2 - minor second + 0: IntervalQuality.AUGMENTED # A1 - augmented unison (not sure how it's possible) + }, + 2: { + 1: IntervalQuality.MAJOR, # M2 - major second + 2: IntervalQuality.DIMINISHED # d3 - diminished third + }, + 3: { + 2: IntervalQuality.MINOR, # m3 - minor third + 1: IntervalQuality.AUGMENTED # A2 - augmented second + }, + 4: { + 2: IntervalQuality.MAJOR, # M3 - major third, + 3: IntervalQuality.DIMINISHED, # d4 - diminished fourth + }, + 5: { + 3: IntervalQuality.PERFECT, # P4 - perfect fourth + 2: IntervalQuality.AUGMENTED # A3 - augmented third + }, + 6: { + 4: IntervalQuality.DIMINISHED, # d5 - diminished 5th, tritone + 3: IntervalQuality.AUGMENTED, # A4 - augmented fourth, tritone + }, + 7: { + 4: IntervalQuality.PERFECT, #P5 - perfect fifth + 5: IntervalQuality.DIMINISHED, #d6 - diminished 6th + }, + 8: { + 5: IntervalQuality.MINOR, #m6 - minor sixth + 4: IntervalQuality.AUGMENTED, #A5 - augmented fifth + }, + 9: { + 5: IntervalQuality.MAJOR, #M6 - major sixth + 6: IntervalQuality.DIMINISHED, #d7 - diminished 7th + }, + 10: { + 6: IntervalQuality.MINOR, #m7 - minor seventh + 5: IntervalQuality.AUGMENTED #A6 - augented sixth + }, + 11: { + 6: IntervalQuality.MAJOR, #M7 - major seventh + 7: IntervalQuality.DIMINISHED #d8 - diminished octave + }, + 12: { + 7: IntervalQuality.PERFECT, #P8 - perfect octave + 6: IntervalQuality.AUGMENTED, #A7 - augmented seventh + } +} class Interval: # see https://en.wikipedia.org/wiki/Interval_(music)#Main_intervals + # also https://en.wikipedia.org/wiki/Interval_(music)#Compound_intervals - def __init__(self, staff_positions:int, scale: Scale): + def __init__(self, staff_positions: int, scale: Scale): """ - Staff positions - number of positions in scale between notes. + @staff_positions - difference in staff positions between notes. Zero-based. + E.g. to get a Perfect Fifth, set this to 4 see. https://en.wikipedia.org/wiki/Staff_(music)#Staff_positions """ self.staff_positions: int = staff_positions self.scale: Scale = scale + # can only be identified when we have at least one note to count interval from + # because quality depends on relation between staff positions and semitone difference between two notes + self.quality: Optional[IntervalQuality] = None + + def __add__(self, other): + raise NotImplementedError() pass def __sub__(self, other): + # FIXME: should it be in Note?.. should we use some method in this class when calling it + # from note so logic will live here? should we just call __add__ with negative value from this class? + raise NotImplementedError() pass + + def get_quantitative_name(self) -> str: + + # note that the interval name is 1-based, but the actual interval is 0-based + # e.g. unison is called Perfect Unison and is written as P1 although notes are + # 0 staff positions (and semitones) apart + + + return str(self.staff_positions+1) + + # +1 because 1 staff position above something is 2nd e.g. "minor 2nd" + def __str__(self): - return f"Unidentified quality {self.staff_positions}th" + # TODO: make long notation option? + return f"{self.quality.notation() if self.quality else '?'}{self.get_quantitative_name()}" - @property - def quality(self) -> IntervalQuality: - pass \ No newline at end of file + @classmethod + def assess_quality(cls, staff_position_difference, semitone_difference) -> IntervalQuality: + """ + @staff_positions - staff positions between notes in scale + @semitones - semitones between these notes + """ + # TODO: there should be an algorithmic way to do this without using a map + + if semitone_difference > 12: + # todo: shouldbe easy + raise NotImplementedError("implement compound interval support") + + try: + + quality = INTERVAL_QUALITY_MAP[staff_position_difference][semitone_difference] + + except KeyError: + raise ValueError(f"Cannot find interval quality corresponding to {semitone_difference} semitone " + f"difference and {staff_position_difference} staff (scale) position difference") + + return quality + + @classmethod + def between_notes(cls, note1: Note, note2: Note, scale: Scale): + """ + Returns an interval between two notes. + The returned interval has quality info. + """ + semitone_interval = (note1 - note2).semitones + staff_interval = scale.all_notes.index(note1) - scale.all_notes.index(note2) + + result = Interval(staff_interval, scale) + result.quality = cls.assess_quality(staff_interval, semitone_interval) + + return result From ffd94e4937e8d6a57814e216bfd206b117d43424 Mon Sep 17 00:00:00 2001 From: Alexey Strelkov Date: Fri, 7 Jan 2022 00:11:01 +0300 Subject: [PATCH 03/10] IntervalInScale stub --- README.md | 6 ++- pyscales/scales.py | 106 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 101 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 38d4712..ca12725 100644 --- a/README.md +++ b/README.md @@ -78,14 +78,16 @@ True ## TODOs - ~~modes~~ -- ~~ midi note numbers (see https://musicinformationretrieval.com/midi_conversion_table.html and https://www.inspiredacoustics.com/en/MIDI_note_numbers_and_center_frequencies)~~ +- ~~midi note numbers (see https://musicinformationretrieval.com/midi_conversion_table.html and https://www.inspiredacoustics.com/en/MIDI_note_numbers_and_center_frequencies)~~ - ~~init note from midi number~~ - intervals - addition and subtraction in scale - finding qualities of interval in scale (notation - https://en.wikipedia.org/wiki/Interval_(music)#Shorthand_notation, quality - https://en.wikipedia.org/wiki/Interval_(music)#Main_intervals) - check interval between two notes in scale -- add and subtract intervals within a context of a scale +- consonance and dissonance of intervals - chords with scales, chord names and notes they consist of (don't forget octaves and root notes) - https://en.wikipedia.org/wiki/List_of_chords - also https://en.wikipedia.org/wiki/Interval_(music)#Chord_qualities_and_interval_qualities +- consonance and dissonance of scales +- interval inversion - chords in given scales (similar to notes_in_scale method) - chord transposing by adding or subtracting tonedeltas - init chord from given notes (check if matches any formula) diff --git a/pyscales/scales.py b/pyscales/scales.py index 835eafb..32a7c72 100644 --- a/pyscales/scales.py +++ b/pyscales/scales.py @@ -208,23 +208,56 @@ def notation(self): } +class IntervalInScale: + """ + Use this to move up and down a scale by given number of staff positions. + Add or subtract this to your notes. + + Not sure what's the correct name for this - degree? Interval in scale? Step? + + This entity is not quality-aware + """ + + def __init__(self, staff_positions: int, scale: Scale): + self.staff_positions: int = staff_positions + self.scale: Scale = scale + + def __add__(self, other): + raise NotImplementedError() + pass + + def __sub__(self, other): + # FIXME: should it be in Note?.. should we use some method in this class when calling it + # from note so logic will live here? should we just call __add__ with negative value from this class? + raise NotImplementedError() + pass + + # there is no __eq__ overload as it does not seem to make a lot of sense to compare this entities + + class Interval: + """ + Interval between notes. + Characterized by quality. Not aware of scale. + """ # see https://en.wikipedia.org/wiki/Interval_(music)#Main_intervals # also https://en.wikipedia.org/wiki/Interval_(music)#Compound_intervals - def __init__(self, staff_positions: int, scale: Scale): + def __init__(self, staff_positions: int, quality: IntervalQuality): """ @staff_positions - difference in staff positions between notes. Zero-based. - E.g. to get a Perfect Fifth, set this to 4 + E.g. to get a Fifth, set this to 4 see. https://en.wikipedia.org/wiki/Staff_(music)#Staff_positions """ self.staff_positions: int = staff_positions - self.scale: Scale = scale + + + # todo: accept semitones instead of quality to determine it automatically and vica-versa # can only be identified when we have at least one note to count interval from # because quality depends on relation between staff positions and semitone difference between two notes - self.quality: Optional[IntervalQuality] = None + self.quality: IntervalQuality = quality def __add__(self, other): @@ -237,6 +270,10 @@ def __sub__(self, other): raise NotImplementedError() pass + def __eq__(self, other): + # todo: implement + raise NotImplementedError() + def get_quantitative_name(self) -> str: @@ -259,11 +296,26 @@ def assess_quality(cls, staff_position_difference, semitone_difference) -> Inter @staff_positions - staff positions between notes in scale @semitones - semitones between these notes """ - # TODO: there should be an algorithmic way to do this without using a map + + # basically it boils down to these rules: + # https://music.utk.edu/theorycomp/courses/murphy/documents/Intervals.pdf + # (thank you Dr. Barbara Murphy for a comprehensive explanation unlike + # most of you find on the internet) + + # Intervals are: + + # - perfect if: (1) the top note is in the major key of the bottom note AND (2) the + # - bottom note is in the major key of the top note. + # - major if the top note is in the major key of the bottom note. + # - minor if it is a half step smaller than major. + # - diminished if it is a half step smaller than minor or perfect. + # - augmented if it is a half step larger than major or perfect. + + # TODO: there should probably be an algorithmic way to do this without using a map that is easily computable if semitone_difference > 12: - # todo: shouldbe easy - raise NotImplementedError("implement compound interval support") + semitone_difference %= 12 + staff_position_difference %= 7 try: @@ -284,7 +336,43 @@ def between_notes(cls, note1: Note, note2: Note, scale: Scale): semitone_interval = (note1 - note2).semitones staff_interval = scale.all_notes.index(note1) - scale.all_notes.index(note2) - result = Interval(staff_interval, scale) - result.quality = cls.assess_quality(staff_interval, semitone_interval) + quality = cls.assess_quality(staff_interval, semitone_interval) + result = Interval(staff_interval, quality) return result + + +# https://music.utk.edu/theorycomp/courses/murphy/documents/Intervals.pdf +PERFECT_CONSONANT_INTERVALS = ( + Interval(0, IntervalQuality.PERFECT), # P1 + Interval(7, IntervalQuality.PERFECT), # P8 + Interval(4, IntervalQuality.PERFECT), # P5 + # P4 is weird so it's not added here: + # The P4 is sometimes consonant and sometimes dissonant. + # In early music, P4 was a consonance and with other perfect intervals made up + # most of music compositions. + # Later, when using complete triads, the 4th tended to lose some of its stability and + # consonance (sounded like active tone between 5th and 3rd). If it appeared + # near a 5th (its inversion), then it was stable. + # SO -- the P4 is a consonant when it functions as an inverted 5th (as part of the + # chord); otherwise, it is dissonant. +) + +IMPERFECT_CONSONANT_INTERVALS = ( + Interval(2, IntervalQuality.MINOR), # m3 + Interval(2, IntervalQuality.MAJOR), # M3 + Interval(5, IntervalQuality.MINOR), # m6 + Interval(5, IntervalQuality.MAJOR), # M6 +) + +CONSONANT_INTERVALS = PERFECT_CONSONANT_INTERVALS + IMPERFECT_CONSONANT_INTERVALS + +DISSONANT_INTERVALS = ( + Interval(1, IntervalQuality.MINOR), # m2 + Interval(1, IntervalQuality.MAJOR), # M2 + Interval(6, IntervalQuality.MINOR), # m7 + Interval(6, IntervalQuality.MAJOR), # M7 + # tritones + Interval(4, IntervalQuality.DIMINISHED), # d5 - diminished 5th, tritone + Interval(3, IntervalQuality.AUGMENTED), # A4 - augmented 4th, tritone +) From c6179bf166c8e8ed48e8d79e41df216d8a0e8d94 Mon Sep 17 00:00:00 2001 From: Alexey Strelkov Date: Fri, 7 Jan 2022 02:16:03 +0300 Subject: [PATCH 04/10] operators overload implementation; updated Interval implementation --- pyscales/primitives.py | 20 +++- pyscales/scales.py | 204 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 199 insertions(+), 25 deletions(-) diff --git a/pyscales/primitives.py b/pyscales/primitives.py index 732f0b2..8da633d 100644 --- a/pyscales/primitives.py +++ b/pyscales/primitives.py @@ -100,6 +100,14 @@ def __eq__(self, other): def __add__(self, other): + from pyscales import IntervalInScale + from pyscales import Interval + if isinstance(other, IntervalInScale): + return other.add_to_note(self) + elif isinstance(other, Interval): + return other.add_to_note(self) + + # assume other is ToneDelta # TODO: cache this somewhere notes_order = NoteArray(NoteArray.DEFAULT_NOTE_ORDER) @@ -118,6 +126,9 @@ def __sub__(self, other): this_note_index = notes_order.index(self) + from pyscales import IntervalInScale + + from pyscales import Interval if hasattr(other, 'semitones'): return notes_order[this_note_index-other.semitones] @@ -128,7 +139,14 @@ def __sub__(self, other): return ToneDelta(this_note_index-other_not_index) - raise ValueError("Can only subtract ToneDelta or other Note") + elif isinstance(other, IntervalInScale): + + return other.subtract_from_note(self) + + elif isinstance(other, Interval): + return other.subtract_from_note(self) + + raise ValueError("Can only subtract ToneDelta, IntervalInScale or other Note") def __str__(self): diff --git a/pyscales/scales.py b/pyscales/scales.py index 32a7c72..061a979 100644 --- a/pyscales/scales.py +++ b/pyscales/scales.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Optional -from .primitives import Note, NoteArray +from .primitives import Note, NoteArray, ToneDelta class ScaleFormula: @@ -207,6 +207,52 @@ def notation(self): } } +# reverse map +INTERVAL_QUALITY_TO_SEMITONE_MAP = { + # quality + IntervalQuality.PERFECT: { + # staff positions # semitones + 0: 0, + 3: 5, + 4: 7, + 7: 12, + }, + IntervalQuality.MAJOR: { + # staff positions # semitones + 1: 2, + 2: 4, + 5: 9, + 6: 11, + }, + IntervalQuality.MINOR: { + # staff positions # semitones + 1: 1, + 2: 3, + 5: 8, + 6: 10, + }, + IntervalQuality.DIMINISHED: { + # staff positions # semitones + 1: 0, + 2: 2, + 3: 4, + 4: 6, + 5: 7, + 6: 9, + 7: 11, + }, + IntervalQuality.AUGMENTED: { + # staff positions # semitones + 0: 1, + 1: 3, + 2: 5, + 3: 6, + 4: 8, + 5: 10, + 6: 12, + } +} + class IntervalInScale: """ @@ -223,14 +269,54 @@ def __init__(self, staff_positions: int, scale: Scale): self.scale: Scale = scale def __add__(self, other): - raise NotImplementedError() - pass + + if isinstance(other, Note): + + try: + note_index = self.scale.all_notes.index(other) + + # return corresponding note from scale + return self.scale.all_notes[note_index+self.staff_positions].copy() + + except ValueError: + raise ValueError(f"Note {other} is not in scale {self.scale}") + + elif isinstance(other, IntervalInScale): + if other.scale == self.scale: + return IntervalInScale(staff_positions=self.staff_positions+other.staff_positions) + + raise ValueError("Cannot add two IntervalInScale objects with different scales") + + else: + raise ValueError("Can add IntervalInScale only to Note and other IntervalInScale") + def __sub__(self, other): - # FIXME: should it be in Note?.. should we use some method in this class when calling it - # from note so logic will live here? should we just call __add__ with negative value from this class? - raise NotImplementedError() - pass + + if isinstance(other, IntervalInScale): + if other.scale == self.scale: + return IntervalInScale(staff_positions=self.staff_positions + other.staff_positions) + + raise ValueError("Cannot add two IntervalInScale objects with different scales") + + else: + raise ValueError("Can only subtract other IntervalInScale from IntervalInScale") + + + def subtract_from_note(self, note: Note): + + interval_in_scale: IntervalInScale = copy(self) + # invert for subtraction + interval_in_scale.staff_positions -= interval_in_scale.staff_positions + + return interval_in_scale.__add__(note) + + def add_to_note(self, note): + + return self.__add__(note) + + + # todo: add __sub__ method to note that calls this one with negative value # there is no __eq__ overload as it does not seem to make a lot of sense to compare this entities @@ -244,35 +330,78 @@ class Interval: # see https://en.wikipedia.org/wiki/Interval_(music)#Main_intervals # also https://en.wikipedia.org/wiki/Interval_(music)#Compound_intervals - def __init__(self, staff_positions: int, quality: IntervalQuality): + def __init__(self, staff_positions: int, quality: Optional[IntervalQuality] = None, + semitones: Optional[int] = None): """ @staff_positions - difference in staff positions between notes. Zero-based. + @quality - interval quality, depends on relation between staff positions and + semitone difference between two notes E.g. to get a Fifth, set this to 4 see. https://en.wikipedia.org/wiki/Staff_(music)#Staff_positions """ self.staff_positions: int = staff_positions + if not quality and not semitones: + raise ValueError("Provide either semitones or quality value") + + if quality and semitones: + # if for some reason we were provided with both, check them instead of failing: + assessed_quality = Interval.assess_quality(staff_positions, semitones) + if assessed_quality != quality: + raise ValueError(f"You passed both quality and semitones when defining Interval," + f"but quality {quality} does not correspond to {staff_positions} staff positions " + f"with {semitones} semitones difference. Please provide either quality, or" + f"semitones difference.") - # todo: accept semitones instead of quality to determine it automatically and vica-versa + if quality: + self.quality: IntervalQuality = quality + self.semitones: int = semitones or Interval.calculate_semitones_difference(staff_positions, quality) - # can only be identified when we have at least one note to count interval from - # because quality depends on relation between staff positions and semitone difference between two notes - self.quality: IntervalQuality = quality + elif semitones: + self.semitones: int = semitones + self.quality: IntervalQuality = quality or Interval.assess_quality(staff_positions, semitones) + + + def get_tone_delta(self): + """ + Returns a ToneDelta with the same number of semitones. + Useful for adding or subtracting operations with Notes. + """ + return ToneDelta(semitones=self.semitones) def __add__(self, other): - raise NotImplementedError() - pass + if isinstance(other, Note): + return self.add_to_note(other) - def __sub__(self, other): - # FIXME: should it be in Note?.. should we use some method in this class when calling it - # from note so logic will live here? should we just call __add__ with negative value from this class? - raise NotImplementedError() - pass + # TODO: I am not sure that adding intervals together makes sense. Or does it? + # Do we add both semitones and staff positions? + raise ValueError("You can only add Intervals to notes.") + + def add_to_note(self, note: Note) -> Note: + """ + Adds this interval to a Note to get a new note + """ + return note + self.get_tone_delta() + + def subtract_from_note(self, note: Note) -> Note: + """ + Subtracts this interval to a Note to get a new note + """ + return note - self.get_tone_delta() + + # def __sub__(self, other): + # # FIXME: should it be in Note?.. should we use some method in this class when calling it + # # from note so logic will live here? should we just call __add__ with negative value from this class? + # raise NotImplementedError() + # pass def __eq__(self, other): - # todo: implement - raise NotImplementedError() + + if (other.staff_positions == self.staff_positions) and (other.quality == self.quality): + return True + + return False def get_quantitative_name(self) -> str: @@ -290,8 +419,23 @@ def __str__(self): # TODO: make long notation option? return f"{self.quality.notation() if self.quality else '?'}{self.get_quantitative_name()}" - @classmethod - def assess_quality(cls, staff_position_difference, semitone_difference) -> IntervalQuality: + + @staticmethod + def calculate_semitones_difference(staff_position_difference: int, quality: IntervalQuality) -> int: + + if staff_position_difference>7: + staff_position_difference %= 7 + + try: + semitones = INTERVAL_QUALITY_TO_SEMITONE_MAP[quality][staff_position_difference] + except KeyError: + raise ValueError(f"Cannot find semitones difference corresponding " + f"to {quality.short_name()}{staff_position_difference+1} interval") + + return semitones + + @staticmethod + def assess_quality(staff_position_difference: int, semitone_difference: int) -> IntervalQuality: """ @staff_positions - staff positions between notes in scale @semitones - semitones between these notes @@ -341,10 +485,22 @@ def between_notes(cls, note1: Note, note2: Note, scale: Scale): return result + def is_consonant(self): + return self in CONSONANT_INTERVALS + + def is_perfect_consonant(self): + return self in PERFECT_CONSONANT_INTERVALS + + def is_imperfect_consonant(self): + return self in IMPERFECT_CONSONANT_INTERVALS + + def is_dissonant(self): + return self in DISSONANT_INTERVALS + # https://music.utk.edu/theorycomp/courses/murphy/documents/Intervals.pdf PERFECT_CONSONANT_INTERVALS = ( - Interval(0, IntervalQuality.PERFECT), # P1 + Interval(0, IntervalQuality.PERFECT), # P1 # TODO: add constants for all these intervals for easier reference? Interval(7, IntervalQuality.PERFECT), # P8 Interval(4, IntervalQuality.PERFECT), # P5 # P4 is weird so it's not added here: From 3546d0427933001dfab9fa5b60cefc6d66d93e11 Mon Sep 17 00:00:00 2001 From: Alexey Strelkov Date: Fri, 7 Jan 2022 02:27:49 +0300 Subject: [PATCH 05/10] added common main interval constants --- pyscales/scales.py | 49 ++++++++++++++++++++++++++++++++++------------ tests.py | 7 +++++++ 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/pyscales/scales.py b/pyscales/scales.py index 061a979..d873b05 100644 --- a/pyscales/scales.py +++ b/pyscales/scales.py @@ -497,12 +497,35 @@ def is_imperfect_consonant(self): def is_dissonant(self): return self in DISSONANT_INTERVALS + # TODO: inversions + + +# main (non-compound, less than octave) intervals constants +Interval.P1 = Interval(0, IntervalQuality.PERFECT) +Interval.P8 = Interval(7, IntervalQuality.PERFECT) +Interval.P4 = Interval(3, IntervalQuality.PERFECT) +Interval.P5 = Interval(4, IntervalQuality.PERFECT) + +Interval.m2 = Interval(1, IntervalQuality.MINOR) +Interval.m3 = Interval(2, IntervalQuality.MINOR) +Interval.m6 = Interval(5, IntervalQuality.MINOR) +Interval.m7 = Interval(6, IntervalQuality.MINOR) + +Interval.M2 = Interval(1, IntervalQuality.MAJOR) +Interval.M3 = Interval(2, IntervalQuality.MAJOR) +Interval.M6 = Interval(5, IntervalQuality.MAJOR) +Interval.M7 = Interval(6, IntervalQuality.MAJOR) + +Interval.d5 = Interval(4, IntervalQuality.DIMINISHED) + +Interval.A4 = Interval(3, IntervalQuality.AUGMENTED) + # https://music.utk.edu/theorycomp/courses/murphy/documents/Intervals.pdf PERFECT_CONSONANT_INTERVALS = ( - Interval(0, IntervalQuality.PERFECT), # P1 # TODO: add constants for all these intervals for easier reference? - Interval(7, IntervalQuality.PERFECT), # P8 - Interval(4, IntervalQuality.PERFECT), # P5 + Interval.P1, # P1 # TODO: add constants for all these intervals for easier reference? + Interval.P8, + Interval.P5, # P5 # P4 is weird so it's not added here: # The P4 is sometimes consonant and sometimes dissonant. # In early music, P4 was a consonance and with other perfect intervals made up @@ -515,20 +538,20 @@ def is_dissonant(self): ) IMPERFECT_CONSONANT_INTERVALS = ( - Interval(2, IntervalQuality.MINOR), # m3 - Interval(2, IntervalQuality.MAJOR), # M3 - Interval(5, IntervalQuality.MINOR), # m6 - Interval(5, IntervalQuality.MAJOR), # M6 + Interval.m3, # m3 + Interval.M3, # M3 + Interval.m6, # m6 + Interval.M6, # M6 ) CONSONANT_INTERVALS = PERFECT_CONSONANT_INTERVALS + IMPERFECT_CONSONANT_INTERVALS DISSONANT_INTERVALS = ( - Interval(1, IntervalQuality.MINOR), # m2 - Interval(1, IntervalQuality.MAJOR), # M2 - Interval(6, IntervalQuality.MINOR), # m7 - Interval(6, IntervalQuality.MAJOR), # M7 + Interval.m2, # m2 + Interval.M2, # M2 + Interval.m7, # m7 + Interval.M7, # M7 # tritones - Interval(4, IntervalQuality.DIMINISHED), # d5 - diminished 5th, tritone - Interval(3, IntervalQuality.AUGMENTED), # A4 - augmented 4th, tritone + Interval.d5, # d5 - diminished 5th, tritone + Interval.A4, # A4 - augmented 4th, tritone ) diff --git a/tests.py b/tests.py index 86581f0..b0d1708 100644 --- a/tests.py +++ b/tests.py @@ -110,5 +110,12 @@ def test_initializing_notes_from_midi_values(self): self.assertEqual(note.octave_number, line[1]) +class IntervalInScaleTestCase(unittest.TestCase): + pass + + +class IntervalsTestCase(unittest.TestCase): + pass + if __name__ == '__main__': unittest.main() From 259412d53fb936c6b729a287b636e4dc985a05cb Mon Sep 17 00:00:00 2001 From: Alexey Strelkov Date: Fri, 7 Jan 2022 02:34:11 +0300 Subject: [PATCH 06/10] added scale equality operator overload + test --- pyscales/scales.py | 6 +++++- tests.py | 18 ++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/pyscales/scales.py b/pyscales/scales.py index d873b05..5592748 100644 --- a/pyscales/scales.py +++ b/pyscales/scales.py @@ -50,6 +50,10 @@ def __init__(self, root_note:Note, formula: ScaleFormula): self._all_notes = NoteArray(self.DEFAULT_NOTE_ORDER) + def __eq__(self, other): + + return (self.root_note == other.root_note) and (self.formula == other.formula) + def notes_in_scale(self) -> NoteArray: # find root note index @@ -283,7 +287,7 @@ def __add__(self, other): elif isinstance(other, IntervalInScale): if other.scale == self.scale: - return IntervalInScale(staff_positions=self.staff_positions+other.staff_positions) + return IntervalInScale(staff_positions=self.staff_positions+other.staff_positions, scale=self.scale) raise ValueError("Cannot add two IntervalInScale objects with different scales") diff --git a/tests.py b/tests.py index b0d1708..9569460 100644 --- a/tests.py +++ b/tests.py @@ -2,7 +2,7 @@ from pyscales import constants, scaleformulas from pyscales.primitives import Note, NoteArray -from pyscales.scales import Scale +from pyscales.scales import Scale, IntervalInScale class TestNoteFrequencies(unittest.TestCase): @@ -111,7 +111,21 @@ def test_initializing_notes_from_midi_values(self): class IntervalInScaleTestCase(unittest.TestCase): - pass + + def test_interval_in_scale_addition_to_each_other(self): + a = IntervalInScale(staff_positions=2, scale=Scale(Note("C", 4), scaleformulas.MINOR_FORMULA)) + b = IntervalInScale(staff_positions=3, scale=Scale(Note("C", 4), scaleformulas.MINOR_FORMULA)) + + c = a+b + + self.assertEqual(c.staff_positions, 5) + + a = IntervalInScale(staff_positions=5, scale=Scale(Note("C", 4), scaleformulas.MINOR_FORMULA)) + b = IntervalInScale(staff_positions=23, scale=Scale(Note("C", 4), scaleformulas.MINOR_FORMULA)) + + c = a + b + + self.assertEqual(c.staff_positions, 28) class IntervalsTestCase(unittest.TestCase): From 5224c70c47dfc0120a5527053b5c8ca5c5ae737a Mon Sep 17 00:00:00 2001 From: Alexey Strelkov Date: Fri, 7 Jan 2022 03:38:06 +0300 Subject: [PATCH 07/10] IntervalInScale tests and fixes --- pyscales/scales.py | 13 ++---- tests.py | 111 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/pyscales/scales.py b/pyscales/scales.py index 5592748..ab72ebd 100644 --- a/pyscales/scales.py +++ b/pyscales/scales.py @@ -95,9 +95,6 @@ def __str__(self): # TODO: change flats and sharps so note names will be unique across scale return str(self.notes_in_scale()) - @property - def all_notes(self): - return self._all_notes class IntervalQuality(Enum): @@ -277,10 +274,10 @@ def __add__(self, other): if isinstance(other, Note): try: - note_index = self.scale.all_notes.index(other) + note_index = self.scale.notes_in_scale().index(other) # return corresponding note from scale - return self.scale.all_notes[note_index+self.staff_positions].copy() + return copy(self.scale.notes_in_scale()[note_index+self.staff_positions]) except ValueError: raise ValueError(f"Note {other} is not in scale {self.scale}") @@ -299,7 +296,7 @@ def __sub__(self, other): if isinstance(other, IntervalInScale): if other.scale == self.scale: - return IntervalInScale(staff_positions=self.staff_positions + other.staff_positions) + return IntervalInScale(staff_positions=self.staff_positions - other.staff_positions, scale=self.scale) raise ValueError("Cannot add two IntervalInScale objects with different scales") @@ -311,7 +308,7 @@ def subtract_from_note(self, note: Note): interval_in_scale: IntervalInScale = copy(self) # invert for subtraction - interval_in_scale.staff_positions -= interval_in_scale.staff_positions + interval_in_scale.staff_positions = -interval_in_scale.staff_positions return interval_in_scale.__add__(note) @@ -482,7 +479,7 @@ def between_notes(cls, note1: Note, note2: Note, scale: Scale): The returned interval has quality info. """ semitone_interval = (note1 - note2).semitones - staff_interval = scale.all_notes.index(note1) - scale.all_notes.index(note2) + staff_interval = scale.notes_in_scale().index(note1) - scale.notes_in_scale().index(note2) quality = cls.assess_quality(staff_interval, semitone_interval) result = Interval(staff_interval, quality) diff --git a/tests.py b/tests.py index 9569460..339e204 100644 --- a/tests.py +++ b/tests.py @@ -127,6 +127,117 @@ def test_interval_in_scale_addition_to_each_other(self): self.assertEqual(c.staff_positions, 28) + def test_interval_in_scale_subtraction_from_each_other(self): + + a = IntervalInScale(staff_positions=4, scale=Scale(Note("C", 4), scaleformulas.MAJOR_FORMULA)) + b = IntervalInScale(staff_positions=1, scale=Scale(Note("C", 4), scaleformulas.MAJOR_FORMULA)) + + c = a - b + + self.assertEqual(c.staff_positions, 3) + + a = IntervalInScale(staff_positions=2, scale=Scale(Note("C", 4), scaleformulas.MINOR_FORMULA)) + b = IntervalInScale(staff_positions=3, scale=Scale(Note("C", 4), scaleformulas.MINOR_FORMULA)) + + c = a-b + + self.assertEqual(c.staff_positions, -1) + + a = IntervalInScale(staff_positions=23, scale=Scale(Note("C", 4), scaleformulas.MINOR_FORMULA)) + b = IntervalInScale(staff_positions=5, scale=Scale(Note("C", 4), scaleformulas.MINOR_FORMULA)) + + c = a - b + + self.assertEqual(c.staff_positions, 18) + + def test_interval_in_scale_addition_to_note(self): + + note = Note("C", 4) + scale = Scale(Note("C", 4), scaleformulas.MAJOR_FORMULA) + + interval_in_scale = IntervalInScale(staff_positions=0, scale=scale) + result = note + interval_in_scale + self.assertEqual(result, Note("C", 4)) + + interval_in_scale = IntervalInScale(staff_positions=1, scale=scale) + result = note + interval_in_scale + self.assertEqual(result, Note("D", 4)) + + interval_in_scale = IntervalInScale(staff_positions=2, scale=scale) + result = note + interval_in_scale + self.assertEqual(result, Note("E", 4)) + + interval_in_scale = IntervalInScale(staff_positions=7, scale=scale) + result = note + interval_in_scale + self.assertEqual(result, Note("C", 5)) + + interval_in_scale = IntervalInScale(staff_positions=8, scale=scale) + result = note + interval_in_scale + self.assertEqual(result, Note("D", 5)) + + interval_in_scale = IntervalInScale(staff_positions=-1, scale=scale) + result = note + interval_in_scale + self.assertEqual(result, Note("B", 3)) + + + note = Note("A", 4) + scale = Scale(Note("F#", 4), scaleformulas.MINOR_FORMULA) + + interval_in_scale = IntervalInScale(staff_positions=2, scale=scale) + result = note + interval_in_scale + self.assertEqual(Note("C#", 5), result) # octaves numbers are absolute, not dependant on scales. C marks new octave start + + interval_in_scale = IntervalInScale(staff_positions=-1, scale=scale) + result = note + interval_in_scale + self.assertEqual(result, Note("G#", 4)) + + note = Note("C", 4) + scale = Scale(Note("A", 4), scaleformulas.MINOR_FORMULA) + + interval_in_scale = IntervalInScale(staff_positions=3, scale=scale) + result = note + interval_in_scale + self.assertEqual(result, Note("F", 4)) + + interval_in_scale = IntervalInScale(staff_positions=1, scale=scale) + result = note + interval_in_scale + self.assertEqual(result, Note("D", 4)) + + + def test_interval_in_scale_subtraction_from_note(self): + + note = Note("C", 4) + scale = Scale(Note("C", 4), scaleformulas.MAJOR_FORMULA) + + interval_in_scale = IntervalInScale(staff_positions=0, scale=scale) + result = note - interval_in_scale + self.assertEqual(result, Note("C", 4)) + + interval_in_scale = IntervalInScale(staff_positions=2, scale=scale) + result = note - interval_in_scale + self.assertEqual(Note("A", 3), result) + + note = Note("F", 4) + scale = Scale(Note("Eb", 4), scaleformulas.MAJOR_FORMULA) + + interval_in_scale = IntervalInScale(staff_positions=0, scale=scale) + result = note - interval_in_scale + self.assertEqual(result, Note("F", 4)) + + interval_in_scale = IntervalInScale(staff_positions=1, scale=scale) + result = note - interval_in_scale + self.assertEqual(result, Note("Eb", 4)) + self.assertEqual(result, Note("D#", 4)) # same thing + + interval_in_scale = IntervalInScale(staff_positions=2, scale=scale) + result = note - interval_in_scale + self.assertEqual(result, Note("D", 4)) + + interval_in_scale = IntervalInScale(staff_positions=4, scale=scale) + result = note - interval_in_scale + self.assertEqual(result, Note("Bb", 3)) + + + class IntervalsTestCase(unittest.TestCase): pass From d4ab4c96f5d291362c9ecbfa4558fbf7d058f99b Mon Sep 17 00:00:00 2001 From: Alexey Strelkov Date: Fri, 7 Jan 2022 03:52:11 +0300 Subject: [PATCH 08/10] moved Interval constants to Intervals so autocomplete would work in IDEs --- pyscales/scales.py | 60 ++++++++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/pyscales/scales.py b/pyscales/scales.py index ab72ebd..fd931d5 100644 --- a/pyscales/scales.py +++ b/pyscales/scales.py @@ -387,8 +387,9 @@ def add_to_note(self, note: Note) -> Note: def subtract_from_note(self, note: Note) -> Note: """ - Subtracts this interval to a Note to get a new note + Subtracts this interval from a Note to get a new note """ + #FIXME: does it actually makes sense to subtract intervals? return note - self.get_tone_delta() # def __sub__(self, other): @@ -464,7 +465,7 @@ def assess_quality(staff_position_difference: int, semitone_difference: int) -> try: - quality = INTERVAL_QUALITY_MAP[staff_position_difference][semitone_difference] + quality = INTERVAL_QUALITY_MAP[semitone_difference][staff_position_difference] except KeyError: raise ValueError(f"Cannot find interval quality corresponding to {semitone_difference} semitone " @@ -502,31 +503,32 @@ def is_dissonant(self): # main (non-compound, less than octave) intervals constants -Interval.P1 = Interval(0, IntervalQuality.PERFECT) -Interval.P8 = Interval(7, IntervalQuality.PERFECT) -Interval.P4 = Interval(3, IntervalQuality.PERFECT) -Interval.P5 = Interval(4, IntervalQuality.PERFECT) +class Intervals: + P1 = Interval(0, IntervalQuality.PERFECT) + P8 = Interval(7, IntervalQuality.PERFECT) + P4 = Interval(3, IntervalQuality.PERFECT) + P5 = Interval(4, IntervalQuality.PERFECT) -Interval.m2 = Interval(1, IntervalQuality.MINOR) -Interval.m3 = Interval(2, IntervalQuality.MINOR) -Interval.m6 = Interval(5, IntervalQuality.MINOR) -Interval.m7 = Interval(6, IntervalQuality.MINOR) + m2 = Interval(1, IntervalQuality.MINOR) + m3 = Interval(2, IntervalQuality.MINOR) + m6 = Interval(5, IntervalQuality.MINOR) + m7 = Interval(6, IntervalQuality.MINOR) -Interval.M2 = Interval(1, IntervalQuality.MAJOR) -Interval.M3 = Interval(2, IntervalQuality.MAJOR) -Interval.M6 = Interval(5, IntervalQuality.MAJOR) -Interval.M7 = Interval(6, IntervalQuality.MAJOR) + M2 = Interval(1, IntervalQuality.MAJOR) + M3 = Interval(2, IntervalQuality.MAJOR) + M6 = Interval(5, IntervalQuality.MAJOR) + M7 = Interval(6, IntervalQuality.MAJOR) -Interval.d5 = Interval(4, IntervalQuality.DIMINISHED) + d5 = Interval(4, IntervalQuality.DIMINISHED) -Interval.A4 = Interval(3, IntervalQuality.AUGMENTED) + A4 = Interval(3, IntervalQuality.AUGMENTED) # https://music.utk.edu/theorycomp/courses/murphy/documents/Intervals.pdf PERFECT_CONSONANT_INTERVALS = ( - Interval.P1, # P1 # TODO: add constants for all these intervals for easier reference? - Interval.P8, - Interval.P5, # P5 + Intervals.P1, # P1 # TODO: add constants for all these intervals for easier reference? + Intervals.P8, + Intervals.P5, # P5 # P4 is weird so it's not added here: # The P4 is sometimes consonant and sometimes dissonant. # In early music, P4 was a consonance and with other perfect intervals made up @@ -539,20 +541,20 @@ def is_dissonant(self): ) IMPERFECT_CONSONANT_INTERVALS = ( - Interval.m3, # m3 - Interval.M3, # M3 - Interval.m6, # m6 - Interval.M6, # M6 + Intervals.m3, # m3 + Intervals.M3, # M3 + Intervals.m6, # m6 + Intervals.M6, # M6 ) CONSONANT_INTERVALS = PERFECT_CONSONANT_INTERVALS + IMPERFECT_CONSONANT_INTERVALS DISSONANT_INTERVALS = ( - Interval.m2, # m2 - Interval.M2, # M2 - Interval.m7, # m7 - Interval.M7, # M7 + Intervals.m2, # m2 + Intervals.M2, # M2 + Intervals.m7, # m7 + Intervals.M7, # M7 # tritones - Interval.d5, # d5 - diminished 5th, tritone - Interval.A4, # A4 - augmented 4th, tritone + Intervals.d5, # d5 - diminished 5th, tritone + Intervals.A4, # A4 - augmented 4th, tritone ) From 36e423c380a6f5a75fac52aee2d6187a0db25ad0 Mon Sep 17 00:00:00 2001 From: Alexey Strelkov Date: Fri, 7 Jan 2022 04:16:43 +0300 Subject: [PATCH 09/10] tests; --- pyscales/scales.py | 7 +++- tests.py | 98 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/pyscales/scales.py b/pyscales/scales.py index fd931d5..8da251a 100644 --- a/pyscales/scales.py +++ b/pyscales/scales.py @@ -370,6 +370,9 @@ def get_tone_delta(self): """ return ToneDelta(semitones=self.semitones) + def __repr__(self): + + return f"{super(Interval, self).__repr__()[:-1]} ({self.__str__()})>" def __add__(self, other): if isinstance(other, Note): @@ -479,8 +482,8 @@ def between_notes(cls, note1: Note, note2: Note, scale: Scale): Returns an interval between two notes. The returned interval has quality info. """ - semitone_interval = (note1 - note2).semitones - staff_interval = scale.notes_in_scale().index(note1) - scale.notes_in_scale().index(note2) + semitone_interval = abs((note1 - note2).semitones) + staff_interval = abs(scale.notes_in_scale().index(note1) - scale.notes_in_scale().index(note2)) quality = cls.assess_quality(staff_interval, semitone_interval) result = Interval(staff_interval, quality) diff --git a/tests.py b/tests.py index 339e204..acbd21a 100644 --- a/tests.py +++ b/tests.py @@ -1,8 +1,8 @@ import unittest from pyscales import constants, scaleformulas -from pyscales.primitives import Note, NoteArray -from pyscales.scales import Scale, IntervalInScale +from pyscales.primitives import Note, NoteArray, ToneDelta +from pyscales.scales import Scale, IntervalInScale, Interval, IntervalQuality, Intervals class TestNoteFrequencies(unittest.TestCase): @@ -240,7 +240,99 @@ def test_interval_in_scale_subtraction_from_note(self): class IntervalsTestCase(unittest.TestCase): - pass + + def test_interval_between_two_notes_in_scale(self): + + c_major = Scale(Note("C", 4), scaleformulas.MAJOR_FORMULA) + + interval = Interval.between_notes(Note("D", 4), Note("C", 4), c_major) + + self.assertEqual(Intervals.M2, interval) + self.assertTrue(interval.is_dissonant()) + self.assertEqual(IntervalQuality.MAJOR, interval.quality) + self.assertEqual(2, interval.semitones) + self.assertEqual(1, interval.staff_positions) + + + interval = Interval.between_notes(Note("C", 4), Note("F", 4), c_major) + + self.assertEqual(Intervals.P4, interval) + # self.assertTrue(interval.is_perfect_consonant()) + # self.assertTrue(interval.is_consonant()) + self.assertEqual(IntervalQuality.PERFECT, interval.quality) + self.assertEqual(5, interval.semitones) + self.assertEqual(3, interval.staff_positions) + + # switch notes positions - should have same results + interval = Interval.between_notes(Note("F", 4), Note("C", 4), c_major) + + self.assertEqual(Intervals.P4, interval) + # self.assertTrue(interval.is_perfect_consonant()) # this interval is weird, not sure if its coonsonant + # self.assertTrue(interval.is_consonant()) + self.assertEqual(IntervalQuality.PERFECT, interval.quality) + self.assertEqual(5, interval.semitones) + self.assertEqual(3, interval.staff_positions) + + a_minor = Scale(Note("A", 4), scaleformulas.MINOR_FORMULA) + interval = Interval.between_notes(Note("A", 4), Note("G", 5), a_minor) + + self.assertEqual(Intervals.m7, interval) + self.assertTrue(interval.is_dissonant()) + self.assertEqual(IntervalQuality.MINOR, interval.quality) + self.assertEqual(10, interval.semitones) + self.assertEqual(6, interval.staff_positions) + + # check again in major just to be sure it's the same + interval = Interval.between_notes(Note("A", 4), Note("G", 5), c_major) + + self.assertEqual(Intervals.m7, interval) + self.assertTrue(interval.is_dissonant()) + self.assertEqual(IntervalQuality.MINOR, interval.quality) + self.assertEqual(10, interval.semitones) + self.assertEqual(6, interval.staff_positions) + + interval = Interval.between_notes(Note("F", 4), Note("B", 4), c_major) + + self.assertEqual(Intervals.A4, interval) + self.assertTrue(interval.is_dissonant()) + self.assertEqual(IntervalQuality.AUGMENTED, interval.quality) + self.assertEqual(6, interval.semitones) + self.assertEqual(3, interval.staff_positions) + + + + + def test_compound_interval_between_two_notes_in_scale(self): + + raise NotImplementedError() + # c_major = Scale(Note("C", 4), scaleformulas.MAJOR_FORMULA) + # + # interval = Interval.between_notes(Note("D", 4), Note("C", 4), c_major) + # + # self.assertEqual(Intervals.M2, interval) + # self.assertTrue(interval.is_dissonant()) + # self.assertEqual(IntervalQuality.MAJOR, interval.quality) + # self.assertEqual(2, interval.semitones) + # self.assertEqual(1, interval.staff_positions) + # + # interval = Interval.between_notes(Note("C", 4), Note("D", 4) + ToneDelta(12), c_major) + # + # # self.assertEqual(Intervals.M2, interval) + # # self.assertTrue(interval.is_dissonant()) + # self.assertEqual(IntervalQuality.MAJOR, interval.quality) + # self.assertEqual(2+12, interval.semitones) + # self.assertEqual(1+7, interval.staff_positions) + + + + def test_interval_addition_to_note(self): + raise NotImplementedError() + + def test_compound_interval_addition_to_note(self): + raise NotImplementedError() + + # compound intervals + if __name__ == '__main__': unittest.main() From 4354f7a303ddff8b1ca92a56fb088143c7c5d86e Mon Sep 17 00:00:00 2001 From: Alexey Strelkov Date: Fri, 7 Jan 2022 04:31:12 +0300 Subject: [PATCH 10/10] fixed semitone calculation for compound intervals; added more tests --- pyscales/scales.py | 19 +++++++++++++----- tests.py | 49 +++++++++++++++++++++++++++++++--------------- 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/pyscales/scales.py b/pyscales/scales.py index 8da251a..085f61d 100644 --- a/pyscales/scales.py +++ b/pyscales/scales.py @@ -1,3 +1,4 @@ +import math from copy import copy from enum import Enum from typing import Optional @@ -428,11 +429,19 @@ def __str__(self): @staticmethod def calculate_semitones_difference(staff_position_difference: int, quality: IntervalQuality) -> int: - if staff_position_difference>7: - staff_position_difference %= 7 + + shift_octaves = 0 + + # if more than an octave, remove octaves as INTERVAL_QUALITY_TO_SEMITONE_MAP goes only within 1 octave + # but save number of octaves for correct semitone calculation + if staff_position_difference > 7: + + shift_octaves = math.floor(staff_position_difference / 7) + + staff_position_difference %= 7 # FIXME: should not we add 1? try: - semitones = INTERVAL_QUALITY_TO_SEMITONE_MAP[quality][staff_position_difference] + semitones = INTERVAL_QUALITY_TO_SEMITONE_MAP[quality][staff_position_difference] + 12*shift_octaves except KeyError: raise ValueError(f"Cannot find semitones difference corresponding " f"to {quality.short_name()}{staff_position_difference+1} interval") @@ -463,8 +472,8 @@ def assess_quality(staff_position_difference: int, semitone_difference: int) -> # TODO: there should probably be an algorithmic way to do this without using a map that is easily computable if semitone_difference > 12: - semitone_difference %= 12 - staff_position_difference %= 7 + semitone_difference %= 12 # FIXME: should not we add 1 here too? or not? + staff_position_difference %= 7 # FIXME: should not we add 1 here since we have both 0 and 1 in map? try: diff --git a/tests.py b/tests.py index acbd21a..8276c04 100644 --- a/tests.py +++ b/tests.py @@ -304,24 +304,41 @@ def test_interval_between_two_notes_in_scale(self): def test_compound_interval_between_two_notes_in_scale(self): - raise NotImplementedError() - # c_major = Scale(Note("C", 4), scaleformulas.MAJOR_FORMULA) - # - # interval = Interval.between_notes(Note("D", 4), Note("C", 4), c_major) - # + # check https://en.wikipedia.org/wiki/Interval_(music)#Main_compound_intervals + + # raise NotImplementedError() + + + c_major = Scale(Note("C", 4), scaleformulas.MAJOR_FORMULA) + + interval = Interval.between_notes(Note("C", 4), Note("D", 4), c_major) + + self.assertEqual(Intervals.M2, interval) + self.assertTrue(interval.is_dissonant()) + self.assertEqual(IntervalQuality.MAJOR, interval.quality) + self.assertEqual(2, interval.semitones) + self.assertEqual(1, interval.staff_positions) + + interval = Interval.between_notes(Note("C", 4), Note("D", 4) + ToneDelta(12), c_major) + # self.assertEqual(Intervals.M2, interval) # self.assertTrue(interval.is_dissonant()) - # self.assertEqual(IntervalQuality.MAJOR, interval.quality) - # self.assertEqual(2, interval.semitones) - # self.assertEqual(1, interval.staff_positions) - # - # interval = Interval.between_notes(Note("C", 4), Note("D", 4) + ToneDelta(12), c_major) - # - # # self.assertEqual(Intervals.M2, interval) - # # self.assertTrue(interval.is_dissonant()) - # self.assertEqual(IntervalQuality.MAJOR, interval.quality) - # self.assertEqual(2+12, interval.semitones) - # self.assertEqual(1+7, interval.staff_positions) + self.assertEqual(IntervalQuality.MAJOR, interval.quality) + self.assertEqual(2+12, interval.semitones) + self.assertEqual(1+7, interval.staff_positions) + + + interval = Interval.between_notes(Note("A", 4), Note("G", 5) + ToneDelta(12), c_major) + + # self.assertEqual(Intervals.m7, interval) + # self.assertTrue(interval.is_dissonant()) + self.assertEqual(IntervalQuality.MINOR, interval.quality) + self.assertEqual(10+12, interval.semitones) + self.assertEqual(6+7, interval.staff_positions) + + # todo: add more compound cases here!! + # todo: add main compound cases from wiki here + # https://en.wikipedia.org/wiki/Interval_(music)#Main_compound_intervals