Add Forte prime form, fix darkest form and Rahn errors, use classmethods

- add the packed_left and prime_form property for Allen Forte prime
   forms
 - add type annotations
 - add from_string convenience class factory method
 - fix darkest_form to always produce the same result using left-packing
 - amend tests to correct values (eliminated duplicates in bright lists)
 - produce lists of cases where Rahn and Forte primes are not the
   "darkest" forms (smallest sum of pitch classes)
This commit is contained in:
Jonathan Harker 2024-09-27 17:57:09 +12:00
parent d6b4b07d86
commit 0bf0531fd6
3 changed files with 152 additions and 67 deletions

View file

@ -1,4 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations
import re
class cache_property: class cache_property:
""" """
@ -19,7 +22,7 @@ class SetClass(list):
Musical set class, containing zero or more pitch classes. Musical set class, containing zero or more pitch classes.
""" """
def __init__(self, *args: int, tonality: int = 12): def __init__(self, *args: int, tonality: int = 12) -> None:
""" """
Instantiate a Class Set with a series of integers. Each pitch class is "normalised" by 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 modulo the tonality; the set is sorted in ascending order; and values "rotated" until the
@ -57,7 +60,7 @@ class SetClass(list):
return sum([hash(i) for i in self.pitch_classes]) return sum([hash(i) for i in self.pitch_classes])
@property @property
def pitch_classes(self): def pitch_classes(self) -> list:
return list(self) return list(self)
@cache_property @cache_property
@ -79,7 +82,7 @@ class SetClass(list):
return sum(self.pitch_classes) return sum(self.pitch_classes)
@cache_property @cache_property
def adjacency_intervals(self): def adjacency_intervals(self) -> list:
"""Adjacency intervals between the pitch classes, used for Leonard notation subscripts.""" """Adjacency intervals between the pitch classes, used for Leonard notation subscripts."""
if not self.pitch_classes: if not self.pitch_classes:
return list() return list()
@ -93,7 +96,7 @@ class SetClass(list):
return intervals return intervals
@cache_property @cache_property
def z_relations(self): def z_relations(self) -> list:
""" """
Return all distinct set classes with the same interval vector (Allan Forte: "Z-related"). 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 iv1,1,1,1,1,1 but For example, Forte 4-Z15 {0,1,4,6} and Forte 4-Z29 {0,1,3,7} both have iv1,1,1,1,1,1 but
@ -134,7 +137,7 @@ class SetClass(list):
return min(SetClass.ordered_interval(a, b), SetClass.ordered_interval(b, a)) return min(SetClass.ordered_interval(a, b), SetClass.ordered_interval(b, a))
@cache_property @cache_property
def versions(self): def versions(self) -> list:
""" """
Returns all possible zero-normalised versions (clock rotations) of this set class, Returns all possible zero-normalised versions (clock rotations) of this set class,
sorted by brightness. See Rahn (1980) Set types, Tₙ sorted by brightness. See Rahn (1980) Set types, Tₙ
@ -152,19 +155,12 @@ class SetClass(list):
return versions return versions
@cache_property @cache_property
def rahn_normal_form(self): def rahn_normal_form(self) -> SetClass:
""" """
Return the Rahn normal form of the set class; Leonard describes this as "most dispersed from 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 the right". Find the smallest outside interval, and proceed inwards from the right until one
result remains. See Rahn (1980), p. 33 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): 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])] return [i for i in versions if i.pitch_classes[-n] == min([i.pitch_classes[-n] for i in versions])]
@ -176,15 +172,69 @@ class SetClass(list):
return versions[0] return versions[0]
@cache_property @cache_property
def darkest_form(self): def packed_left(self) -> SetClass:
""" """
Returns the version with the smallest brightness value. Return the form of the set class that is most packed to the left (smallest adjacency
TODO: How to break a tie? Or return all matches in a tuple? Sorted by what? intervals to the left). Find the smallest adjacency interval, and proceed towards the right
until one result remains.
""" """
return self.versions[0] if self.versions else self 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 @cache_property
def brightest_form(self): 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. Returns the version with the largest brightness value.
TODO: How to break a tie? Or return all matches in a tuple? Sorted by what? TODO: How to break a tie? Or return all matches in a tuple? Sorted by what?
@ -192,7 +242,7 @@ class SetClass(list):
return self.versions[-1] if self.versions else self return self.versions[-1] if self.versions else self
@cache_property @cache_property
def inversion(self): def inversion(self) -> SetClass:
""" """
Returns the inversion of this set class, equivalent of reflection through the 0 axis on a Returns the inversion of this set class, equivalent of reflection through the 0 axis on a
clock diagram. clock diagram.
@ -200,7 +250,7 @@ class SetClass(list):
return SetClass(*[self.tonality - i for i in self.pitch_classes], tonality=self.tonality) return SetClass(*[self.tonality - i for i in self.pitch_classes], tonality=self.tonality)
@cache_property @cache_property
def is_symmetrical(self): def is_symmetrical(self) -> bool:
""" """
Returns whether this set class is symmetrical upon inversion, for example Forte 5-Z17: Returns whether this set class is symmetrical upon inversion, for example Forte 5-Z17:
{0,1,2,5,9} {0,4,8,9,11} {0,1,2,5,9} {0,4,8,9,11}
@ -208,7 +258,7 @@ class SetClass(list):
return self.darkest_form == self.inversion.darkest_form return self.darkest_form == self.inversion.darkest_form
@cache_property @cache_property
def complement(self): def complement(self) -> SetClass:
""" """
Returns the set class containing all pitch classes absent in this one (rotated so the Returns the set class containing all pitch classes absent in this one (rotated so the
smallest is 0). smallest is 0).
@ -257,7 +307,16 @@ class SetClass(list):
# Class methods ----------------------------------------------------------- # Class methods -----------------------------------------------------------
def all_of_cardinality(cardinality: int, tonality: int = 12) -> set: @classmethod
def from_string(this, string: str) -> 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))
@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. 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 Warning: high values of tonality can take a long time to calculate (T=24 takes about a
@ -273,35 +332,53 @@ class SetClass(list):
return set(SetClass(*i, tonality=tonality) for i in _powerset(range(tonality)) if len(i) == cardinality and 0 in i) 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: @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). Returns all set classes of a given cardinality, in their darkest forms (ignore rotations).
Tonality can be specified, default is 12. Tonality can be specified, default is 12.
Warning: high values of tonality can take a long time to calculate (T=24 takes about a Warning: high values of tonality can take a long time to calculate (T=24 takes about a
minute on an Intel i7-13700H CPU). minute on an Intel i7-13700H CPU).
""" """
return set(i.darkest_form for i in SetClass.all_of_cardinality(cardinality, tonality)) return set(i.darkest_form for i in this.all_of_cardinality(cardinality, tonality))
def normal_of_cardinality(cardinality: int, tonality: int = 12, prime: bool = False) -> set: @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 Returns all set classes of a given cardinality, in their (darkest) Rahn normal forms (ignore
rotations). Tonality can be specified, default is 12. 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 Warning: high values of tonality can take a long time to calculate (T=24 takes about a
minute on an Intel i7-13700H CPU). minute on an Intel i7-13700H CPU).
""" """
return set(i.rahn_normal_form for i in SetClass.all_of_cardinality(cardinality, tonality)) 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 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
def bright_rahn_normal_forms(): @classmethod
""" def bright_prime_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), Allen Forte describes the algorithm for deriving the "prime" or zeroed normal form, in his
except for all the times when that is not the case; this function returns that list, as a set of book *The Structure of Atonal Music* (1973) in section 1.2 (pp. 3-5), citing Milton Babbitt
(cardinality, darkest, rahn) tuples. (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;
cases = set() this function returns that list, as a set of (cardinality, darkest, prime) tuples.
for C in range(13): # 0 to 12 """
for sc in SetClass.all_of_cardinality(C): cases = set()
if sc.darkest_form.brightness < sc.rahn_normal_form.brightness: for C in range(13): # 0 to 12
cases.add((C, sc.darkest_form, sc.rahn_normal_form)) for sc in SetClass.all_of_cardinality(C):
return cases if sc.darkest_form.brightness < sc.prime_form.brightness:
cases.add((C, sc.darkest_form, sc.prime_form))
return cases

