From 0bf0531fd698773e8106aa663b06afd748afaf5b Mon Sep 17 00:00:00 2001 From: Jonathan Harker Date: Fri, 27 Sep 2024 17:57:09 +1200 Subject: [PATCH] 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) --- setclass/setclass.py | 155 ++++++++++++++++++++------- setclass/tests/test_cardinalities.py | 10 +- setclass/tests/test_exploration.py | 54 ++++++---- 3 files changed, 152 insertions(+), 67 deletions(-) diff --git a/setclass/setclass.py b/setclass/setclass.py index 6c458bc..d58f34d 100644 --- a/setclass/setclass.py +++ b/setclass/setclass.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +from __future__ import annotations +import re + class cache_property: """ @@ -19,7 +22,7 @@ class SetClass(list): 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 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]) @property - def pitch_classes(self): + def pitch_classes(self) -> list: return list(self) @cache_property @@ -79,7 +82,7 @@ class SetClass(list): return sum(self.pitch_classes) @cache_property - def adjacency_intervals(self): + def adjacency_intervals(self) -> list: """Adjacency intervals between the pitch classes, used for Leonard notation subscripts.""" if not self.pitch_classes: return list() @@ -93,7 +96,7 @@ class SetClass(list): return intervals @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"). 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 @@ -134,7 +137,7 @@ class SetClass(list): return min(SetClass.ordered_interval(a, b), SetClass.ordered_interval(b, a)) @cache_property - def versions(self): + def versions(self) -> list: """ Returns all possible zero-normalised versions (clock rotations) of this set class, sorted by brightness. See Rahn (1980) Set types, Tₙ @@ -152,19 +155,12 @@ class SetClass(list): return versions @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 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])] @@ -176,15 +172,69 @@ class SetClass(list): return versions[0] @cache_property - def darkest_form(self): + def packed_left(self) -> SetClass: """ - 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 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. """ - 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 - 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. 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 @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 clock diagram. @@ -200,7 +250,7 @@ class SetClass(list): return SetClass(*[self.tonality - i for i in self.pitch_classes], tonality=self.tonality) @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: {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 @cache_property - def complement(self): + def complement(self) -> SetClass: """ Returns the set class containing all pitch classes absent in this one (rotated so the smallest is 0). @@ -257,7 +307,16 @@ class SetClass(list): # 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. 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) - 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). 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)) + 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 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)) + 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(): - """ - 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 + @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 SetClass.all_of_cardinality(C): + if sc.darkest_form.brightness < sc.prime_form.brightness: + cases.add((C, sc.darkest_form, sc.prime_form)) + return cases diff --git a/setclass/tests/test_cardinalities.py b/setclass/tests/test_cardinalities.py index 1c2a751..736db9a 100644 --- a/setclass/tests/test_cardinalities.py +++ b/setclass/tests/test_cardinalities.py @@ -11,7 +11,7 @@ def test_all_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" 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(): @@ -48,7 +48,7 @@ def test_all_of_cardinality(): assert SetClass(0, 1, 2) 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, 1, 3, 4, 8, 10) in s6 @@ -58,14 +58,14 @@ def test_all_of_cardinality(): def test_darkest_of_cardinality(): s3 = SetClass.darkest_of_cardinality(3) s6 = SetClass.darkest_of_cardinality(6) - assert len(s3) == 22 - assert len(s6) == 127 + assert len(s3) == 19 + assert len(s6) == 80 # only one version (rotation) of each set class is present assert SetClass(0, 1, 2) 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, 1, 3, 4, 8, 10) in s6 diff --git a/setclass/tests/test_exploration.py b/setclass/tests/test_exploration.py index 27fb2b4..ba61283 100644 --- a/setclass/tests/test_exploration.py +++ b/setclass/tests/test_exploration.py @@ -1,4 +1,4 @@ -from setclass.setclass import SetClass, bright_rahn_normal_forms +from setclass.setclass import SetClass def test_brightness_conjecture(): @@ -23,29 +23,24 @@ def test_brightness_conjecture(): 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). """ - R = bright_rahn_normal_forms() - assert len(R) == 63 - counts = { - 0: 0, - 1: 0, - 2: 0, - 3: 1, - 4: 3, - 5: 17, - 6: 7, - 7: 23, - 8: 9, - 9: 3, - 10: 0, - 11: 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] + R = SetClass.bright_rahn_normal_forms() + assert len(R) == 61 + sets = [[i for i in R if i[0] == C] for C in range(13)] + assert [len(s) for s in sets] == [0, 0, 0, 1, 3, 17, 6, 23, 8, 3, 0, 0, 0] + + +def test_bright_prime_forms(): + """ + There are 59 instances where the prime form is not also the "darkest" (smallest sum of pitch + class values). + """ + P = SetClass.bright_prime_forms() + assert len(P) == 59 + sets = [[i for i in P if i[0] == C] for C in range(13)] + assert [len(s) for s in sets] == [0, 0, 0, 1, 3, 16, 6, 23, 8, 2, 0, 0, 0] def print_bright_rahn(): @@ -56,6 +51,19 @@ def print_bright_rahn(): values). """ 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: print(f"{d.leonard_notation}, {r.leonard_notation}")