setclass/setclass/setclass.py

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