View file

@ -11,7 +11,7 @@ def test_all_of_cardinality_set_lengths():
def test_darkest_of_cardinality_set_lengths(): def test_darkest_of_cardinality_set_lengths():
"Confirm expected lengths of the sets of set classes, for cardinality 1 through 12" "Confirm expected lengths of the sets of set classes, for cardinality 1 through 12"
sets = [SetClass.darkest_of_cardinality(i + 1) for i in range(12)] sets = [SetClass.darkest_of_cardinality(i + 1) for i in range(12)]
assert [len(s) for s in sets] == [1, 6, 22, 55, 66, 127, 66, 55, 22, 6, 1, 1] assert [len(s) for s in sets] == [1, 6, 19, 43, 66, 80, 66, 43, 19, 6, 1, 1]
def test_normal_of_cardinality_set_lengths(): def test_normal_of_cardinality_set_lengths():
@ -48,7 +48,7 @@ def test_all_of_cardinality():
assert SetClass(0, 1, 2) in s3 assert SetClass(0, 1, 2) in s3
assert SetClass(0, 10, 11) in s3 assert SetClass(0, 10, 11) in s3
assert SetClass(0, 4, 6) in s3 assert SetClass(0, 2, 8) in s3
assert SetClass(0, 6, 10) in s3 assert SetClass(0, 6, 10) in s3
assert SetClass(0, 1, 3, 4, 8, 10) in s6 assert SetClass(0, 1, 3, 4, 8, 10) in s6
@ -58,14 +58,14 @@ def test_all_of_cardinality():
def test_darkest_of_cardinality(): def test_darkest_of_cardinality():
s3 = SetClass.darkest_of_cardinality(3) s3 = SetClass.darkest_of_cardinality(3)
s6 = SetClass.darkest_of_cardinality(6) s6 = SetClass.darkest_of_cardinality(6)
assert len(s3) == 22 assert len(s3) == 19
assert len(s6) == 127 assert len(s6) == 80
# only one version (rotation) of each set class is present # only one version (rotation) of each set class is present
assert SetClass(0, 1, 2) in s3 assert SetClass(0, 1, 2) in s3
assert SetClass(0, 10, 11) not in s3 assert SetClass(0, 10, 11) not in s3
assert SetClass(0, 4, 6) in s3 assert SetClass(0, 2, 8) in s3
assert SetClass(0, 6, 10) not in s3 assert SetClass(0, 6, 10) not in s3
assert SetClass(0, 1, 3, 4, 8, 10) in s6 assert SetClass(0, 1, 3, 4, 8, 10) in s6

