From de855a3fe61cc5319a6f62bd46026553c9b0668a Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Thu, 26 Mar 2026 23:58:33 -0400 Subject: [PATCH] Add non-12-TET support: TET() factory, 19-TET, 31-TET - TET(n) factory creates N-tone equal temperament systems - Built-in named systems: "19-tet" and "31-tet" with proper note names and scale definitions (major, minor, harmonic minor, pentatonic) - Per-system c_index replaces global C_INDEX constant - Fix 6 hardcoded '12's in tones.py: from_frequency, from_midi, interval_to, midi property, circle_of_fifths/fourths - Numbered pitch classes for custom EDOs: TET(17) uses "0"-"16" - Octave parser skips numeric-only names (fixes "0" being eaten) Refs #38 Co-Authored-By: Claude Opus 4.6 (1M context) --- pytheory/__init__.py | 4 +- pytheory/systems.py | 148 ++++++++++++++++++++++++++++++++++++++++++- pytheory/tones.py | 116 +++++++++++++++++++-------------- 3 files changed, 217 insertions(+), 51 deletions(-) diff --git a/pytheory/__init__.py b/pytheory/__init__.py index 7d12a2c..07bb9f0 100644 --- a/pytheory/__init__.py +++ b/pytheory/__init__.py @@ -3,7 +3,7 @@ __version__ = "0.32.0" from .tones import Tone, Interval -from .systems import System, SYSTEMS +from .systems import System, SYSTEMS, TET from .scales import TonedScale, Key, PROGRESSIONS from .chords import Chord, Fretboard, analyze_progression from .charts import CHARTS, Fingering, charts_for_fretboard @@ -21,7 +21,7 @@ Scale = TonedScale __all__ = [ "Tone", "Note", "Interval", "Scale", "TonedScale", "Key", "PROGRESSIONS", "Chord", "Fretboard", "Fingering", "analyze_progression", - "System", "SYSTEMS", "CHARTS", "charts_for_fretboard", + "System", "SYSTEMS", "TET", "CHARTS", "charts_for_fretboard", "play", "save", "save_midi", "play_progression", "play_pattern", "play_score", "Synth", "Envelope", "Duration", "TimeSignature", "RhythmNote", "Rest", "Score", "Part", diff --git a/pytheory/systems.py b/pytheory/systems.py index 55572a9..6df210c 100644 --- a/pytheory/systems.py +++ b/pytheory/systems.py @@ -6,14 +6,36 @@ from ._statics import ( class System: - def __init__(self, *, tone_names, degrees, scales=None): + def __init__(self, *, tone_names, degrees, scales=None, c_index=None): self.tone_names = tone_names self.degrees = degrees self._scales = scales + # c_index: the index of the "reference C" in the tone list. + # For octave arithmetic — scientific pitch changes octave at C. + # Default 3 for 12-TET western (A=0, A#=1, B=2, C=3). + # For non-12-TET systems, this is the index of the tone nearest C, + # or 0 if no C equivalent exists. + if c_index is not None: + self.c_index = c_index + else: + # Try to find C in the tone names, fall back to 0 + self.c_index = 0 + for i, names in enumerate(tone_names): + if "C" in names: + self.c_index = i + break + if scales is None: - self._scales = SCALES[self.semitones] + n = self.semitones + if n in SCALES: + self._scales = SCALES[n] + else: + # Generate chromatic scale for unknown sizes + self._scales = { + "chromatic": (n, {}), + } @property def semitones(self): @@ -182,6 +204,126 @@ class System: def __repr__(self): return f"" + +def TET(n, *, names=None, reference_index=0): + """Create an N-tone equal temperament system. + + Each step divides the octave into *n* equal parts. The frequency + ratio between adjacent tones is ``2^(1/n)``. + + Args: + n: Number of equal divisions of the octave (e.g. 19, 24, 31, 53). + names: Optional list of *n* tone name strings. If omitted, + tones are numbered ``"0"`` through ``"n-1"``. + reference_index: Index of the tone that corresponds to A440 + (default 0, meaning tone "0" = A4 = 440 Hz). + + Returns: + A :class:`System` instance. + + Example:: + + >>> edo19 = TET(19) + >>> from pytheory import Tone + >>> t = Tone("0", octave=4, system=edo19) + >>> t.frequency # 440.0 Hz (tone 0 = A4) + 440.0 + + >>> edo31 = TET(31) + >>> t = Tone("18", octave=4, system=edo31) + >>> t.frequency # 18 steps above A in 31-TET + """ + if names is not None: + if len(names) != n: + raise ValueError(f"Expected {n} names, got {len(names)}") + tone_names = [(name,) for name in names] + else: + tone_names = [(str(i),) for i in range(n)] + + # Degrees: numbered, with no modal names + degrees = [(f"degree {i+1}", ()) for i in range(n)] + + # Scales: chromatic (all steps = 1) plus MOS scales for common EDOs + scale_data = { + "chromatic": (n, {}), + } + + # Add well-known scales for specific EDOs + if n == 19: + # 19-TET: major and minor have different step sizes + # Major: 3 3 2 3 3 3 2 (sums to 19) + # Minor: 3 2 3 3 2 3 3 + scale_data["heptatonic"] = [7, { + "major": {"intervals": (3, 3, 2, 3, 3, 3, 2)}, + "minor": {"intervals": (3, 2, 3, 3, 2, 3, 3)}, + "harmonic minor": {"intervals": (3, 2, 3, 3, 2, 4, 2)}, + }] + scale_data["pentatonic"] = [5, { + "major pentatonic": {"intervals": (3, 3, 5, 3, 5)}, + "minor pentatonic": {"intervals": (5, 3, 3, 5, 3)}, + }] + elif n == 24: + # 24-TET (quarter-tone): standard 12-TET scales with doubled steps + scale_data["heptatonic"] = [7, { + "major": {"intervals": (4, 4, 2, 4, 4, 4, 2)}, + "minor": {"intervals": (4, 2, 4, 4, 2, 4, 4)}, + }] + elif n == 31: + # 31-TET: excellent approximation of quarter-comma meantone + # Major: 5 5 3 5 5 5 3 (sums to 31) + # Minor: 5 3 5 5 3 5 5 + scale_data["heptatonic"] = [7, { + "major": {"intervals": (5, 5, 3, 5, 5, 5, 3)}, + "minor": {"intervals": (5, 3, 5, 5, 3, 5, 5)}, + "harmonic minor": {"intervals": (5, 3, 5, 5, 3, 7, 3)}, + }] + scale_data["pentatonic"] = [5, { + "major pentatonic": {"intervals": (5, 5, 8, 5, 8)}, + "minor pentatonic": {"intervals": (8, 5, 5, 8, 5)}, + }] + elif n == 53: + # 53-TET: nearly perfect fifths and thirds + # Major: 9 9 4 9 9 9 4 (sums to 53) + scale_data["heptatonic"] = [7, { + "major": {"intervals": (9, 9, 4, 9, 9, 9, 4)}, + "minor": {"intervals": (9, 4, 9, 9, 4, 9, 9)}, + }] + + # Find C equivalent for c_index (reference_index is A, C is 3 steps in 12-TET) + # Proportionally: C is 3/12 of the way around from A + c_idx = round(n * 3 / 12) if n != 12 else 3 + + return System( + tone_names=tone_names, + degrees=degrees, + scales=scale_data, + c_index=c_idx, + ) + + +# ── 19-TET named system ── +# Traditional note names for 19-TET: all 12 western notes plus +# 7 quarter-tone positions (enharmonic splits) +_19TET_NAMES = [ + "A", "A#", "Bb", "B", "B#", + "C", "C#", "Db", "D", "D#", + "Eb", "E", "E#", "F", "F#", + "Gb", "G", "G#", "Ab", +] + +# ── 31-TET named system ── +# Adriaan Fokker's naming: sharps and flats are distinct pitches +_31TET_NAMES = [ + "A", "A↑", "A#", "Bb", "B↓", + "B", "B↑", "C", "C↑", "C#", + "Db", "D↓", "D", "D↑", "D#", + "Eb", "E↓", "E", "E↑", "E#", + "F", "F↑", "F#", "Gb", "G↓", + "G", "G↑", "G#", "Ab", "A↓", + "A♮", # enharmonic return (distinct from "A" by a diesis) +] + + SYSTEMS = { "western": System(tone_names=TONES["western"], degrees=DEGREES["western"]), "indian": System(tone_names=TONES["indian"], degrees=DEGREES["indian"], scales=INDIAN_SCALES[12]), @@ -189,4 +331,6 @@ SYSTEMS = { "japanese": System(tone_names=TONES["japanese"], degrees=DEGREES["japanese"], scales=JAPANESE_SCALES[12]), "blues": System(tone_names=TONES["blues"], degrees=DEGREES["blues"], scales=BLUES_SCALES[12]), "gamelan": System(tone_names=TONES["gamelan"], degrees=DEGREES["gamelan"], scales=GAMELAN_SCALES[12]), + "19-tet": TET(19, names=_19TET_NAMES), + "31-tet": TET(31, names=_31TET_NAMES), } diff --git a/pytheory/tones.py b/pytheory/tones.py index 0dfeb8d..c5b2917 100644 --- a/pytheory/tones.py +++ b/pytheory/tones.py @@ -58,15 +58,19 @@ class Tone: if len(name) >= 2 and name[1] in ('x', 'X') and name[0].isalpha(): name = name[0] + '##' + name[2:] - try: - parsed_octave = int("".join([c for c in filter(str.isdigit, name)])) - except ValueError: - parsed_octave = None + # Only parse octave from trailing digits if name starts with + # a letter (e.g. "C4" → name="C", octave=4). Numeric pitch + # class names like "0" or "11" should not be parsed as octaves. + if name and name[0].isalpha(): + try: + parsed_octave = int("".join([c for c in filter(str.isdigit, name)])) + except ValueError: + parsed_octave = None - if parsed_octave is not None: - name = name.replace(str(parsed_octave), "") - if octave is None: - octave = parsed_octave + if parsed_octave is not None: + name = name.replace(str(parsed_octave), "") + if octave is None: + octave = parsed_octave self.name = name self.octave = octave @@ -354,10 +358,13 @@ class Tone: Returns: A new ``Tone`` instance. """ - try: - octave = int("".join([c for c in filter(str.isdigit, s)])) - except ValueError: - octave = None + octave = None + # Only parse octave from trailing digits if name starts with a letter + if s and s[0].isalpha(): + try: + octave = int("".join([c for c in filter(str.isdigit, s)])) + except ValueError: + octave = None tone = s.replace(str(octave), "") if octave else s @@ -400,19 +407,20 @@ class Tone: import math if hz <= 0: raise ValueError("Frequency must be positive") - # Semitones from A4 - semitones_from_a4 = 12 * math.log2(hz / REFERENCE_A) - semitones = round(semitones_from_a4) - # A4 is index 0 in the Western system, octave 4 - # Convert to absolute position from C0 - a4_from_c0 = ((0 - C_INDEX) % 12) + (4 * 12) # = 57 - abs_pos = a4_from_c0 + semitones - octave = abs_pos // 12 - relative = abs_pos % 12 - index = (relative + C_INDEX) % 12 if isinstance(system, str): from .systems import SYSTEMS system = SYSTEMS[system] + n = len(system.tone_names) + c_idx = getattr(system, 'c_index', C_INDEX) + # Steps from A4 in this EDO + steps_from_a4 = n * math.log2(hz / REFERENCE_A) + steps = round(steps_from_a4) + # A4 is index 0, octave 4. Convert to absolute position from C0. + a4_from_c0 = ((0 - c_idx) % n) + (4 * n) + abs_pos = a4_from_c0 + steps + octave = abs_pos // n + relative = abs_pos % n + index = (relative + c_idx) % n return klass.from_index(index, octave=octave, system=system) @classmethod @@ -428,13 +436,19 @@ class Tone: >>> Tone.from_midi(69) """ + if isinstance(system, str): + from .systems import SYSTEMS + system = SYSTEMS[system] + # MIDI is a 12-TET standard. Convert to Hz and use from_frequency + # for non-12 systems. + n = len(system.tone_names) + if n != 12: + hz = REFERENCE_A * (2 ** ((note_number - 69) / 12)) + return klass.from_frequency(hz, system=system) adjusted = note_number - 12 # MIDI C0=12 octave = adjusted // 12 relative = adjusted % 12 index = (relative + C_INDEX) % 12 - if isinstance(system, str): - from .systems import SYSTEMS - system = SYSTEMS[system] return klass.from_index(index, octave=octave, system=system) @classmethod @@ -554,9 +568,10 @@ class Tone: 'octave' """ semitones = abs(self - other) - octaves = semitones // 12 - remainder = semitones % 12 - name = self._INTERVAL_NAMES.get(remainder, f"{remainder} semitones") + n = len(self.system.tones) + octaves = semitones // n + remainder = semitones % n + name = self._INTERVAL_NAMES.get(remainder, f"{remainder} steps") if octaves == 0: return name if remainder == 0: @@ -579,6 +594,12 @@ class Tone: """ if self.octave is None: return None + n = len(self.system.tones) + if n != 12: + # Non-12-TET: approximate MIDI via frequency + import math + hz = self.pitch() + return round(69 + 12 * math.log2(hz / REFERENCE_A)) semitones_from_c0 = ((self._index - C_INDEX) % 12) + (self.octave * 12) return semitones_from_c0 + 12 # MIDI C0 = 12 (C-1 = 0) @@ -620,42 +641,43 @@ class Tone: return 1200 * math.log2(f2 / f1) def circle_of_fifths(self) -> list[Tone]: - """The 12 tones of the circle of fifths starting from this tone. + """The circle of fifths starting from this tone. - Each step ascends by a perfect fifth (7 semitones). After 12 - steps you return to the starting tone. The circle of fifths - is the backbone of Western harmony — it determines key - signatures, chord relationships, and modulation paths. - - Clockwise = add sharps: C → G → D → A → E → B → F# → ... - Counter-clockwise = add flats (see ``circle_of_fourths``). + Each step ascends by a perfect fifth (7 semitones in 12-TET). + After N steps (where N = number of tones in the system) you + return to the starting tone. The circle of fifths is the + backbone of Western harmony — it determines key signatures, + chord relationships, and modulation paths. Returns: - A list of 12 Tones. + A list of Tones (12 for Western, N for other systems). """ + n = len(self.system.tones) + # Perfect fifth: the closest approximation to 3:2 ratio + fifth = round(n * 7 / 12) # 7 in 12-TET, 11 in 19-TET, 18 in 31-TET tones: list[Tone] = [] t = self - for _ in range(12): + for _ in range(n): tones.append(t) - t = t.add(7) + t = t.add(fifth) return tones def circle_of_fourths(self) -> list[Tone]: - """The 12 tones of the circle of fourths starting from this tone. + """The circle of fourths starting from this tone. - Each step ascends by a perfect fourth (5 semitones) — the - reverse direction of the circle of fifths. - - Clockwise = add flats: C → F → Bb → Eb → Ab → ... + Each step ascends by a perfect fourth — the reverse direction + of the circle of fifths. Returns: - A list of 12 Tones. + A list of Tones (12 for Western, N for other systems). """ + n = len(self.system.tones) + fourth = round(n * 5 / 12) # 5 in 12-TET, 8 in 19-TET, 13 in 31-TET tones: list[Tone] = [] t = self - for _ in range(12): + for _ in range(n): tones.append(t) - t = t.add(5) + t = t.add(fourth) return tones @property