Add Forte prime and name, methods to derive from decimal and forte names, tests

- Add properties for Forte prime (include inversions) and Forte name
 - Create set class objects from the Forte name or decimal
 - Fix brightest_form to use most packed to the right
 - Add a dictionary of (hopefully correct!) FORTE_PRIMES
 - Add tests to improve coverage
This commit is contained in:
Jonathan Harker 2024-10-12 21:17:27 +13:00
parent 640a1440b3
commit a85898b416
3 changed files with 584 additions and 13 deletions

353
setclass/forte.py Normal file
View file

@ -0,0 +1,353 @@
FORTE_PRIMES = {
1: '1-1',
3: '2-1',
5: '2-2',
9: '2-3',
17: '2-4',
33: '2-5',
65: '2-6',
7: '3-1',
11: '3-2',
13: '3-2',
19: '3-3',
25: '3-3',
35: '3-4',
49: '3-4',
67: '3-5',
97: '3-5',
21: '3-6',
37: '3-7',
41: '3-7',
69: '3-8',
81: '3-8',
133: '3-9',
73: '3-10',
137: '3-11',
145: '3-11',
273: '3-12',
15: '4-1',
23: '4-2',
29: '4-2',
27: '4-3',
39: '4-4',
57: '4-4',
71: '4-5',
113: '4-5',
135: '4-6',
51: '4-7',
99: '4-8',
195: '4-9',
45: '4-10',
43: '4-11',
53: '4-11',
77: '4-12',
89: '4-12',
75: '4-13',
105: '4-13',
141: '4-14',
177: '4-14',
83: '4-Z15',
101: '4-Z15',
163: '4-16',
197: '4-16',
153: '4-17',
147: '4-18',
201: '4-18',
275: '4-19',
281: '4-19',
291: '4-20',
85: '4-21',
149: '4-22',
169: '4-22',
165: '4-23',
277: '4-24',
325: '4-25',
297: '4-26',
293: '4-27',
329: '4-27',
585: '4-28',
139: '4-Z29',
209: '4-Z29',
31: '5-1',
47: '5-2',
61: '5-2',
55: '5-3',
59: '5-3',
79: '5-4',
121: '5-4',
143: '5-5',
241: '5-5',
103: '5-6',
115: '5-6',
199: '5-7',
227: '5-7',
93: '5-8',
87: '5-9',
117: '5-9',
91: '5-10',
109: '5-10',
157: '5-11',
185: '5-11',
107: '5-Z12',
279: '5-13',
285: '5-13',
167: '5-14',
229: '5-14',
327: '5-15',
155: '5-16',
217: '5-16',
283: '5-Z17',
179: '5-Z18',
205: '5-Z18',
203: '5-19',
211: '5-19',
355: '5-20',
397: '5-20',
307: '5-21',
409: '5-21',
403: '5-22',
173: '5-23',
181: '5-23',
171: '5-24',
213: '5-24',
301: '5-25',
361: '5-25',
309: '5-26',
345: '5-26',
299: '5-27',
425: '5-27',
333: '5-28',
357: '5-28',
331: '5-29',
421: '5-29',
339: '5-30',
405: '5-30',
587: '5-31',
589: '5-31',
595: '5-32',
613: '5-32',
341: '5-33',
597: '5-34',
661: '5-35',
151: '5-Z36',
233: '5-Z36',
313: '5-Z37',
295: '5-Z38',
457: '5-Z38',
63: '6-1',
95: '6-2',
125: '6-2',
111: '6-Z3',
123: '6-Z3',
119: '6-Z4',
207: '6-5',
243: '6-5',
231: '6-Z6',
455: '6-7',
189: '6-8',
175: '6-9',
245: '6-9',
187: '6-Z10',
221: '6-Z10',
183: '6-Z11',
237: '6-Z11',
215: '6-Z12',
235: '6-Z12',
219: '6-Z13',
315: '6-14',
441: '6-14',
311: '6-15',
473: '6-15',
371: '6-16',
413: '6-16',
407: '6-Z17',
467: '6-Z17',
423: '6-18',
459: '6-18',
411: '6-Z19',
435: '6-Z19',
819: '6-20',
349: '6-21',
373: '6-21',
343: '6-22',
469: '6-22',
365: '6-Z23',
347: '6-Z24',
437: '6-Z24',
363: '6-Z25',
429: '6-Z25',
427: '6-Z26',
603: '6-27',
621: '6-27',
619: '6-Z28',
717: '6-Z29',
715: '6-30',
845: '6-30',
691: '6-31',
821: '6-31',
693: '6-32',
685: '6-33',
725: '6-33',
683: '6-34',
853: '6-34',
1365: '6-35',
159: '6-Z36',
249: '6-Z36',
287: '6-Z37',
399: '6-Z38',
317: '6-Z39',
377: '6-Z39',
303: '6-Z40',
489: '6-Z40',
335: '6-Z41',
485: '6-Z41',
591: '6-Z42',
359: '6-Z43',
461: '6-Z43',
615: '6-Z44',
627: '6-Z44',
605: '6-Z45',
599: '6-Z46',
629: '6-Z46',
663: '6-Z47',
669: '6-Z47',
679: '6-Z48',
667: '6-Z49',
723: '6-Z50',
127: '7-1',
191: '7-2',
253: '7-2',
319: '7-3',
505: '7-3',
223: '7-4',
251: '7-4',
239: '7-5',
247: '7-5',
415: '7-6',
499: '7-6',
463: '7-7',
487: '7-7',
381: '7-8',
351: '7-9',
501: '7-9',
607: '7-10',
637: '7-10',
379: '7-11',
445: '7-11',
671: '7-Z12',
375: '7-13',
477: '7-13',
431: '7-14',
491: '7-14',
471: '7-15',
623: '7-16',
635: '7-16',
631: '7-Z17',
755: '7-Z18',
829: '7-Z18',
719: '7-19',
847: '7-19',
743: '7-20',
925: '7-20',
823: '7-21',
827: '7-21',
871: '7-22',
701: '7-23',
757: '7-23',
687: '7-24',
981: '7-24',
733: '7-25',
749: '7-25',
699: '7-26',
885: '7-26',
695: '7-27',
949: '7-27',
747: '7-28',
861: '7-28',
727: '7-29',
941: '7-29',
855: '7-30',
939: '7-30',
731: '7-31',
877: '7-31',
859: '7-32',
875: '7-32',
1367: '7-33',
1371: '7-34',
1387: '7-35',
367: '7-Z36',
493: '7-Z36',
443: '7-Z37',
439: '7-Z38',
475: '7-Z38',
255: '8-1',
383: '8-2',
509: '8-2',
639: '8-3',
447: '8-4',
507: '8-4',
479: '8-5',
503: '8-5',
495: '8-6',
831: '8-7',
927: '8-8',
975: '8-9',
765: '8-10',
703: '8-11',
1013: '8-11',
763: '8-12',
893: '8-12',
735: '8-13',
1005: '8-13',
759: '8-14',
957: '8-14',
863: '8-Z15',
1003: '8-Z15',
943: '8-16',
983: '8-16',
891: '8-17',
879: '8-18',
987: '8-18',
887: '8-19',
955: '8-19',
951: '8-20',
1375: '8-21',
1391: '8-22',
1403: '8-22',
1455: '8-23',
1399: '8-24',
1495: '8-25',
1467: '8-26',
1463: '8-27',
1499: '8-27',
1755: '8-28',
751: '8-Z29',
989: '8-Z29',
511: '9-1',
767: '9-2',
1021: '9-2',
895: '9-3',
1019: '9-3',
959: '9-4',
1015: '9-4',
991: '9-5',
1007: '9-5',
1407: '9-6',
1471: '9-7',
1531: '9-7',
1503: '9-8',
1527: '9-8',
1519: '9-9',
1759: '9-10',
1775: '9-11',
1783: '9-11',
1911: '9-12',
1023: '10-1',
1535: '10-2',
1791: '10-3',
1919: '10-4',
1983: '10-5',
2015: '10-6',
2047: '11-1',
4095: '12-1',
}

