#!/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}")