Compare commits

..

8 commits
docs ... main

Author SHA1 Message Date
8bb811cf1c Update README with docs 2024-10-15 15:24:05 +13:00
a85898b416 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
2024-10-12 21:24:10 +13:00
640a1440b3 Copy edit documentation after suggestions from B.P. Leonard 2024-10-12 16:07:19 +13:00
64dd35e1f8 Improve docstrings for Sphinx documentation 2024-10-02 13:51:52 +13:00
42d4cfb427 Replace handrolled cached property with the built-in 2024-10-02 09:35:19 +13:00
6c42477d7a Add decimal number (bitmask), from Goyette (2012) 2024-10-02 08:44:02 +13:00
929813c587 Add more type hints, fix from_string for tonality 2024-10-02 08:31:50 +13:00
b0e0fe94ee Merge pull request 'Add documentation by Sphinx' (#1) from docs into main
Reviewed-on: #1
2024-10-02 08:29:57 +13:00
8 changed files with 894 additions and 145 deletions

View file

@ -25,17 +25,20 @@ In Python:
from setclass import SetClass
sc = SetClass(0, 3, 5, 6, 7, 10, 11) # Forte 7-20; pitch classes as integers 0-11
sc.versions
sc.brightest_form
sc.darkest_form
sc.rahn_normal_form
sc.forte_name
sc.duodecimal_notation # SetClass[0,3,5,6,7,T,E]
```
Proper library documentation to come soon with Sphinx.
Documentation is generated from the doc comments with Sphinx, and in the meantime is available [here](https://git.jon.geek.nz/docs/public/setclass/setclass.html):
```
make -C docs html
```
## TODO
- Documentation (Sphinx)
- <s>Documentation (Sphinx)</s>
- Interoperate with music21 objects
- Generate MIDI files
- Generate LilyPond files for set pitches

View file

@ -3,8 +3,9 @@
# You can set these variables from the command line, and also
# from the environment for the first two.
PROJROOT := $(abspath $(MAKEFILE_LIST)/../..)
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SPHINXBUILD ?= PYTHONPATH="$(PROJROOT)" sphinx-build
SOURCEDIR = source
BUILDDIR = build

View file

@ -28,6 +28,7 @@ release = '0.1'
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [
'sphinx.ext.napoleon',
'sphinx.ext.autodoc',
'sphinx.ext.todo',
'sphinx.ext.viewcode',
@ -61,9 +62,7 @@ source_suffix = {
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'sphinx_rtd_theme'
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
html_theme_options = {
'display_version': False,
'navigation_depth': 2,
'prev_next_buttons_location': 'None'
}

View file

@ -6,6 +6,8 @@ setclass module
.. automodule:: setclass.setclass
:members:
:special-members: __init__
:member-order: bysource
:undoc-members:
:show-inheritance:

View file

@ -1,3 +1,4 @@
# Main requirements
-r requirements.txt
# Code lint
@ -14,5 +15,6 @@ tox
# Documentation
sphinx
sphinx_rtd_theme
sphinx-rtd-theme
sphinxcontrib-napoleon
myst-parser

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

@ -1,37 +1,37 @@
#!/usr/bin/env python3
from __future__ import annotations
from functools import cached_property
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.
Musical set class, containing zero or more pitch classes. This implementation can handle set
classes of any arbitrary :attr:`tonality`, or number of uniform divisions of the octave.
"""
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:
Instantiate a :class:`ClassSet` object with a series of integers.
sc = SetClass(0, 7, 9)
sc = SetClass(0, 2, 3, tonality=7)
Each supplied pitch class is "normalised" by modulo the :attr:`tonality`, the set is
sorted in ascending order, and values are transposed until the lowest pitch class 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:
>>> sc1 = SetClass(0, 7, 9)
>>> sc1
SetClass{0,7,9}
>>> sc2 = SetClass(0, 2, 3, tonality=7)
>>> sc2
SetClass{0,2,3} (T=7)
Args:
*args (int):
The pitch class values.
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).
"""
self._tonality = tonality
@ -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:
@ -53,38 +53,60 @@ class SetClass(list):
super().append(i)
def __repr__(self) -> str:
"""Return the Python instance string representation."""
"""Return a string representation of this instance."""
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 a unique identifier for the instance, based on the :attr:`pitch_classes` used."""
return sum([hash(i) for i in self.pitch_classes])
@property
def pitch_classes(self) -> list:
def pitch_classes(self) -> list(int):
"""The pitch classes as an ordered :class:`list` of integers."""
return list(self)
@cache_property
@cached_property
def tonality(self) -> int:
"""
Returns the number of (equal) divisions of the octave. The default value is 12, which
The number of uniform 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
@cached_property
def cardinality(self) -> int:
"""Returns the cardinality of the set class, i.e. the number of pitch classes."""
"""
The cardinality of the set class, i.e. the number of :attr:`pitch_classes`.
"""
return len(self.pitch_classes)
@cache_property
@cached_property
def brightness(self) -> int:
"""Returns the brightness of the set class, defined as the sum of the pitch class values."""
"""
The brightness of the set class.
Brightness (B) is a property proposed by Brian Leonard, defined as the sum of the values of
the :attr:`pitch_classes` in the set class.
"""
return sum(self.pitch_classes)
@cache_property
def adjacency_intervals(self) -> list:
"""Adjacency intervals between the pitch classes, used for Leonard notation subscripts."""
@cached_property
def decimal(self) -> int:
"""
The decimal value of the binary representation of the :attr:`pitch_classes`.
Returns the decimal value of the pitch classes expressed as a binary bit mask, i.e. the sum
of 2 where i is each pitch class value in ascending order. For example, set class {0,1,4,6}
has a binary value of 000001010011, which is the decimal value 83.
Further reading: Goyette (2012) p. 25, citing Brinkman (1986).
"""
return sum([2**i for i in self.pitch_classes])
@cached_property
def adjacency_intervals(self) -> list(int):
"""The ordered :class:`list` of adjacency intervals between the pitch classes."""
if not self.pitch_classes:
return list()
intervals = list()
@ -96,52 +118,85 @@ class SetClass(list):
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 iv1,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
@cached_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
An ordered :class:`list` 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. Each element in the vector is the frequency of occurrence of the interval
represented by its ordinal position, i.e. 2,5,4,3,6,1 means two semitones, five major
seconds, four minor thirds, and so on. 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)
ic = self.unordered_interval(a, b)
iv[ic - 1] += 1
return iv
def ordered_interval(a: int, b: int) -> int:
@cached_property
def z_relations(self) -> list:
"""
The ordered interval or "directed interval" (Babbitt) of two pitch classes is determined by
the difference of the pitch class values, modulo 12:
ia,b = b-a mod 12 Rahn (1980), p. 25
"""
return (b % 12 - a % 12) % 12
A :class:`list` of the Z-relations of this set class.
def unordered_interval(a: int, b: int) -> int:
Allen Forte in his book *The Structure of Atonal Music* (1973) described the relationship
between twins of set classes that share the same :attr:`interval_vector`, but are not
related by :attr:`inversion`, :attr:`complement`, or transposition, as Z-related ('Z' for
*zygote* from Greek: ζυγωτός, 'joined' or 'paired'). This property returns all distinct set
classes with the same interval vector.
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
are not inversions, complements, or transpositions of each other.
"""
return [i for i in SetClass.darkest_of_cardinality(self.cardinality) if i.interval_vector == self.interval_vector]
def ordered_interval(self, a: int, b: int) -> int:
"""
Return the ordered interval of two pitch classes.
The ordered interval, or "directed interval" (Babbitt) of two :attr:`pitch_classes` is
determined by the difference of the pitch class values, modulo :attr:`tonality`. For example
with tonality 12:
ia,b = b-a mod 12
Rahn (1980), p. 25
Args:
a (int): the first pitch class value.
b (int): the second pitch class value.
Returns:
The ordered interval as ann integer.
"""
return (b % self.tonality - a % self.tonality) % self.tonality
def unordered_interval(self, a: int, b: int) -> int:
"""
Return the unordered interval of two pitch classes.
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: ia,b, ib,a Rahn (1980), p. 28
"""
return min(SetClass.ordered_interval(a, b), SetClass.ordered_interval(b, a))
interval") of two :attr:`pitch_classes` is the smaller of the possible values of
:attr:`ordered_interval` (differences in pitch class value):
@cache_property
def versions(self) -> list:
i(a,b) = min: ia,b, ib,a
Rahn (1980), p. 28
Args:
a (int): the first pitch class value.
b (int): the second pitch class value.
Returns:
The ordered interval as ann integer.
"""
Returns all possible zero-normalised versions (clock rotations) of this set class,
sorted by brightness. See Rahn (1980) Set types, Tₙ
return min(self.ordered_interval(a, b), self.ordered_interval(b, a))
@cached_property
def versions(self) -> list(SetClass):
"""
All possible zero-normalised transpositions of this set class, sorted by :attr:`brightness`.
See Rahn (1980), Tₙ set types.
"""
# The empty set class has one version, itself
if not self.pitch_classes:
@ -155,12 +210,16 @@ class SetClass(list):
versions.sort(key=lambda x: x.brightness)
return versions
@cache_property
@cached_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
The Rahn normal form of the set class.
John Rahn's normal form described in his book *Basic Atonal Theory* (1980) is an algorithm
to produce a unique form for each set class. Often wrongly described as "most packed to the
left", Leonard describes it as "most dispersed from the right". Find the smallest outside
interval, and if necessary proceed inwards from the right finding the smallest next interval
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])]
@ -172,11 +231,20 @@ class SetClass(list):
n += 1
return versions[0]
@cache_property
@cached_property
def rahn_prime_form(self) -> SetClass:
"""
The Rahn prime is the most dispersed from the right of the Rahn normal forms of a set class
and its inversion.
"""
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
@cached_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
The form of the set class that is most packed to the left (the smallest adjacency intervals
to the left). Find the smallest first adjacency interval, and proceed towards the right
until one result remains.
"""
def _most_packed(versions, n):
@ -189,14 +257,15 @@ class SetClass(list):
n += 1
return versions[0]
@cache_property
@cached_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).
Return the prime form of the set class.
Allen Forte describes the algorithm for finding this normal form in his book *The Structure
of Atonal Music* (1973) in section 1.2 (pp. 3-5), citing Milton Babbitt (1961). 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).
"""
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])]
@ -212,14 +281,36 @@ class SetClass(list):
n += 1
return versions[0]
@cache_property
@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:
"""
Returns the version with the smallest brightness value, most packed to the left.
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:
@ -234,61 +325,91 @@ class SetClass(list):
n += 1
return versions[0]
@cache_property
@cached_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?
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]
@cache_property
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:
"""
Returns the inversion of this set class, equivalent of reflection through the 0 axis on a
clock diagram.
The inversion of this set class, transposed so the smallest pitch class is 0. Equivalent to
a 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
@cached_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}
Whether this set class is symmetrical upon inversion, for example Forte 5-Z37:
>>> sc = SetClass(0, 1, 2, 5, 9)
>>> sc.rahn_normal_form
SetClass{0,3,4,5,8}
>>> sc.inversion.rahn_normal_form
SetClass{0,3,4,5,8}
>>> sc.is_symmetrical
True
"""
return self.darkest_form == self.inversion.darkest_form
@cache_property
@cached_property
def complement(self) -> SetClass:
"""
Returns the set class containing all pitch classes absent in this one (rotated so the
smallest is 0).
The set class containing all :attr:`pitch_classes` absent in this one, transposed so the
smallest pitch class is 0.
"""
return SetClass(*[i for i in range(self.tonality) if i not in self.pitch_classes], tonality=self.tonality)
@cache_property
@cached_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).
A string representation using and for 10 and 11.
For a set class with a :attr:`tonality` no greater than 12, this property replaces the 10
and 11 pitch classes with the Dozenal Society characters '' and '' respectively. These are
the Pitman forms from the Unicode 8.0 specification released in 2015.
"""
return f"{self}" if self.tonality > 12 else f"{self}".replace('10', '').replace('11', '')
@cache_property
@cached_property
def duodecimal_notation(self) -> str:
"""
If tonality is no greater than 12, replace 10 and 11 with 'T' and 'E'.
A string representation using T and E for 10 and 11.
For a set class with a :attr:`tonality` no greater than 12, this property replaces the 10
and 11 pitch classes with the letters 'T' and 'E' respectively.
"""
return f"{self}" if self.tonality > 12 else f"{self}".replace('10', 'T').replace('11', 'E')
@cache_property
@cached_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 [013468T₂]³².
The string representation proposed by Brian Leonard.
Returns a string representation of this set class using subscripts to denote the
:attr:`adjacency_intervals` between the pitch classes instead of commas, and the overall
:attr:`brightness` (sum of the values of :attr:`pitch_classes`) 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 would be written as [013468T₂]³² in this scheme:
>>> SetClass.from_string('{0,1,3,4,6,8,10}').leonard_notation
[013468T₂]³²
"""
# Return numbers as subscript and superscript strings:
def subscript(n: int) -> str:
@ -309,19 +430,107 @@ class SetClass(list):
# Class methods -----------------------------------------------------------
@classmethod
def from_string(this, string: str) -> SetClass:
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}')
Create a :class:`SetClass` from a string.
A useful convenience function for converting from various text representations of set
classes that contain a sequence of zero or more integers. Non-integer content is ignored,
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,9]")
SetClass{0,2,4,6,8,9}
>>> SetClass.from_string('{0, 3, 7}', tonality=8)
SetClass{0,3,7} (T=8)
Args:
string (str):
Any string representation of a set class, containing some integer content.
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 found in the string.
"""
return SetClass(*re.findall(r'\d+', string))
return SetClass(*re.findall(r'\d+', string), tonality=tonality)
@classmethod
def all_of_cardinality(cls, cardinality: int, tonality: int = 12) -> set:
def from_decimal(this, decimal: int, tonality: int = 12) -> SetClass:
"""
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
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:
"""
Returns a :class:`set` of all possible :class:`SetClass` objects with a given
:attr:`cardinality`.
.. warning:: High values of tonality can take a long time to calculate (T=24 takes about a
minute on an Intel i7-13700H CPU).
Args:
cardinality (int):
The :attr:`cardinality` of the set classes to return.
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:`set` of :class:`SetClass` objects.
"""
def _powerset(seq):
"""Set of all possible unique subsets."""
@ -334,36 +543,72 @@ class SetClass(list):
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:
def darkest_of_cardinality(this, cardinality: int, tonality: int = 12) -> 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
Returns a :class:`set` of all :class:`SetClass` objects with a given :attr:`cardinality` in
their darkest forms.
This produces a smaller set than :attr:`all_of_cardinality`, since it eliminates set classes
that are "brighter" transpositions of the darkest :class:`SetClass` returned by the
:attr:`darkest_form` property.
.. warning:: High values of tonality can take a long time to calculate (T=24 takes about a
minute on an Intel i7-13700H CPU).
Args:
cardinality (int):
The :attr:`cardinality` of the set classes to return.
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:`set` of :class:`SetClass` objects in :attr:`darkest_form`.
"""
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:
def normal_of_cardinality(this, cardinality: int, tonality: int = 12) -> 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
Returns a :class:`set` of all :class:`SetClass` objects with a given :attr:`cardinality` in
their Rahn normal form.
This produces a smaller set than :attr:`all_of_cardinality`, since it eliminates set classes
that are transpositions of the normalised :class:`SetClass` returned by the
:attr:`rahn_normal_form` property.
.. warning:: High values of tonality can take a long time to calculate (T=24 takes about a
minute on an Intel i7-13700H CPU).
Args:
cardinality (int):
The :attr:`cardinality` of the set classes to return.
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:`set` of :class:`SetClass` objects in :attr:`rahn_normal_form`.
"""
return set(i.rahn_normal_form for i in this.all_of_cardinality(cardinality, tonality))
@classmethod
def bright_rahn_normal_forms(this) -> set:
"""
The set of Rahn normal forms that are not the same as the darkest form.
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.
unique form for each set class (see :attr:`rahn_normal_form`). Most of the time it is also
the same as the :attr:`darkest_form` (that with the smallest :attr:`brightness` value),
except for all the times when that is not the case!
Returns:
A :class:`set` of (:attr:`cardinality`, :attr:`darkest_form`, :attr:`rahn_normal_form`)
tuples.
"""
cases = set()
for C in range(13): # 0 to 12
for sc in SetClass.all_of_cardinality(C):
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
@ -371,15 +616,21 @@ class SetClass(list):
@classmethod
def bright_prime_forms(this) -> set:
"""
The set of Forte prime forms that are not the same as the darkest form.
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.
(1961). It produces a unique form for each set class (see :attr:`prime_form`), which most of
the time is the same as the :attr:`darkest_form` (that with the smallest :attr:`brightness`
value), except for all the times when that is not the case!
Returns:
A :class:`set` of (:attr:`cardinality`, :attr:`darkest_form`, :attr:`prime_form`) tuples.
"""
cases = set()
for C in range(13): # 0 to 12
for sc in SetClass.all_of_cardinality(C):
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

View file

@ -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)
@ -86,6 +190,11 @@ def test_brightness():
assert f520.brightness == 20
def test_decimal():
"Set classes have a unique decimal number (sum of 2 raised to each normalised pitch class value)"
assert f520.decimal == 355
def test_pitch_classes():
"Set classes have pitch classes"
assert len(f520) == 5
@ -102,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():
@ -122,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
@ -153,6 +287,10 @@ def test_complement_brightness():
assert f520.complement.brightness == 32
def test_complement_decimal():
assert f520.complement.decimal == 935
def test_complement_pitch_classes():
assert len(f520.complement) == 7
assert len(f520.complement.pitch_classes) == 7