307 lines
12 KiB
Python
307 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
|
|
class cache_property:
|
|
"""
|
|
Property that only computes its value once when first accessed, and caches the result.
|
|
"""
|
|
|
|
def __init__(self, function):
|
|
self.function = function
|
|
self.name = function.__name__
|
|
|
|
def __get__(self, obj, type=None) -> object:
|
|
obj.__dict__[self.name] = self.function(obj)
|
|
return obj.__dict__[self.name]
|
|
|
|
|
|
class SetClass(list):
|
|
"""
|
|
Musical set class, containing zero or more pitch classes.
|
|
"""
|
|
|
|
def __init__(self, *args: int, tonality: int = 12):
|
|
"""
|
|
Instantiate a Class Set with a series of integers. Each pitch class is "normalised" by
|
|
modulo the tonality; the set is sorted in ascending order; and values "rotated" until the
|
|
lowest value is 0. For a number of divisions of the octave other than the default (Western
|
|
harmony) value of 12, supply an integer value using the 'tonality' keyword. Example:
|
|
|
|
sc = SetClass(0, 7, 9)
|
|
sc = SetClass(0, 2, 3, tonality=7)
|
|
"""
|
|
self._tonality = tonality
|
|
|
|
# deduplicate arg values, modulo each by tonality
|
|
pitches = set()
|
|
for i in set({*args}):
|
|
try:
|
|
pitches.add(int(i) % tonality)
|
|
except ValueError:
|
|
raise ValueError("Requires integer arguments (use 10 and 11 for 'T' and 'E')")
|
|
|
|
# if necessary "rotate" so that the lowest non-zero interval is zero
|
|
if pitches:
|
|
n = min(pitches)
|
|
pitches = [i - n for i in pitches]
|
|
|
|
# in ascending order
|
|
for i in sorted(pitches):
|
|
super().append(i)
|
|
|
|
def __repr__(self) -> str:
|
|
"""Return the Python instance string representation."""
|
|
s = f"SetClass{{{','.join(str(i) for i in self.pitch_classes)}}}"
|
|
return s if self.tonality == 12 else f"{s} (T={self.tonality})"
|
|
|
|
def __hash__(self) -> int:
|
|
return sum([hash(i) for i in self.pitch_classes])
|
|
|
|
@property
|
|
def pitch_classes(self):
|
|
return list(self)
|
|
|
|
@cache_property
|
|
def tonality(self) -> int:
|
|
"""
|
|
Returns the number of (equal) divisions of the octave. The default value is 12, which
|
|
represents traditional Western chromatic harmony, the octave divided into twelve semitones.
|
|
"""
|
|
return self._tonality
|
|
|
|
@cache_property
|
|
def cardinality(self) -> int:
|
|
"""Returns the cardinality of the set class, i.e. the number of pitch classes."""
|
|
return len(self.pitch_classes)
|
|
|
|
@cache_property
|
|
def brightness(self) -> int:
|
|
"""Returns the brightness of the set class, defined as the sum of the pitch class values."""
|
|
return sum(self.pitch_classes)
|
|
|
|
@cache_property
|
|
def adjacency_intervals(self):
|
|
"""Adjacency intervals between the pitch classes, used for Leonard notation subscripts."""
|
|
if not self.pitch_classes:
|
|
return list()
|
|
intervals = list()
|
|
prev = None
|
|
for i in self.pitch_classes:
|
|
if prev is not None:
|
|
intervals.append(i - prev)
|
|
prev = i
|
|
intervals.append(self.tonality - prev)
|
|
return intervals
|
|
|
|
@cache_property
|
|
def z_relations(self):
|
|
"""
|
|
Return all distinct set classes with the same interval vector (Allan Forte: "Z-related").
|
|
For example, Forte 4-Z15 {0,1,4,6} and Forte 4-Z29 {0,1,3,7} both have iv⟨1,1,1,1,1,1⟩ but
|
|
are not inversions, complements, or transpositions (rotations) of each other.
|
|
"""
|
|
return [i for i in SetClass.darkest_of_cardinality(self.cardinality) if i.interval_vector == self.interval_vector]
|
|
|
|
@cache_property
|
|
def interval_vector(self) -> list:
|
|
"""
|
|
An ordered tuple containing the multiplicities of each interval class in the set class.
|
|
Denoted in angle-brackets, e.g.
|
|
The interval vector of {0,2,4,5,7,9,11} is ⟨2,5,4,3,6,1⟩
|
|
— Rahn (1980), p. 100
|
|
"""
|
|
from itertools import combinations
|
|
iv = [0 for i in range(1, int(self.tonality / 2) + 1)]
|
|
for (a, b) in combinations(self.pitch_classes, 2):
|
|
ic = SetClass.unordered_interval(a, b)
|
|
iv[ic - 1] += 1
|
|
return iv
|
|
|
|
def ordered_interval(a: int, b: int) -> int:
|
|
"""
|
|
The ordered interval or "directed interval" (Babbitt) of two pitch classes is determined by
|
|
the difference of the pitch class values, modulo 12:
|
|
i⟨a,b⟩ = b-a mod 12 — Rahn (1980), p. 25
|
|
"""
|
|
return (b % 12 - a % 12) % 12
|
|
|
|
def unordered_interval(a: int, b: int) -> int:
|
|
"""
|
|
The unordered interval (also "interval distance", "interval class", "ic", or "undirected
|
|
interval") of two pitch classes is the smaller of the two possible ordered intervals
|
|
(differences in pitch class value):
|
|
i(a,b) = min: i⟨a,b⟩, i⟨b,a⟩ — Rahn (1980), p. 28
|
|
"""
|
|
return min(SetClass.ordered_interval(a, b), SetClass.ordered_interval(b, a))
|
|
|
|
@cache_property
|
|
def versions(self):
|
|
"""
|
|
Returns all possible zero-normalised versions (clock rotations) of this set class,
|
|
sorted by brightness. See Rahn (1980) Set types, Tₙ
|
|
"""
|
|
# The empty set class has one version, itself
|
|
if not self.pitch_classes:
|
|
return [self]
|
|
|
|
versions = set()
|
|
for i in self.pitch_classes:
|
|
transpose = self.tonality - i
|
|
versions.add(SetClass(*[j + transpose for j in self.pitch_classes], tonality=self.tonality))
|
|
versions = list(versions)
|
|
versions.sort(key=lambda x: x.brightness)
|
|
return versions
|
|
|
|
@cache_property
|
|
def rahn_normal_form(self):
|
|
"""
|
|
Return the Rahn normal form of the set class; Leonard describes this as "most dispersed from
|
|
the right". Find the smallest outside interval, and proceed inwards from the right until one
|
|
result remains. See Rahn (1980), p. 33
|
|
"""
|
|
# Set class {0,2,4,5,8,9} has four darkest versions:
|
|
# {0,2,4,5,8,9} B = 28
|
|
# {0,1,4,6,8,9} B = 28
|
|
# {0,2,3,6,7,10} B = 28
|
|
# {0,1,4,5,8,10} B = 28
|
|
# {0,3,5,7,8,11} B = 34
|
|
# {0,3,4,7,9,11} B = 34
|
|
def _most_dispersed(versions, n):
|
|
return [i for i in versions if i.pitch_classes[-n] == min([i.pitch_classes[-n] for i in versions])]
|
|
|
|
versions = self.versions
|
|
n = 1
|
|
while len(versions) > 1:
|
|
versions = _most_dispersed(versions, n)
|
|
n += 1
|
|
return versions[0]
|
|
|
|
@cache_property
|
|
def darkest_form(self):
|
|
"""
|
|
Returns the version with the smallest brightness value.
|
|
TODO: How to break a tie? Or return all matches in a tuple? Sorted by what?
|
|
"""
|
|
return self.versions[0] if self.versions else self
|
|
|
|
@cache_property
|
|
def brightest_form(self):
|
|
"""
|
|
Returns the version with the largest brightness value.
|
|
TODO: How to break a tie? Or return all matches in a tuple? Sorted by what?
|
|
"""
|
|
return self.versions[-1] if self.versions else self
|
|
|
|
@cache_property
|
|
def inversion(self):
|
|
"""
|
|
Returns the inversion of this set class, equivalent of reflection through the 0 axis on a
|
|
clock diagram.
|
|
"""
|
|
return SetClass(*[self.tonality - i for i in self.pitch_classes], tonality=self.tonality)
|
|
|
|
@cache_property
|
|
def is_symmetrical(self):
|
|
"""
|
|
Returns whether this set class is symmetrical upon inversion, for example Forte 5-Z17:
|
|
{0,1,2,5,9} → {0,4,8,9,11}
|
|
"""
|
|
return self.darkest_form == self.inversion.darkest_form
|
|
|
|
@cache_property
|
|
def complement(self):
|
|
"""
|
|
Returns the set class containing all pitch classes absent in this one (rotated so the
|
|
smallest is 0).
|
|
"""
|
|
return SetClass(*[i for i in range(self.tonality) if i not in self.pitch_classes], tonality=self.tonality)
|
|
|
|
@cache_property
|
|
def dozenal_notation(self) -> str:
|
|
"""
|
|
If tonality is no greater than 12, return a string representation using Dozenal Society
|
|
characters '↊' for 10 and '↋' for 11 (the Pitman forms from Unicode 8.0 release, 2015).
|
|
"""
|
|
return f"{self}" if self.tonality > 12 else f"{self}".replace('10', '↊').replace('11', '↋')
|
|
|
|
@cache_property
|
|
def duodecimal_notation(self) -> str:
|
|
"""
|
|
If tonality is no greater than 12, replace 10 and 11 with 'T' and 'E'.
|
|
"""
|
|
return f"{self}" if self.tonality > 12 else f"{self}".replace('10', 'T').replace('11', 'E')
|
|
|
|
@cache_property
|
|
def leonard_notation(self) -> str:
|
|
"""
|
|
Returns a string representation of this set class using subscripts to denote the adjacency
|
|
intervals between the pitch classes instead of commas, and the overall brightness (sum of
|
|
pitch class values) denoted as a superscript. In standard tonality (T=12) the letters T and
|
|
E are used for pitch classes 10 and 11. For example, Forte 7-34, {0,1,3,4,6,8,10} is
|
|
notated [0₁1₂3₁4₂6₂8₂T₂]⁽³²⁾.
|
|
"""
|
|
# Return numbers as subscript and superscript strings:
|
|
def subscript(n: int) -> str:
|
|
return "".join(["₀₁₂₃₄₅₆₇₈₉"[int(i)] for i in str(n)])
|
|
|
|
def superscript(n: int) -> str:
|
|
return "".join(["⁰¹²³⁴⁵⁶⁷⁸⁹"[int(i)] for i in str(n)])
|
|
|
|
pitches = ""
|
|
for i in range(self.cardinality):
|
|
pitch = self.pitch_classes[i]
|
|
sub = subscript(self.adjacency_intervals[i])
|
|
pitches += f"{pitch}{sub}"
|
|
sup = superscript(self.brightness)
|
|
s = f"[{pitches}]⁽{sup}⁾"
|
|
return s.replace('10', 'T').replace('11', 'E') if self.tonality == 12 else s
|
|
|
|
# Class methods -----------------------------------------------------------
|
|
|
|
def all_of_cardinality(cardinality: int, tonality: int = 12) -> set:
|
|
"""
|
|
Returns all set classes of a given cardinality. Tonality can be specified, default is 12.
|
|
Warning: high values of tonality can take a long time to calculate (T=24 takes about a
|
|
minute on an Intel i7-13700H CPU).
|
|
"""
|
|
def _powerset(seq):
|
|
"""Set of all possible unique subsets."""
|
|
from itertools import chain, combinations
|
|
return chain.from_iterable(combinations(seq, r) for r in range(len(seq) + 1))
|
|
|
|
if tonality < cardinality:
|
|
raise ValueError("Set classes of cardinality {cardinality} are not possible in a {tonality}-tone octave.")
|
|
|
|
return set(SetClass(*i, tonality=tonality) for i in _powerset(range(tonality)) if len(i) == cardinality and 0 in i)
|
|
|
|
def darkest_of_cardinality(cardinality: int, tonality: int = 12, prime: bool = False) -> set:
|
|
"""
|
|
Returns all set classes of a given cardinality, in their darkest forms (ignore rotations).
|
|
Tonality can be specified, default is 12.
|
|
Warning: high values of tonality can take a long time to calculate (T=24 takes about a
|
|
minute on an Intel i7-13700H CPU).
|
|
"""
|
|
return set(i.darkest_form for i in SetClass.all_of_cardinality(cardinality, tonality))
|
|
|
|
def normal_of_cardinality(cardinality: int, tonality: int = 12, prime: bool = False) -> set:
|
|
"""
|
|
Returns all set classes of a given cardinality, in their (darkest) Rahn normal forms (ignore
|
|
rotations). Tonality can be specified, default is 12.
|
|
Warning: high values of tonality can take a long time to calculate (T=24 takes about a
|
|
minute on an Intel i7-13700H CPU).
|
|
"""
|
|
return set(i.rahn_normal_form for i in SetClass.all_of_cardinality(cardinality, tonality))
|
|
|
|
|
|
def bright_rahn_normal_forms():
|
|
"""
|
|
John Rahn's normal form from _Basic Atonal Theory_ (1980) is an algorithm to produce a unique
|
|
form for each set class. Most of the time it is also the "darkest" (smallest brightness value),
|
|
except for all the times when that is not the case; this function returns that list, as a set of
|
|
(cardinality, darkest, rahn) tuples.
|
|
"""
|
|
cases = set()
|
|
for C in range(13): # 0 to 12
|
|
for sc in SetClass.all_of_cardinality(C):
|
|
if sc.darkest_form.brightness < sc.rahn_normal_form.brightness:
|
|
cases.add((C, sc.darkest_form, sc.rahn_normal_form))
|
|
return cases
|