View file

@ -41,7 +41,7 @@ class SetClass(list):
try: try:
pitches.add(int(i) % tonality) pitches.add(int(i) % tonality)
except ValueError: except ValueError:
raise ValueError("Requires integer arguments (use 10 and 11 for 'T' and 'E')") raise TypeError("Requires integer arguments (use 10 and 11 for 'T' and 'E')")
# if necessary "rotate" so that the lowest non-zero interval is zero # if necessary "rotate" so that the lowest non-zero interval is zero
if pitches: if pitches:
@ -281,15 +281,36 @@ class SetClass(list):
n += 1 n += 1
return versions[0] return versions[0]
@cached_property
def forte_prime(self) -> SetClass:
"""
Return the Forte prime of the set class, including its inversions. Forte's list of named set
classes included inversions, for instance his "3-11" set class has a normal form of {0,3,7}
which describes the minor chord, and also describes its (set) inversion {0,5,9} which has a
:attr:`prime_form` of {0,4,7}, the major chord.
"""
return min(self, self.inversion)
@cached_property
def forte_name(self) -> str:
"""
Return the Forte name of this set class. Only applies to 12-tonality set classes.
Allen Forte listed and named all possible prime set classes in his book *The Structure
of Atonal Music* (1973) in Appendix 1, p. 179-181.
"""
if self.tonality != 12:
return ''
from setclass.forte import FORTE_PRIMES
d = self.forte_prime.decimal
return FORTE_PRIMES[d] if d in FORTE_PRIMES else ''
@cached_property @cached_property
def darkest_form(self) -> SetClass: def darkest_form(self) -> SetClass:
""" """
The version of this set class with the smallest :attr:`brightness` value, most packed to the The version of this set class with the smallest :attr:`brightness` value, most packed to the
left. left.
""" """
if not self.versions:
return self
B = min(i.brightness for i in self.versions) B = min(i.brightness for i in self.versions)
versions = [i for i in self.versions if i.brightness == B] versions = [i for i in self.versions if i.brightness == B]
if len(versions) == 1: if len(versions) == 1:
@ -307,10 +328,22 @@ class SetClass(list):
@cached_property @cached_property
def brightest_form(self) -> SetClass: def brightest_form(self) -> SetClass:
""" """
The version of this set class with the largest :attr:`brightness` value. The version of this set class with the largest :attr:`brightness` value, most packed to the
TODO: How to break a tie? Or return all matches in a tuple? Sorted by what? right.
""" """
return self.versions[-1] if self.versions else self B = max(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]
@cached_property @cached_property
def inversion(self) -> SetClass: def inversion(self) -> SetClass:
@ -406,8 +439,8 @@ class SetClass(list):
and integers must be separated by some non-integer character(s), usually a space, comma, or and integers must be separated by some non-integer character(s), usually a space, comma, or
similar. For instance: similar. For instance:
>>> SetClass.from_string("Prélude à l'après-midi d'un faune uses [0,2,4,6,8,10]") >>> SetClass.from_string("Prélude à l'après-midi d'un faune uses [0,2,4,6,8,9]")
SetClass{0,2,4,6,8,10} SetClass{0,2,4,6,8,9}
>>> SetClass.from_string('{0, 3, 7}', tonality=8) >>> SetClass.from_string('{0, 3, 7}', tonality=8)
SetClass{0,3,7} (T=8) SetClass{0,3,7} (T=8)
@ -423,6 +456,62 @@ class SetClass(list):
""" """
return SetClass(*re.findall(r'\d+', string), tonality=tonality) return SetClass(*re.findall(r'\d+', string), tonality=tonality)
@classmethod
def from_decimal(this, decimal: int, tonality: int = 12) -> SetClass:
"""
Create a :class:`SetClass` from its decimal number.
A useful convenience function for converting from a decimal representation of set classes.
For instance:
>>> SetClass.from_decimal(145)
SetClass{0,4,7}
>>> SetClass.from_decimal(137, tonality=8)
SetClass{0,3,7} (T=8)
Args:
decmial (int):
The decimal to convert from.
tonality (int):
The :attr:`tonality`, or modulus, is the number of uniform divisions of the octave.
If unspecified, the default value of 12 is assumed (Western harmony).
Returns:
A :class:`SetClass` instance with the pitch classes encoded in the decimal representation.
"""
if decimal.bit_length() > tonality:
raise ValueError("Decimal number too large for tonality's maximum cardinality")
pc = list()
for i in range(decimal.bit_length()):
if decimal & (2 ** i):
pc.append(i)
return SetClass(*pc, tonality=tonality)
@classmethod
def from_forte_name(this, name: str) -> SetClass:
"""
Create a :class:`SetClass` from its Forte prime form designation. Assumes 12-tonality.
Name is case insensitive.
A useful convenience function for creating a set class from its Forte name. For instance:
>>> SetClass.from_forte_name('3-11')
SetClass{0,3,7}
Args:
name (str):
The Forte name (case insensitive).
Returns:
A :class:`SetClass` instance in Forte prime form.
"""
from setclass.forte import FORTE_PRIMES
decimal = next((k for k, v in FORTE_PRIMES.items() if v == str(name).upper()), None)
if decimal:
return SetClass.from_decimal(decimal)
else:
raise ValueError(f"Forte name '{name}' not found.")
@classmethod @classmethod
def all_of_cardinality(this, cardinality: int, tonality: int = 12) -> set: def all_of_cardinality(this, cardinality: int, tonality: int = 12) -> set:
""" """

