diff --git a/setclass/forte.py b/setclass/forte.py new file mode 100644 index 0000000..578a68b --- /dev/null +++ b/setclass/forte.py @@ -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', +} diff --git a/setclass/setclass.py b/setclass/setclass.py index 00ea8dc..ee9abf2 100644 --- a/setclass/setclass.py +++ b/setclass/setclass.py @@ -41,7 +41,7 @@ class SetClass(list): try: pitches.add(int(i) % tonality) 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 pitches: @@ -281,15 +281,36 @@ class SetClass(list): n += 1 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 def darkest_form(self) -> SetClass: """ The version of this set class with the smallest :attr:`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: @@ -307,10 +328,22 @@ class SetClass(list): @cached_property def brightest_form(self) -> SetClass: """ - The version of this set class with the largest :attr:`brightness` value. - TODO: How to break a tie? Or return all matches in a tuple? Sorted by what? + The version of this set class with the largest :attr:`brightness` value, most packed to the + 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 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 similar. For instance: - >>> SetClass.from_string("Prélude à l'après-midi d'un faune uses [0,2,4,6,8,10]") - SetClass{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,9} >>> SetClass.from_string('{0, 3, 7}', tonality=8) SetClass{0,3,7} (T=8) @@ -423,6 +456,62 @@ class SetClass(list): """ 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 def all_of_cardinality(this, cardinality: int, tonality: int = 12) -> set: """ diff --git a/setclass/tests/test_setclass.py b/setclass/tests/test_setclass.py index e5d6a99..8e00d58 100644 --- a/setclass/tests/test_setclass.py +++ b/setclass/tests/test_setclass.py @@ -1,3 +1,4 @@ +import pytest from setclass.setclass import SetClass @@ -5,6 +6,16 @@ from setclass.setclass import SetClass # 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(): "Pitch classes are normalised to start at zero" a = SetClass(0, 1, 3) @@ -66,6 +77,99 @@ def test_interval_vector_invarant(): 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 f520 = SetClass(0, 1, 5, 6, 8) @@ -107,11 +211,21 @@ def test_versions(): 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(): - b = f520.brightest_form - assert b in f520.versions - assert b == SetClass(0, 4, 5, 9, 10) - assert b.leonard_notation == '[0₄4₁5₄9₁T₂]⁽²⁸⁾' + b = SetClass(0, 1, 2, 3, 4, 6, 7, 8, 10) + assert b.brightest_form in b.versions + assert b.brightest_form == SetClass(0, 2, 3, 4, 6, 8, 9, 10, 11) + assert b.leonard_notation == '[0₁1₁2₁3₁4₂6₁7₁8₂T₂]⁽⁴¹⁾' def test_darkest_form(): @@ -127,6 +241,21 @@ def test_rahn_normal_form(): 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(): i = f520.inversion assert i not in f520.versions