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) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 23:58:33 -04:00
parent dc9f7b3342
commit de855a3fe6
3 changed files with 217 additions and 51 deletions
+2 -2
View File
@@ -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",
+146 -2
View File
@@ -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"<System semitones={self.semitones!r}>"
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),
}
+69 -47
View File
@@ -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)
<Tone A4>
"""
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