"""
Module containing classes for flexibly representing musical scales. This includes the :class:`PitchInterval` class, for
representing just and/or equal-tempered intervals; the :class:`ScaleType` class, which represents a succession of
intervals without specifying a starting point; and the :class:`Scale` class, which combines a ScaleType with a
starting reference pitch, and also allows for a choice between cyclical and non-cyclical.
"""
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ #
# This file is part of SCAMP (Suite for Computer-Assisted Music in Python) #
# Copyright © 2020 Marc Evanstein <marc@marcevanstein.com>. #
# #
# This program is free software: you can redistribute it and/or modify it under the terms of #
# the GNU General Public License as published by the Free Software Foundation, either version #
# 3 of the License, or (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; #
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. #
# See the GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License along with this program. #
# If not, see <http://www.gnu.org/licenses/>. #
# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ #
from __future__ import annotations
import itertools
from fractions import Fraction
from typing import Sequence
from expenvelope.envelope import Envelope, SavesToJSON
from scamp_extensions.utilities.sequences import multi_option_method
from .utilities import ratio_to_cents
import math
from numbers import Real
import logging
from copy import deepcopy
[docs]class PitchInterval(SavesToJSON):
"""
Represents an interval between two pitches. This combines a cents displacement and a frequency ratio, allowing
it to represent both just and equal-tempered intervals, or even a combination of both. PitchIntervals can be
added, negated, and subtracted.
:param cents: cents displacement
:param ratio: frequency ratio, either instead of or in addition to the cents displacement
"""
def __init__(self, cents: float, ratio: Fraction):
self.cents = cents
self.ratio = ratio
[docs] @classmethod
def parse(cls, representation):
"""
Parses several different possible types of data into a PitchInterval object.
:param representation: One of the following:
- a float (representing cents)
- an int or a Fraction object (representing a ratio)
- a tuple of (cents, ratio)
- a string, which will be evaluated as a (cents, ratio) tuple if it has a comma, and will be evaluated as
a Fraction if it has a slash. e.g. "3" is a ratio, "37." is cents, "4/3" is a ratio, and "200., 5/4"
is a cents displacement followed by a ratio.
:return: a PitchInterval
"""
if isinstance(representation, dict):
return cls._from_json(representation)
elif isinstance(representation, str):
if "," in representation:
cents_string, ratio_string = representation.split(",")
return cls(float(cents_string), Fraction(ratio_string))
elif "/" in representation:
return cls(0, Fraction(representation))
else:
return cls.parse(eval(representation))
elif hasattr(representation, "__len__"):
return cls(float(representation[0]), Fraction(representation[1]))
elif isinstance(representation, float):
return cls(representation, Fraction(1))
elif isinstance(representation, (int, Fraction)):
return cls(0., Fraction(representation))
else:
raise ValueError("Cannot parse given representation as a pitch interval.")
[docs] def to_cents(self) -> float:
"""
Resolves this interval to its size in cents.
"""
return self.cents + ratio_to_cents(self.ratio)
[docs] def to_half_steps(self) -> float:
"""
Resolves this interval to its size in half steps.
"""
return self.to_cents() / 100
[docs] def to_scala_string(self):
"""
Returns a string representation of this interval for use in exporting to scala files. Scala intervals can be
either in cents or frequency ratio, however, unlike :class:`PitchInterval`, they cannot combine the two. Thus,
if this PitchInterval combines the two, it will be converted to a flat cents value.
"""
if self.cents == 0 and self.ratio == 1:
return "0."
elif self.cents == 0:
return str(self.ratio)
elif self.ratio == 1:
return str(self.cents)
else:
return str(self.to_cents())
# ------------------------------------- Loading / Saving ---------------------------------------
def _to_dict(self):
return {"cents": self.cents, "ratio": [self.ratio.numerator, self.ratio.denominator]}
@classmethod
def _from_dict(cls, json_dict):
json_dict["ratio"] = Fraction(*json_dict["ratio"])
return cls(**json_dict)
def __neg__(self):
return PitchInterval(-self.cents, 1/self.ratio)
def __add__(self, other):
if not isinstance(other, PitchInterval):
raise ValueError("PitchIntervals can only be added or subtracted from other PitchIntervals.")
return PitchInterval(self.cents + other.cents, self.ratio * other.ratio)
def __sub__(self, other):
return self + -other
def __repr__(self):
return "PitchInterval({}, {})".format(self.cents, self.ratio)
[docs]class ScaleType(SavesToJSON):
"""
A ScaleType represents the intervallic relationships in a scale without specifying a specific starting point.
This maps closely to what is represented in a Scala .scl file, which is why this object can load from and
save to that format. In fact, the one difference between the data stored here and that stored in a .scl file is
that this object allows a scale degree to be defined by both a cents offset and a subsequently applied ratio.
:param intervals: a sequence of intervals above the starting note. These can be either :class:`PitchInterval`
objects or anything that can be interpreted by :func:`PitchInterval.parse`.
"""
_standard_equal_tempered_patterns = {
"chromatic": [100.],
"diatonic": [200., 400., 500., 700., 900., 1100., 1200.],
"melodic minor": [200., 300., 500., 700., 900., 1100., 1200.],
"harmonic minor": [200., 300., 500., 700., 800., 1100., 1200.],
"whole tone": [200., 400., 600., 800., 1000., 1200.],
"octatonic": [200., 300., 500., 600., 800., 900., 1100., 1200.],
"pentatonic": [200., 400., 700., 900., 1200.],
"blues": [300., 500., 600., 700., 1000., 1200.]
}
def __init__(self, *intervals):
self.intervals = [x if isinstance(x, PitchInterval) else PitchInterval.parse(x) for x in intervals]
[docs] def to_half_steps(self) -> Sequence[float]:
"""
Returns a list of floats representing the number of half steps from the starting pitch for each scale degree.
"""
return [interval.to_half_steps() for interval in self.intervals]
[docs] def rotate(self, steps: int, in_place: bool = True) -> ScaleType:
"""
Rotates the step sizes of this scale type in the manner of a modal shift. E.g. going from ionian to lydian
would be a rotation of 3.
:param steps: the number of steps to shift the starting point of the scale up or down by. Can be negative.
:param in_place: whether to modify this ScaleType in place, or to return a modified copy.
:return: the modified ScaleType
"""
intervals = self.intervals if in_place else deepcopy(self.intervals)
steps = steps % len(intervals)
if steps == 0:
rotated_intervals = intervals
else:
shift_first_intervals_up = intervals[steps:] + [x + intervals[-1] for x in intervals[:steps]]
rotated_intervals = [x - intervals[steps - 1] for x in shift_first_intervals_up]
if in_place:
self.intervals = rotated_intervals
return self
else:
return ScaleType(*rotated_intervals)
# ------------------------------------- Class Methods ---------------------------------------
[docs] @classmethod
def chromatic(cls):
"""Returns a 12-tone equal tempered chromatic ScaleType."""
return cls(*ScaleType._standard_equal_tempered_patterns["chromatic"])
[docs] @classmethod
def diatonic(cls, modal_shift: int = 0) -> ScaleType:
"""
Returns a diatonic ScaleType with the specified modal shift.
:param modal_shift: how many steps up or down to shift the starting note of the scale. 0 returns ionian,
1 returns dorian, 2 returns phrygian, etc. (There are also convenience methods for creating these
modal scale types.)
"""
return cls(*ScaleType._standard_equal_tempered_patterns["diatonic"]).rotate(modal_shift)
[docs] @classmethod
def major(cls, modal_shift: int = 0) -> ScaleType:
"""Alias of :func:`ScaleType.diatonic`."""
return cls.diatonic(modal_shift)
[docs] @classmethod
def ionian(cls, modal_shift: int = 0) -> ScaleType:
"""Alias of :func:`ScaleType.diatonic`."""
return cls.diatonic(modal_shift)
[docs] @classmethod
def dorian(cls) -> ScaleType:
"""Convenience method for creating a dorian ScaleType."""
return cls.diatonic(1)
[docs] @classmethod
def phrygian(cls) -> ScaleType:
"""Convenience method for creating a phrygian ScaleType."""
return cls.diatonic(2)
[docs] @classmethod
def lydian(cls) -> ScaleType:
"""Convenience method for creating a lydian ScaleType."""
return cls.diatonic(3)
[docs] @classmethod
def mixolydian(cls) -> ScaleType:
"""Convenience method for creating a myxolydian ScaleType."""
return cls.diatonic(4)
[docs] @classmethod
def aeolian(cls) -> ScaleType:
"""Convenience method for creating an aeolian ScaleType."""
return cls.diatonic(5)
[docs] @classmethod
def natural_minor(cls) -> ScaleType:
"""Alias of :func:`ScaleType.aeolian`."""
return cls.aeolian()
[docs] @classmethod
def locrian(cls) -> ScaleType:
"""Convenience method for creating an locrian ScaleType."""
return cls.diatonic(6)
[docs] @classmethod
def harmonic_minor(cls, modal_shift: int = 0) -> ScaleType:
"""
Returns a harmonic minor ScaleType with the specified modal shift.
:param modal_shift: How many steps up or down to shift the starting note of the scale. The default value of
zero creates the standard harmonic minor scale.
"""
return cls(*ScaleType._standard_equal_tempered_patterns["harmonic minor"]).rotate(modal_shift)
[docs] @classmethod
def melodic_minor(cls, modal_shift: int = 0) -> ScaleType:
"""
Returns a melodic minor ScaleType with the specified modal shift.
:param modal_shift: How many steps up or down to shift the starting note of the scale. The default value of
zero creates the standard melodic minor scale.
"""
return cls(*ScaleType._standard_equal_tempered_patterns["melodic minor"]).rotate(modal_shift)
[docs] @classmethod
def whole_tone(cls) -> ScaleType:
"""Convenience method for creating a whole tone ScaleType."""
return cls(*ScaleType._standard_equal_tempered_patterns["whole tone"])
[docs] @classmethod
def octatonic(cls, whole_step_first: bool = True) -> ScaleType:
"""
Convenience method for creating an octatonic (alternating whole and half steps) ScaleType
:param whole_step_first: whether to start with a whole step or a half step.
"""
if whole_step_first:
return cls(*ScaleType._standard_equal_tempered_patterns["octatonic"])
else:
return cls(*ScaleType._standard_equal_tempered_patterns["octatonic"]).rotate(1)
[docs] @classmethod
def pentatonic(cls, modal_shift: int = 0) -> ScaleType:
"""
Returns a pentatonic ScaleType with the specified modal shift.
:param modal_shift: how many steps up or down to shift the starting note of the scale. A shift of 3 creates
a minor pentatonic scale.
"""
return cls(*ScaleType._standard_equal_tempered_patterns["pentatonic"]).rotate(modal_shift)
[docs] @classmethod
def pentatonic_minor(cls) -> ScaleType:
"""Convenience method for creating a pentatonic minor ScaleType."""
return cls.pentatonic(4)
[docs] @classmethod
def blues(cls) -> ScaleType:
"""Convenience method for creating a blues ScaleType."""
return cls(*ScaleType._standard_equal_tempered_patterns["blues"])
# ------------------------------------- Loading / Saving ---------------------------------------
[docs] def save_to_scala(self, file_path: str, description: str = "Mystery scale saved using SCAMP") -> None:
"""
Converts and saves this ScaleType to a scala file at the given file path. Note that any intervals that combine
cents and ratio information will be flattened out to only cents information, since the combination is not
possible in scala files.
:param file_path: path of the file to save
:param description: description of the scale for the file header
"""
lines = ["! {}".format(file_path.split("/")[-1]),
"!",
"{}".format(description),
str(len(self.intervals)),
"!"]
lines.extend(interval.to_scala_string() for interval in self.intervals)
with open(file_path, "w") as scala_file:
scala_file.write("\n".join(lines))
[docs] @classmethod
def load_from_scala(cls, file_path: str) -> ScaleType:
"""
Loads a ScaleType from a scala file.
:param file_path: file path of a correctly formatted scala file
"""
pitch_entries = []
with open(file_path, "r") as scala_file:
lines = scala_file.read().split("\n")
description = num_steps = None
for line in lines:
line = line.strip()
if line.startswith("!") or len(line) == 0:
continue
elif description is None:
description = line
elif num_steps is None:
num_steps = int(line)
else:
first_non_numeric_char = None
for i, char in enumerate(line):
if not (char.isnumeric() or char in (".", "/")):
first_non_numeric_char = i
break
if first_non_numeric_char is None:
pitch_entries.append(line)
else:
pitch_entries.append(line[:i])
if len(pitch_entries) != num_steps:
logging.warning("Wrong number of pitches in Scala file. "
"That's fine, I guess, but though you should know...")
return cls(*pitch_entries)
def _to_dict(self):
return {
"intervals": self.intervals
}
@classmethod
def _from_dict(cls, json_dict):
return cls(*json_dict["intervals"])
def __repr__(self):
return "ScaleType({})".format(self.intervals)
[docs]class Scale(SavesToJSON):
"""
Class representing a scale starting on a specific pitch. A :class:`Scale` combines a :class:`ScaleType` with a
starting pitch, and also an option as to whether the pitch collection should cycle (as pretty much all the
standard scales do). To illustrate the difference between a :class:`ScaleType` and a :class:`Scale`, "D dorian"
would be represented by a :class:`Scale`, whereas "dorian" would be represented by a :class:`ScaleType`.
:param scale_type: a :class:`ScaleType` object
:param start_pitch: a pitch to treat as the starting note of the scale
:param cycle: whether or not this scale cycles. If so, the interval from the first pitch to the last pitch is
treated as the cycle size.
"""
def __init__(self, scale_type: ScaleType, start_pitch: Real, cycle: bool = True):
self.scale_type = scale_type
self._start_pitch = start_pitch
self._cycle = cycle
self._initialize_instance_vars()
@property
def start_pitch(self) -> Real:
"""The pitch that scale starts from."""
return self._start_pitch
@start_pitch.setter
def start_pitch(self, value):
self._start_pitch = value
self._initialize_instance_vars()
@property
def cycle(self) -> bool:
"""Whether or not this scale repeats after a full cycle."""
return self._cycle
@cycle.setter
def cycle(self, value):
self._cycle = value
self._initialize_instance_vars()
def _initialize_instance_vars(self):
# convert the scale type to a list of MIDI-valued seed pitches
self._seed_pitches = (self._start_pitch,) + tuple(self._start_pitch + x for x in self.scale_type.to_half_steps())
self._envelope = Envelope.from_points(*zip(range(len(self._seed_pitches)), self._seed_pitches))
self._inverse_envelope = Envelope.from_points(*zip(self._seed_pitches, range(len(self._seed_pitches))))
self.num_steps = len(self._seed_pitches) - 1
self.width = self._seed_pitches[-1] - self._seed_pitches[0] if self._cycle else None
[docs] @classmethod
def from_pitches(cls, seed_pitches: Sequence[Real], cycle: bool = True) -> Scale:
"""
Constructs a Scale from a list of seed pitches, given as floating-point MIDI pitch values. For instance, a
C major scale could be constructed by calling Scale.from_pitches([60, 62, 64, 65, 67, 69, 71, 72]). Note that
the upper C needs to be specified, since it is not assumed that scales will be octave repeating, and the repeat
interval is given by the distance between the first and last seed pitch. Also note that this particular C major
scale would place scale degree 0 at middle C, whereas Scale.from_pitches([48, 50, 52, 53, 55, 57, 59, 60]) would
place it an octave lower.
:param seed_pitches: a list of floating-point MIDI pitch values.
:param cycle: Whether or not to cycle the scale, creating multiple "octaves" (or perhaps not octaves if the
scale repeats at a different interval.
"""
return cls(ScaleType(*(100. * (x - seed_pitches[0]) for x in seed_pitches[1:])), seed_pitches[0], cycle=cycle)
[docs] @classmethod
def from_scala_file(cls, file_path: str, start_pitch: Real, cycle: bool = True) -> Scale:
"""
Constructs a Scale from a scala file located at the given file path, and a start pitch.
:param file_path: path of the scala file to load
:param start_pitch: the pitch to define as scale degree 0
:param cycle: whether or not this scale is treated as cyclic
"""
return cls(ScaleType.load_from_scala(file_path), start_pitch, cycle=cycle)
[docs] @classmethod
def from_start_pitch_and_cent_or_ratio_intervals(cls, start_pitch: Real, intervals, cycle: bool = True) -> Scale:
"""
Creates a scale from a start pitch and a sequence of intervals (either cents or frequency ratios).
:param start_pitch: The pitch to start on
:param intervals: a sequence of intervals above the start pitch. These can be either :class:`PitchInterval`
objects or anything that can be interpreted by :func:`PitchInterval.parse`.
:param cycle: whether or not this scale is treated as cyclic. See explanation in :func:`Scale.from_pitches`
about defining cyclic scales.
"""
return cls(ScaleType(*intervals), start_pitch, cycle=cycle)
[docs] @multi_option_method
def degree_to_pitch(self, degree: Real) -> float:
"""
Given a degree of the scale, returns the pitch that it corresponds to. Degree 0 corresponds to the start
pitch, and negative degrees correspond to notes below the start pitch (for cyclical scales). Fractional degrees
are possible and result in pitches interpolated between the scale degrees.
:param degree: a (potentially floating-point) scale degree
"""
if self._cycle:
cycle_displacement = math.floor(degree / self.num_steps)
mod_degree = degree % self.num_steps
return self._envelope.value_at(mod_degree) + cycle_displacement * self.width
else:
return self._envelope.value_at(degree)
[docs] @multi_option_method
def pitch_to_degree(self, pitch: Real) -> float:
"""
Given a pitch, returns the scale degree that it corresponds to. Pitches that lie in between the notes of the
scale will return fractional degrees via interpolation.
:param pitch: a pitch, potentially in between scale degrees
"""
if self._cycle:
cycle_displacement = math.floor((pitch - self._seed_pitches[0]) / self.width)
mod_pitch = (pitch - self._seed_pitches[0]) % self.width + self._seed_pitches[0]
return self._inverse_envelope.value_at(mod_pitch) + cycle_displacement * self.num_steps
else:
return self._inverse_envelope.value_at(pitch)
[docs] @multi_option_method
def round(self, pitch: Real) -> float:
"""Rounds the given pitch to the nearest note of the scale."""
return self.degree_to_pitch(round(self.pitch_to_degree(pitch)))
[docs] @multi_option_method
def floor(self, pitch: Real) -> float:
"""Returns the nearest note of the scale below or equal to the given pitch."""
return self.degree_to_pitch(math.floor(self.pitch_to_degree(pitch)))
[docs] @multi_option_method
def ceil(self, pitch: Real) -> float:
"""Returns the nearest note of the scale above or equal to the given pitch."""
return self.degree_to_pitch(math.ceil(self.pitch_to_degree(pitch)))
# ------------------------------------- Transformations ---------------------------------------
[docs] def transpose(self, half_steps: float) -> Scale:
"""
Transposes this scale (in place) by the given number of half steps.
:param half_steps: number of half steps to transpose up or down by
:return: self, for chaining purposes
"""
self._start_pitch = self._start_pitch + half_steps
self._initialize_instance_vars()
return self
[docs] def transposed(self, half_steps: float) -> Scale:
"""
Same as :func:`Scale.transpose`, except that it returns a transposed copy, leaving this scale unaltered.
"""
copy = self.duplicate()
copy.transpose(half_steps)
return copy
# ------------------------------------- Class Methods ---------------------------------------
[docs] @classmethod
def chromatic(cls, start_pitch: Real = 60, cycle: bool = True) -> Scale:
"""
Returns a 12-tone equal tempered chromatic scale starting on the specified pitch.
:param start_pitch: the pitch this scale starts from (doesn't affect the scale in this case, but affects
where we count scale degrees from).
:param cycle: whether or not this scale repeats after an octave or is constrained to a single octave.
"""
return cls(ScaleType.chromatic(), start_pitch, cycle=cycle)
[docs] @classmethod
def diatonic(cls, start_pitch: Real, modal_shift: int = 0, cycle: bool = True) -> Scale:
"""
Returns a diatonic scale starting on the specified pitch, and with the specified modal shift.
:param start_pitch: the pitch this scale starts from
:param modal_shift: how many steps up or down to shift the scale's interval relationships. 0 is ionian, 1 is
dorian, 2 is phrygian, etc. (There are also convenience methods for creating these modal scales.)
:param cycle: whether or not this scale repeats after an octave or is constrained to a single octave.
"""
return cls(ScaleType.diatonic(modal_shift), start_pitch, cycle=cycle)
[docs] @classmethod
def major(cls, start_pitch: Real, modal_shift: int = 0, cycle: bool = True) -> Scale:
"""Alias of :func:`Scale.diatonic`."""
return cls.diatonic(start_pitch, modal_shift, cycle)
[docs] @classmethod
def ionian(cls, start_pitch: Real, modal_shift: int = 0, cycle: bool = True) -> Scale:
"""Alias of :func:`Scale.diatonic`."""
return cls.diatonic(start_pitch, modal_shift, cycle)
[docs] @classmethod
def dorian(cls, start_pitch: Real, cycle: bool = True) -> Scale:
"""
Convenience method for creating a dorian scale with the given start pitch. (Same as :func:`Scale.diatonic` with
a modal shift of 1.)
"""
return cls(ScaleType.dorian(), start_pitch, cycle=cycle)
[docs] @classmethod
def phrygian(cls, start_pitch: Real, cycle: bool = True) -> Scale:
"""
Convenience method for creating a phrygian scale with the given start pitch. (Same as :func:`Scale.diatonic`
with a modal shift of 2.)
"""
return cls(ScaleType.phrygian(), start_pitch, cycle=cycle)
[docs] @classmethod
def lydian(cls, start_pitch: Real, cycle: bool = True) -> Scale:
"""
Convenience method for creating a lydian scale with the given start pitch. (Same as :func:`Scale.diatonic`
with a modal shift of 3.)
"""
return cls(ScaleType.lydian(), start_pitch, cycle=cycle)
[docs] @classmethod
def mixolydian(cls, start_pitch: Real, cycle: bool = True) -> Scale:
"""
Convenience method for creating a mixolydian scale with the given start pitch. (Same as :func:`Scale.diatonic`
with a modal shift of 4.)
"""
return cls(ScaleType. mixolydian(), start_pitch, cycle=cycle)
[docs] @classmethod
def aeolian(cls, start_pitch: Real, cycle: bool = True) -> Scale:
"""
Convenience method for creating a aeolian scale with the given start pitch. (Same as :func:`Scale.diatonic`
with a modal shift of 5.)
"""
return cls(ScaleType.aeolian(), start_pitch, cycle=cycle)
[docs] @classmethod
def natural_minor(cls, start_pitch: Real, cycle: bool = True) -> Scale:
"""Alias of :func:`Scale.aeolian`."""
return cls.aeolian(start_pitch, cycle)
[docs] @classmethod
def locrian(cls, start_pitch: Real, cycle: bool = True) -> Scale:
"""
Convenience method for creating a locrian scale with the given start pitch. (Same as :func:`Scale.diatonic`
with a modal shift of 6.)
"""
return cls(ScaleType.locrian(), start_pitch, cycle=cycle)
[docs] @classmethod
def harmonic_minor(cls, start_pitch: Real, modal_shift: int = 0, cycle: bool = True) -> Scale:
"""
Returns a harmonic minor scale starting on the specified pitch, and with the specified modal shift.
:param start_pitch: the pitch this scale starts from
:param modal_shift: How many steps up or down to shift the scale's interval relationships. To get a regular
harmonic minor scale, simply use the default modal shift of 0.
:param cycle: whether or not this scale repeats after an octave or is constrained to a single octave.
"""
return cls(ScaleType.harmonic_minor(modal_shift), start_pitch, cycle=cycle)
[docs] @classmethod
def melodic_minor(cls, start_pitch: Real, modal_shift: int = 0, cycle: bool = True) -> Scale:
"""
Returns a melodic minor scale starting on the specified pitch, and with the specified modal shift.
:param start_pitch: the pitch this scale starts from
:param modal_shift: How many steps up or down to shift the scale's interval relationships. To get a regular
melodic minor scale, simply use the default modal shift of 0. A so-called acoustic scale (major sharp-4,
flat-7) can be produced with a modal shift of 4.
:param cycle: whether or not this scale repeats after an octave or is constrained to a single octave.
"""
return cls(ScaleType.melodic_minor(modal_shift), start_pitch, cycle=cycle)
[docs] @classmethod
def whole_tone(cls, start_pitch: Real, cycle: bool = True) -> Scale:
"""
Returns a whole tone scale with the given start pitch.
:param start_pitch: the pitch this scale starts from
:param cycle: whether or not this scale repeats after an octave or is constrained to a single octave.
"""
return cls(ScaleType.whole_tone(), start_pitch, cycle=cycle)
[docs] @classmethod
def octatonic(cls, start_pitch: Real, cycle: bool = True, whole_step_first: bool = True) -> Scale:
"""
Returns an octatonic scale with the given start pitch.
:param start_pitch: the pitch this scale starts from
:param cycle: whether or not this scale repeats after an octave or is constrained to a single octave.
:param whole_step_first: whether this is a whole-half or half-whole octatonic scale.
"""
return cls(ScaleType.octatonic(whole_step_first=whole_step_first), start_pitch, cycle=cycle)
[docs] @classmethod
def pentatonic(cls, start_pitch: Real, modal_shift: int = 0, cycle: bool = True) -> Scale:
"""
Returns a pentatonic scale starting on the specified pitch, and with the specified modal shift.
:param start_pitch: the pitch this scale starts from
:param modal_shift: How many steps up or down to shift the scale's interval relationships. To get a regular
harmonic minor scale, simply use the default modal shift of 0.
:param cycle: whether or not this scale repeats after an octave or is constrained to a single octave.
"""
return cls(ScaleType.pentatonic(modal_shift), start_pitch, cycle=cycle)
[docs] @classmethod
def pentatonic_minor(cls, start_pitch: Real, cycle: bool = True) -> Scale:
"""
Returns a pentatonic minor scale starting on the specified pitch.
:param start_pitch: the pitch this scale starts from
:param cycle: whether or not this scale repeats after an octave or is constrained to a single octave.
"""
return cls(ScaleType.pentatonic_minor(), start_pitch, cycle=cycle)
[docs] @classmethod
def blues(cls, start_pitch: Real, cycle: bool = True) -> Scale:
"""
Returns a 6-note blues scale starting on the specified pitch.
:param start_pitch: the pitch this scale starts from
:param cycle: whether or not this scale repeats after an octave or is constrained to a single octave.
"""
return cls(ScaleType.blues(), start_pitch, cycle=cycle)
# ------------------------------------- Loading / Saving ---------------------------------------
def _to_dict(self):
return {
"scale_type": self.scale_type,
"start_pitch": self._start_pitch,
"cycle": self._cycle
}
@classmethod
def _from_dict(cls, json_dict):
return cls(**json_dict)
# ------------------------------------- Special Methods ---------------------------------------
def __getitem__(self, item):
if isinstance(item, Real):
return self.degree_to_pitch(item)
elif isinstance(item, slice):
start = 0 if item.start is None else item.start
step = 1 if item.step is None else item.step
if item.stop is None:
return (self.degree_to_pitch(x) for x in itertools.count(start, step))
else:
return [self.degree_to_pitch(x)
for x in itertools.islice(itertools.count(start, step), int((item.stop - start) / step))]
elif isinstance(item, (list, tuple)):
pieces = [[self.__getitem__(x)] if isinstance(x, Real) else self.__getitem__(x) for x in item]
if all(isinstance(x, list) for x in pieces):
return sum(pieces, start=[])
else:
return itertools.chain(*pieces)
def __iter__(self):
for step_num in range(self.num_steps + 1):
yield self.degree_to_pitch(step_num)
def __contains__(self, item):
if not self._cycle:
return item in self._seed_pitches
else:
return (item - self.start_pitch) % self.width + self.start_pitch in self._seed_pitches
def __repr__(self):
return "Scale({}, {}{})".format(
repr(self.scale_type),
self._start_pitch,
", cycle={}".format(self._cycle) if not self._cycle else ""
)