mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 06:46:14 +00:00
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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user