commit 4011c1fa470f5ed131035f3a3597d96a35870552 Author: Jonathan Harker Date: Fri Sep 20 12:58:54 2024 +1200 Initial WIP commit diff --git a/setclass.py b/setclass.py new file mode 100644 index 0000000..4449623 --- /dev/null +++ b/setclass.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 + +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__ + + 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. + """ + def __init__(self, *args: int, tonality: int = 12): + """ + 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: + + sc = SetClass(0, 7, 9) + sc = SetClass(0, 2, 3, tonality=7) + """ + self._tonality = tonality + + # deduplicate arg values, modulo each by tonality + pitches = set() + for i in set({*args}): + try: + pitches.add(int(i) % tonality) + except ValueError: + raise ValueError("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: + n = min(pitches) + pitches = [i - n for i in pitches] + + # in ascending order + for i in sorted(pitches): + super().append(i) + + def __repr__(self) -> str: + """Return the Python instance string representation.""" + return f"SetClass{self.pitch_classes}".replace(' ', '') + + def __hash__(self) -> int: + return sum([hash(i) for i in self.pitch_classes]) + + @property + def pitch_classes(self): + return list(self) + + @cache_property + def tonality(self) -> int: + """ + Returns the number of (equal) 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 + def cardinality(self) -> int: + """Returns the cardinality of the set class, i.e. the number of pitch classes.""" + return len(self.pitch_classes) + + @cache_property + def brightness(self) -> int: + """Returns the brightness of the set class, defined as the sum of the pitch class values.""" + return sum(self.pitch_classes) + + @cache_property + def adjacency_intervals(self): + """Adjacency intervals between the pitch classes, used for Leonard notation subscripts.""" + if not self.pitch_classes: + return list() + intervals = list() + prev = None + for i in self.pitch_classes: + if prev is not None: + intervals.append(i - prev) + prev = i + intervals.append(self.tonality - prev) + return intervals + + @cache_property + def versions(self): + """ + Returns all possible zero-normalised versions (clock rotations) of this set class, + sorted by brightness. + """ + # The empty set class has one version, itself + if not self.pitch_classes: + return [self] + + versions = set() + for i in self.pitch_classes: + translate = self.tonality - i + versions.add(SetClass(*[j + translate for j in self.pitch_classes], tonality=self.tonality)) + versions = list(versions) + versions.sort(key=lambda x: x.brightness) + return versions + + @cache_property + def darkest_form(self): + """ + 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 self.versions[0] if self.versions else self + + @cache_property + def brightest_form(self): + """ + Returns the version with the largest brightness value. + TODO: How to break a tie? Or return all matches in a tuple? Sorted by what? + """ + return self.versions[-1] if self.versions else self + + @cache_property + def complement(self): + """ + Returns the set class containing all pitch classes absent in this one (rotated so the + smallest is 0). + """ + return SetClass(*[i for i in range(self.tonality) if i not in self.pitch_classes], tonality=self.tonality) + + @cache_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). + """ + return f"{self}" if self.tonality > 12 else f"{self}".replace('10', '↊').replace('11', '↋') + + @cache_property + def duodecimal_notation(self) -> str: + """ + If tonality is no greater than 12, replace 10 and 11 with 'T' and 'E'. + """ + return f"{self}" if self.tonality > 12 else f"{self}".replace('10', 'T').replace('11', 'E') + + @cache_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. For example, Forte 5-20, {0,1,5,6,8} is + notated [0₁1₄5₁6₂8₄]²⁰ + """ + # Return numbers as subscript and superscript strings: + def subscript(n: int) -> str: + return "".join(["₀₁₂₃₄₅₆₇₈₉"[int(i)] for i in str(n)]) + + def superscript(n: int) -> str: + return "".join(["⁰¹²³⁴⁵⁶⁷⁸⁹"[int(i)] for i in str(n)]) + + pitches = "" + for i in range(self.cardinality): + pitch = self.pitch_classes[i] + sub = subscript(self.adjacency_intervals[i]) + pitches += f"{pitch}{sub}" + sup = superscript(self.brightness) + return f"[{pitches}]{sup}" + + +if __name__ == "__main__": + print("Running tests:") + + # Forte 0-1, the empty set + empty = SetClass() + print(f"Forte 0-1: {empty}") + assert empty.tonality == 12 + assert empty.cardinality == 0 + assert empty.brightness == 0 + assert len(empty) == 0 + assert len(empty.pitch_classes) == 0 + assert len(empty.adjacency_intervals) == 0 + assert empty.leonard_notation == '[]⁰' + print(f" Leonard: {empty.leonard_notation}") + assert len(empty.versions) == 1 + print(f" Versions ({len(empty.versions)}):") + for i in empty.versions: + print(f" {i.leonard_notation}") + assert empty.brightest_form == empty.darkest_form + + # Forte 12-1, the complement of empty (full set) + f121 = empty.complement + print(f"Forte 12-1: {empty}") + assert f121 == SetClass(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) + assert f121.tonality == 12 + assert f121.cardinality == 12 + assert f121.brightness == 66 + assert len(f121) == 12 + assert len(f121.pitch_classes) == 12 + assert len(f121.adjacency_intervals) == 12 + assert f121.leonard_notation == '[0₁1₁2₁3₁4₁5₁6₁7₁8₁9₁10₁11₁]⁶⁶' + assert len(f121.versions) == 1 + assert f121.brightest_form == f121.darkest_form + + # Forte 5-20 + f520 = SetClass(0, 1, 5, 6, 8) + print(f"{f520}") + assert f520.tonality == 12 + assert f520.cardinality == 5 + assert f520.brightness == 20 + assert len(f520) == 5 + assert len(f520.pitch_classes) == 5 + print(f"Adjacency intervals: {f520.adjacency_intervals}") + assert len(f520.adjacency_intervals) == 5 + assert len(f520.versions) == 5 + print(f"Leonard notation: {f520.leonard_notation}") + assert f520.leonard_notation == '[0₁1₄5₁6₂8₄]²⁰' + + assert f520.brightest_form.leonard_notation == '[0₄4₁5₄9₁10₂]²⁸' + assert f520.brightest_form == SetClass(0, 4, 5, 9, 10) + + sc = f520 + co = f520.complement + print(f"Versions ({len(sc.versions)}):") + for i in sc.versions: + print(f" {i.leonard_notation}") + print(f"Brightest form: {sc.brightest_form.leonard_notation}") + print(f"Darkest form: {sc.darkest_form.leonard_notation}") + print() + + print(f"Complement: {co}") + print(f"Adjacency intervals: {co.adjacency_intervals}") + print(f"Leonard notation: {co.leonard_notation}") + print(f"Versions ({len(co.versions)}):") + for i in co.versions: + print(f" {i.leonard_notation}") + print(f"Darkest form: {co.darkest_form}")