setclass/setclass/setclass.py

385 lines
16 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import re
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__
self.__doc__ = function.__doc__
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) -> None:
"""
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) -> list(int):
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) -> list(int):
"""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) -> list:
"""
Return all distinct set classes with the same interval vector (Allen 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) -> list(SetClass):
"""
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) -> SetClass:
"""
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
"""
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 packed_left(self) -> SetClass:
"""
Return the form of the set class that is most packed to the left (smallest adjacency
intervals to the left). Find the smallest adjacency interval, and proceed towards the right
until one result remains.
"""
def _most_packed(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 = 0
while len(versions) > 1:
versions = _most_packed(versions, n)
n += 1
return versions[0]
@cache_property
def prime_form(self) -> SetClass:
"""
Return the prime form of the set class. Find the forms with the smallest outside interval,
and if necessary chose the form most packed to the left (the smallest adjacency intervals
working from left to right).
Allen Forte describes the algorithm in his book *The Structure of Atonal Music* (1973) in
section 1.2 (pp. 3-5), citing Milton Babbitt (1961).
"""
def _most_packed(versions, n):
return [i for i in versions if i.pitch_classes[n] == min([i.pitch_classes[n] for i in versions])]
# Requirement 1: select forms where the largest pitch class has the smallest value
smallest_span = min([i.pitch_classes[-1] for i in self.versions])
versions = [i for i in self.versions if i.pitch_classes[-1] == smallest_span]
# Requirement 2: select forms most packed to the left
n = 0
while len(versions) > 1:
versions = _most_packed(versions, n)
n += 1
return versions[0]
@cache_property
def darkest_form(self) -> SetClass:
"""
Returns the version with the smallest brightness value, most packed to the left.
"""
if not self.versions:
return self
B = min(i.brightness for i in self.versions)
versions = [i for i in self.versions if i.brightness == B]
if len(versions) == 1:
return versions[0]
def _most_packed(versions, n):
return [i for i in versions if i.pitch_classes[n] == min([i.pitch_classes[n] for i in versions])]
n = 0
while len(versions) > 1:
versions = _most_packed(versions, n)
n += 1
return versions[0]
@cache_property
def brightest_form(self) -> SetClass:
"""
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) -> SetClass:
"""
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) -> bool:
"""
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) -> SetClass:
"""
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 -----------------------------------------------------------
@classmethod
def from_string(this, string: str, tonality: int = 12) -> SetClass:
"""
Attempt to create a SetClass from any string containing a sequence of zero or more integers.
A useful convenience function, e.g. SetClass.from_string('{0,3,7,9}')
"""
return SetClass(*re.findall(r'\d+', string), tonality=tonality)
@classmethod
def all_of_cardinality(cls, 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)
@classmethod
def darkest_of_cardinality(this, 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 this.all_of_cardinality(cardinality, tonality))
@classmethod
def normal_of_cardinality(this, 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 this.all_of_cardinality(cardinality, tonality))
@classmethod
def bright_rahn_normal_forms(this) -> set:
"""
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 this.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
@classmethod
def bright_prime_forms(this) -> set:
"""
Allen Forte describes the algorithm for deriving the "prime" or zeroed normal form, in his
book *The Structure of Atonal Music* (1973) in section 1.2 (pp. 3-5), citing Milton Babbitt
(1961). It produces a unique form for each set class, which most of the time 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, prime) tuples.
"""
cases = set()
for C in range(13): # 0 to 12
for sc in this.all_of_cardinality(C):
if sc.darkest_form.brightness < sc.prime_form.brightness:
cases.add((C, sc.darkest_form, sc.prime_form))
return cases