View file

@ -1,4 +1,4 @@
from setclass.setclass import SetClass, bright_rahn_normal_forms from setclass.setclass import SetClass
def test_brightness_conjecture(): def test_brightness_conjecture():
@ -23,29 +23,24 @@ def test_brightness_conjecture():
def test_bright_rahn(): def test_bright_rahn():
""" """
There are 63 instances where the Rahn normal form is not also the "darkest" (smallest sum of There are 61 instances where the Rahn normal form is not also the "darkest" (smallest sum of
pitch class values). pitch class values).
""" """
R = bright_rahn_normal_forms() R = SetClass.bright_rahn_normal_forms()
assert len(R) == 63 assert len(R) == 61
counts = { sets = [[i for i in R if i[0] == C] for C in range(13)]
0: 0, assert [len(s) for s in sets] == [0, 0, 0, 1, 3, 17, 6, 23, 8, 3, 0, 0, 0]
1: 0,
2: 0,
3: 1, def test_bright_prime_forms():
4: 3, """
5: 17, There are 59 instances where the prime form is not also the "darkest" (smallest sum of pitch
6: 7, class values).
7: 23, """
8: 9, P = SetClass.bright_prime_forms()
9: 3, assert len(P) == 59
10: 0, sets = [[i for i in P if i[0] == C] for C in range(13)]
11: 0, assert [len(s) for s in sets] == [0, 0, 0, 1, 3, 16, 6, 23, 8, 2, 0, 0, 0]
12: 0
}
for C in range(13): # 0 to 12
bright_rahns = set(i for i in R if i[0] == C)
assert len(bright_rahns) == counts[C]
def print_bright_rahn(): def print_bright_rahn():
@ -56,6 +51,19 @@ def print_bright_rahn():
values). values).
""" """
print("Smallest brightness, Rahn normal form") print("Smallest brightness, Rahn normal form")
set_classes = sorted(bright_rahn_normal_forms(), key=lambda x: x[0] * 1000 + x[1].brightness) set_classes = sorted(SetClass.bright_rahn_normal_forms(), key=lambda x: x[0] * 1000 + x[1].brightness)
for (C, d, r) in set_classes:
print(f"{d.leonard_notation}, {r.leonard_notation}")
def print_bright_prime():
"""
Convenience function, to print the list of set classes where the Forte prime form is not the
"darkest" form (that with the smallest brightness value), as pairs of "darkest" and Forte prime
form pairs, using Leonard notation, sorted by cardinality then brightness (sum of pitch class
values).
"""
print("Smallest brightness, Forte prime form")
set_classes = sorted(SetClass.bright_prime_forms(), key=lambda x: x[0] * 1000 + x[1].brightness)
for (C, d, r) in set_classes: for (C, d, r) in set_classes:
print(f"{d.leonard_notation}, {r.leonard_notation}") print(f"{d.leonard_notation}, {r.leonard_notation}")