View file

@ -1,3 +1,4 @@
import pytest
from setclass.setclass import SetClass from setclass.setclass import SetClass
@ -5,6 +6,16 @@ from setclass.setclass import SetClass
# Functional tests # Functional tests
def test_no_init_args():
empty = SetClass()
assert empty.pitch_classes == []
def test_non_integer_init_args():
with pytest.raises(TypeError):
SetClass('Z', 'abc')
def test_zero_normalised_pc(): def test_zero_normalised_pc():
"Pitch classes are normalised to start at zero" "Pitch classes are normalised to start at zero"
a = SetClass(0, 1, 3) a = SetClass(0, 1, 3)
@ -66,6 +77,99 @@ def test_interval_vector_invarant():
assert version.interval_vector == iv assert version.interval_vector == iv
def test_z_relations():
# Forte 4-Z15 {0,1,4,6} and Forte 4-Z29 {0,1,3,7} both have iv⟨1,1,1,1,1,1⟩
z1 = SetClass.from_forte_name('4-Z15')
z2 = SetClass.from_forte_name('4-Z29')
assert z1 == SetClass(0, 1, 4, 6)
assert z2 == SetClass(0, 1, 3, 7)
assert z1 in z1.z_relations
assert z1 in z2.z_relations
assert z2 in z1.z_relations
assert z2 in z2.z_relations
def test_forte_name():
tests = [
('1-1', [0]),
('3-11', [0, 3, 7]),
('6-34', [0, 1, 3, 5, 7, 9]),
('6-35', [0, 2, 4, 6, 8, 10]),
('6-Z43', [0, 1, 2, 5, 6, 8]),
('12-1', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]),
]
for (name, pitch_classes) in tests:
assert SetClass(*pitch_classes).forte_name == name
def test_forte_name_tonality():
assert SetClass(0, 1, 2, tonality=13).forte_name == ''
# ----------------------------------------------------------------------------
# Class methods
def test_from_string():
tests = [
("No integer values in this string", []),
("Here is a 1", [0]),
("{0, 3, 7}", [0, 3, 7]),
(" 0 4 7 ", [0, 4, 7]),
("Prélude à l'après-midi d'un faune uses [0,2,4,6,8,9]", [0, 2, 4, 6, 8, 9]),
("Works from Leonard notation: [0₁1₂3₁4₂6₂8₂9₃]⁽³¹⁾", [0, 1, 3, 4, 6, 8, 9]),
("Repeated numbers: 0,0,1,1,6,8", [0, 1, 6, 8]),
("Modulo numbers: 1, 22, 3, 2, 12, 144", [0, 1, 2, 3, 10]),
]
for (string, pitch_classes) in tests:
assert SetClass.from_string(string).pitch_classes == pitch_classes
def test_from_decimal():
tests = [
(0, []),
(1, [0]),
(137, [0, 3, 7]),
(145, [0, 4, 7]),
(853, [0, 2, 4, 6, 8, 9]),
(1365, [0, 2, 4, 6, 8, 10]),
(4095, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]),
]
for (decimal, pitch_classes) in tests:
assert SetClass.from_decimal(decimal).pitch_classes == pitch_classes
def test_from_decimal_error():
with pytest.raises(ValueError):
SetClass.from_decimal(4096)
# with pytest.raises(ValueError):
# sc = SetClass.from_decimal(2048, tonality=11)
def test_from_forte_name():
tests = [
('1-1', [0]),
('3-11', [0, 3, 7]),
('6-34', [0, 1, 3, 5, 7, 9]),
('6-35', [0, 2, 4, 6, 8, 10]),
('6-Z43', [0, 1, 2, 5, 6, 8]),
('12-1', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]),
]
for (name, pitch_classes) in tests:
assert SetClass.from_forte_name(name).pitch_classes == pitch_classes
def test_from_forte_name_errors():
tests = [
'0-1',
'',
'Wibble',
None,
]
for name in tests:
with pytest.raises(ValueError):
SetClass.from_forte_name(name)
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# Example Forte 5-20 set class # Example Forte 5-20 set class
f520 = SetClass(0, 1, 5, 6, 8) f520 = SetClass(0, 1, 5, 6, 8)
@ -107,11 +211,21 @@ def test_versions():
assert len(f520.versions) == 5 assert len(f520.versions) == 5
def test_is_symmetrical():
sc = SetClass(0, 1, 2, 5, 9)
assert sc.rahn_normal_form == sc.inversion.rahn_normal_form
assert sc.is_symmetrical
sc = SetClass(0, 1, 2, 5, 8)
assert sc.rahn_normal_form != sc.inversion.rahn_normal_form
assert not sc.is_symmetrical
def test_brightest_form(): def test_brightest_form():
b = f520.brightest_form b = SetClass(0, 1, 2, 3, 4, 6, 7, 8, 10)
assert b in f520.versions assert b.brightest_form in b.versions
assert b == SetClass(0, 4, 5, 9, 10) assert b.brightest_form == SetClass(0, 2, 3, 4, 6, 8, 9, 10, 11)
assert b.leonard_notation == '[0₄4₁5₄9₁T₂]⁽²⁸⁾' assert b.leonard_notation == '[0₁1₁2₁3₁4₂6₁7₁8₂T₂]⁽⁴¹'
def test_darkest_form(): def test_darkest_form():
@ -127,6 +241,21 @@ def test_rahn_normal_form():
assert r == SetClass(0, 1, 5, 6, 8) assert r == SetClass(0, 1, 5, 6, 8)
def test_rahn_prime_form():
r = f520.rahn_prime_form
assert r == SetClass(0, 2, 3, 7, 8)
assert r not in f520.versions
assert r in f520.inversion.versions
# prime = min(self.rahn_normal_form.decimal, self.inversion.rahn_normal_form.decimal)
# return self.inversion.rahn_normal_form if self.inversion.rahn_normal_form.decimal == prime else self.inversion.rahn_normal_form
def test_packed_left():
r = f520.packed_left
assert r == SetClass(0, 1, 3, 7, 8)
assert r in f520.versions
def test_inversion(): def test_inversion():
i = f520.inversion i = f520.inversion
assert i not in f520.versions assert i not in f520.versions