diff --git a/.gitignore b/.gitignore index 6b758b2..808c003 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .vscode/settings.json .DS_Store .pytest_cache +t.py diff --git a/pytheory/chords.py b/pytheory/chords.py index e69de29..f0fe12c 100644 --- a/pytheory/chords.py +++ b/pytheory/chords.py @@ -0,0 +1,7 @@ +class Chord: + def __init__(self, *, tones): + self.tones = tones + + # @property + # def harmony(self): + # pass diff --git a/pytheory/core.py b/pytheory/core.py index a49f51d..bad0c5b 100644 --- a/pytheory/core.py +++ b/pytheory/core.py @@ -1,309 +1,6 @@ from math import ceil, floor -import numeral -from ._statics import REFERENCE_A, TEMPERAMENTS, TONES, DEGREES, SCALES, SYSTEMS - -class Tone: - # __slots__ = ("name", "octave", "system") - - def __init__(self, *, name, octave=None, system=None): - self.name = name - self.octave = octave - self.system = system - - if self.system: - try: - assert self.name in self.system.tones - except AssertionError: - raise ValueError( - f"Tone {self.name!r} was not found in system: {self.system.tones!r}" - ) - - @property - def full_name(self): - if self.octave: - return f"{self.name}{self.octave}" - else: - return self.name - - def __repr__(self): - return f"" - - def __eq__(self, other): - - # Comparing string literals. - if self.name == other: - return True - - # Comparing against other Tones. - try: - if (self.name == other.name) and (self.octave == other.octave): - return True - except AttributeError: - pass - - @classmethod - def from_string(klass, s, system=None): - tone = "".join([c for c in filter(str.isalpha, s)]) - try: - octave = int("".join([c for c in filter(str.isdigit, s)])) - except ValueError: - octave = None - - return klass(name=tone, octave=octave, system=system) - - @classmethod - def from_index(klass, i, *, octave, system): - tone = system.tones[i].name - return klass(name=tone, octave=octave, system=system) - - @property - def _index(self): - try: - return self.system.tones.index(self.name) - except AttributeError: - raise ValueError("Tone index cannot be referenced without a system!") - - def _math(self, interval): - """Returns (new index, new octave).""" - - try: - mod = len(self.system.tones) - except AttributeError: - raise ValueError( - "Tone math can only be computed with an associated system!" - ) - result = self._index + interval - index = result % mod - octave = result // mod + self.octave - return (index, octave) - - def add(self, interval): - index, octave = self._math(interval) - return self.from_index(index, octave=octave, system=self.system) - - def subtract(self, interval): - return self.add((-1 * interval)) - - def pitch( - self, - *, - reference_pitch=REFERENCE_A, - temperament="equal", - symbolic=False, - precision=None, - ): - try: - tones = len(self.system.tones) - except AttributeError: - raise ValueError("Pitches can only be computed with an associated system!") - pitch_scale = TEMPERAMENTS[temperament](tones) - pitch = pitch_scale[self._index] - if symbolic: - return reference_pitch * pitch - else: - return reference_pitch * pitch.evalf(precision) - - -class System: - def __init__(self, *, tones, degrees, scales=None): - self.tones = [Tone.from_string(tone) for tone in tones] - - # Add current system to tones (a bit of a hack). - for tone in self.tones: - tone.system = self - - self.degrees = degrees - self._scales = scales - - if scales is None: - self._scales = SCALES[self.semitones] - - @property - def semitones(self): - return len(self.tones) - - @property - def scales(self): - scales = {} - - for (scale_type, scale_properties) in self._scales.items(): - scales[scale_type] = {} - - tones = scale_properties[0] - new_scales = scale_properties[1] - - if not new_scales: - new_scales = {scale_type: {}} - - for scale in new_scales.items(): - scale_name = scale[0] - scales[scale_type][scale_name] = self.generate_scale( - tones=tones, semitones=self.semitones, **scale[1] - ) - return scales - - @property - def modes(self): - def gen(): - for i, degree in enumerate(self.degrees): - for mode in degree[1]: - yield {"degree": (i + 1), "mode": mode} - - return [g for g in gen()] - - @staticmethod - def generate_scale( - *, - tones=7, - semitones=12, - major=False, - minor=False, - hemitonic=False, # Contains semitones. - harmonic=False, - melodic=False, - offset=None, - ): - """Generates the primary scale for a given number of semitones/tones.""" - # TODO: Support minor, support harmonic, support melodic. - - # Sanity check. - if major and minor: - raise ValueError("Scale cannot be both major and minor. Choose one.") - - def gen(tones, semitones, major, minor, harmonic, melodic, hemitonic): - if major or minor: - hemitonic = True - # Assume chromatic scale, if neither major nor minor. - if not (major or minor) and not hemitonic: - for i in range(tones): - yield 1 - else: - if hemitonic: - if major: - pattern = (2, 2, 1, 2, 2, 2, 1) - elif minor: - pattern = (2, 1, 2, 2, 1, 2, 2) - if harmonic: - pattern = (2, 1, 2, 2, 1, 3, 1) - else: - pattern = None - - step_count = 0 - - if pattern: - for step in pattern: - yield step - else: - for i in range(tones): - # TODO: figure out how to make this work with monotonic. - yield 1 - - scale = [ - g - for g in gen( - tones=tones, - semitones=semitones, - major=major, - minor=minor, - harmonic=harmonic, - melodic=melodic, - hemitonic=hemitonic, - ) - ] - - if offset: - scale = scale[offset - 1 :] + scale[: offset - 1] - - # descending goes in meta? - return {"intervals": scale, "hemitonic": hemitonic, "meta": {}} - - def __repr__(self): - return f"" - - -SYSTEMS = {"western": System(tones=TONES["western"], degrees=DEGREES["western"])} - - -class Scale: - def __init__(self, *, tones, degrees=None, system=None): - self.tones = tones - self.degrees = degrees - self.system = system - - if self.degrees: - if not len(self.tones) == len(self.degrees): - raise ValueError("The number of tones and degrees must be equal!") - - def __repr__(self): - r = [] - for (i, tone) in enumerate(self.tones): - degree = numeral.int2roman(i + 1, only_ascii=True) - r += [f"{degree}={tone.full_name}"] - - r = " ".join(r) - return f"" - - def __getitem__(self, item): - # Degree–style reference (e.g. "IV"). - if isinstance(item, str): - degrees = [] - for (i, tone) in enumerate(self.tones): - degrees.append(numeral.int2roman(i + 1, only_ascii=True)) - - if item in degrees: - item = degrees.index(item) - - # List/Tuple–style reference. - if isinstance(item, int) or isinstance(item, slice): - return self.tones[item] - - -class TonedScale: - def __init__(self, *, system=SYSTEMS["western"], tonic): - self.system = system - - if not isinstance(tonic, Tone): - tonic = Tone.from_string(tonic, system=self.system) - - self.tonic = tonic - - def __repr__(self): - return f"" - - def __getitem__(self, scale): - return self._scales[scale] - - @property - def scales(self): - return tuple(self._scales().keys()) - - @property - def _scales(self): - scales = {} - - for scale_type in self.system.scales: - for scale in self.system.scales[scale_type]: - - working_scale = [] - reference_scale = self.system.scales[scale_type][scale]["intervals"] - - current_tone = self.tonic - working_scale.append(current_tone) - - for interval in reference_scale: - current_tone = current_tone.add(interval) - working_scale.append(current_tone) - - scales[scale] = Scale(tones=tuple(working_scale)) - - return scales - - -class Chord: - def __init__(self, *, tones): - self.tones = tones - - # @property - # def harmony(self): - # pass +from .tones import Tone +from .systems import System, SYSTEMS +from .scales import Scale, TonedScale +from .chords import Chord diff --git a/pytheory/scales.py b/pytheory/scales.py new file mode 100644 index 0000000..cec1135 --- /dev/null +++ b/pytheory/scales.py @@ -0,0 +1,79 @@ +import numeral + +from .systems import SYSTEMS +from .tones import Tone + + +class Scale: + def __init__(self, *, tones, degrees=None, system=None): + self.tones = tones + self.degrees = degrees + self.system = system + + if self.degrees: + if not len(self.tones) == len(self.degrees): + raise ValueError("The number of tones and degrees must be equal!") + + def __repr__(self): + r = [] + for (i, tone) in enumerate(self.tones): + degree = numeral.int2roman(i + 1, only_ascii=True) + r += [f"{degree}={tone.full_name}"] + + r = " ".join(r) + return f"" + + def __getitem__(self, item): + # Degree–style reference (e.g. "IV"). + if isinstance(item, str): + degrees = [] + for (i, tone) in enumerate(self.tones): + degrees.append(numeral.int2roman(i + 1, only_ascii=True)) + + if item in degrees: + item = degrees.index(item) + + # List/Tuple–style reference. + if isinstance(item, int) or isinstance(item, slice): + return self.tones[item] + + +class TonedScale: + def __init__(self, *, system=SYSTEMS["western"], tonic): + self.system = system + + if not isinstance(tonic, Tone): + tonic = Tone.from_string(tonic, system=self.system) + + self.tonic = tonic + + def __repr__(self): + return f"" + + def __getitem__(self, scale): + return self._scales[scale] + + @property + def scales(self): + return tuple(self._scales().keys()) + + @property + def _scales(self): + scales = {} + + for scale_type in self.system.scales: + for scale in self.system.scales[scale_type]: + + working_scale = [] + reference_scale = self.system.scales[scale_type][scale]["intervals"] + + current_tone = self.tonic + working_scale.append(current_tone) + + for interval in reference_scale: + current_tone = current_tone.add(interval) + working_scale.append(current_tone) + + scales[scale] = Scale(tones=tuple(working_scale)) + + return scales diff --git a/pytheory/systems.py b/pytheory/systems.py new file mode 100644 index 0000000..c0ed0e1 --- /dev/null +++ b/pytheory/systems.py @@ -0,0 +1,121 @@ +from ._statics import TEMPERAMENTS, TONES, DEGREES, SCALES, SYSTEMS +from .tones import Tone + +class System: + def __init__(self, *, tones, degrees, scales=None): + self.tones = [Tone.from_string(tone) for tone in tones] + + # Add current system to tones (a bit of a hack). + for tone in self.tones: + tone.system = self + + self.degrees = degrees + self._scales = scales + + if scales is None: + self._scales = SCALES[self.semitones] + + @property + def semitones(self): + return len(self.tones) + + @property + def scales(self): + scales = {} + + for (scale_type, scale_properties) in self._scales.items(): + scales[scale_type] = {} + + tones = scale_properties[0] + new_scales = scale_properties[1] + + if not new_scales: + new_scales = {scale_type: {}} + + for scale in new_scales.items(): + scale_name = scale[0] + scales[scale_type][scale_name] = self.generate_scale( + tones=tones, semitones=self.semitones, **scale[1] + ) + return scales + + @property + def modes(self): + def gen(): + for i, degree in enumerate(self.degrees): + for mode in degree[1]: + yield {"degree": (i + 1), "mode": mode} + + return [g for g in gen()] + + @staticmethod + def generate_scale( + *, + tones=7, + semitones=12, + major=False, + minor=False, + hemitonic=False, # Contains semitones. + harmonic=False, + melodic=False, + offset=None, + ): + """Generates the primary scale for a given number of semitones/tones.""" + # TODO: Support minor, support harmonic, support melodic. + + # Sanity check. + if major and minor: + raise ValueError("Scale cannot be both major and minor. Choose one.") + + def gen(tones, semitones, major, minor, harmonic, melodic, hemitonic): + if major or minor: + hemitonic = True + # Assume chromatic scale, if neither major nor minor. + if not (major or minor) and not hemitonic: + for i in range(tones): + yield 1 + else: + if hemitonic: + if major: + pattern = (2, 2, 1, 2, 2, 2, 1) + elif minor: + pattern = (2, 1, 2, 2, 1, 2, 2) + if harmonic: + pattern = (2, 1, 2, 2, 1, 3, 1) + else: + pattern = None + + step_count = 0 + + if pattern: + for step in pattern: + yield step + else: + for i in range(tones): + # TODO: figure out how to make this work with monotonic. + yield 1 + + scale = [ + g + for g in gen( + tones=tones, + semitones=semitones, + major=major, + minor=minor, + harmonic=harmonic, + melodic=melodic, + hemitonic=hemitonic, + ) + ] + + if offset: + scale = scale[offset - 1 :] + scale[: offset - 1] + + # descending goes in meta? + return {"intervals": scale, "hemitonic": hemitonic, "meta": {}} + + def __repr__(self): + return f"" + + +SYSTEMS = {"western": System(tones=TONES["western"], degrees=DEGREES["western"])} diff --git a/pytheory/tones.py b/pytheory/tones.py new file mode 100644 index 0000000..b441f39 --- /dev/null +++ b/pytheory/tones.py @@ -0,0 +1,102 @@ +from ._statics import REFERENCE_A + +class Tone: + # __slots__ = ("name", "octave", "system") + + def __init__(self, *, name, octave=None, system=None): + self.name = name + self.octave = octave + self.system = system + + if self.system: + try: + assert self.name in self.system.tones + except AssertionError: + raise ValueError( + f"Tone {self.name!r} was not found in system: {self.system.tones!r}" + ) + + @property + def full_name(self): + if self.octave: + return f"{self.name}{self.octave}" + else: + return self.name + + def __repr__(self): + return f"" + + def __eq__(self, other): + + # Comparing string literals. + if self.name == other: + return True + + # Comparing against other Tones. + try: + if (self.name == other.name) and (self.octave == other.octave): + return True + except AttributeError: + pass + + @classmethod + def from_string(klass, s, system=None): + tone = "".join([c for c in filter(str.isalpha, s)]) + try: + octave = int("".join([c for c in filter(str.isdigit, s)])) + except ValueError: + octave = None + + return klass(name=tone, octave=octave, system=system) + + @classmethod + def from_index(klass, i, *, octave, system): + tone = system.tones[i].name + return klass(name=tone, octave=octave, system=system) + + @property + def _index(self): + try: + return self.system.tones.index(self.name) + except AttributeError: + raise ValueError("Tone index cannot be referenced without a system!") + + def _math(self, interval): + """Returns (new index, new octave).""" + + try: + mod = len(self.system.tones) + except AttributeError: + raise ValueError( + "Tone math can only be computed with an associated system!" + ) + result = self._index + interval + index = result % mod + octave = result // mod + self.octave + return (index, octave) + + def add(self, interval): + index, octave = self._math(interval) + return self.from_index(index, octave=octave, system=self.system) + + def subtract(self, interval): + return self.add((-1 * interval)) + + def pitch( + self, + *, + reference_pitch=REFERENCE_A, + temperament="equal", + symbolic=False, + precision=None, + ): + try: + tones = len(self.system.tones) + except AttributeError: + raise ValueError("Pitches can only be computed with an associated system!") + pitch_scale = TEMPERAMENTS[temperament](tones) + pitch = pitch_scale[self._index] + if symbolic: + return reference_pitch * pitch + else: + return reference_pitch * pitch.evalf(precision)