Add gamelan slendro/pelog, Thai 7-TET, Turkish 53-TET makam

- Slendro (5-TET): true equal 5-tone gamelan tuning, 240 cents/step
- Pelog (9-TET): 7-of-9 gamelan tuning with pathet nem/lima/barang
- Thai classical (7-TET): 7 equal divisions (~171 cents each)
- Turkish makam (53-TET): Arel-Ezgi-Uzdilek system with 9 makams
  (rast, hicaz, ussak, nihavend, huseyni, kurdi, segah, saba, huzzam)
- Fix octave parser to only match trailing digits (not "Mib+3")
- Fix _index to use _name_to_index (avoid creating Tone objects)
- Fix _math to use per-system c_index

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 00:11:19 -04:00
parent 62cfbb2591
commit 47ca94111f
3 changed files with 247 additions and 23 deletions
+192
View File
@@ -387,6 +387,198 @@ ARABIC_24_SCALES = {
],
}
# ── 5-TET Gamelan Slendro ────────────────────────────────────────────────────
# Slendro is a 5-tone equal temperament — each step is 240 cents.
# The actual tuning varies between gamelans (each set is unique), but
# 5-TET is the theoretical ideal that all slendro tunings approximate.
# Ordered from nem (≈A) to loosely match Western indexing.
TONES_SLENDRO = [
("nem",), # 0 — 6 (≈A)
("ji",), # 1 — 1 (≈C)
("ro",), # 2 — 2 (≈D)
("lu",), # 3 — 3 (≈F)
("mo",), # 4 — 5 (≈G)
]
DEGREES_SLENDRO = [
("nem", ()), ("ji", ()), ("ro", ()), ("lu", ()), ("mo", ()),
]
SLENDRO_SCALES = {
"chromatic": (5, {}),
"pentatonic": [5, {
# The full slendro IS the pentatonic — all 5 tones
"slendro": {"intervals": (1, 1, 1, 1, 1)},
}],
}
# ── 9-TET Gamelan Pelog ─────────────────────────────────────────────────────
# Pelog uses 7 tones from a roughly 9-step division of the octave.
# 9-TET (133 cents/step) approximates the unequal pelog intervals.
# The 3 pathet (modes) select 5 tones from the 7.
TONES_PELOG = [
("nem",), # 0 — 6
("pi",), # 1 — 7
("ji",), # 2 — 1
("ro",), # 3 — 2
("lu",), # 4 — 3
("pat",), # 5 — 4
("barang",), # 6 — complementary
("mo",), # 7 — 5
("nem+",), # 8 — auxiliary
]
DEGREES_PELOG = [
("nem", ()), ("pi", ()), ("ji", ()), ("ro", ()),
("lu", ()), ("pat", ()), ("barang", ()), ("mo", ()), ("nem+", ()),
]
PELOG_SCALES = {
"chromatic": (9, {}),
"heptatonic": [7, {
# Full pelog — 7 tones from 9 steps
"pelog": {"intervals": (1, 2, 1, 1, 2, 1, 1)},
}],
"pentatonic": [5, {
# Pathet nem — the most common mode
"pelog nem": {"intervals": (1, 2, 2, 2, 2)},
# Pathet lima
"pelog lima": {"intervals": (1, 2, 2, 1, 3)},
# Pathet barang
"pelog barang": {"intervals": (2, 1, 2, 2, 2)},
}],
}
# ── 7-TET Thai classical ────────────────────────────────────────────────────
# Thai classical music divides the octave into 7 exactly equal steps
# (~171 cents each). This is unique — no Western equivalent exists.
# The 7 tones are numbered 1-7 in Thai theory.
TONES_THAI = [
("do",), # 0 — 1st degree
("re",), # 1 — 2nd
("mi",), # 2 — 3rd
("fa",), # 3 — 4th
("sol",), # 4 — 5th
("la",), # 5 — 6th
("si",), # 6 — 7th
]
DEGREES_THAI = [
("thang 1", ()), ("thang 2", ()), ("thang 3", ()),
("thang 4", ()), ("thang 5", ()), ("thang 6", ()), ("thang 7", ()),
]
THAI_SCALES = {
"chromatic": (7, {}),
"pentatonic": [5, {
# The standard Thai pentatonic — 5 of 7 equal steps
"thai pentatonic": {"intervals": (1, 1, 2, 1, 2)},
# Alternate selection
"thai pentatonic 2": {"intervals": (2, 1, 1, 2, 1)},
}],
"heptatonic": [7, {
# The full 7-TET scale
"thai": {"intervals": (1, 1, 1, 1, 1, 1, 1)},
}],
}
# ── 53-TET Turkish makam (Arel-Ezgi-Uzdilek) ───────────────────────────────
# The gold standard for Turkish music theory. 53-TET has nearly perfect
# fifths (31 steps = 701.89 cents vs 701.96 just) and excellent thirds.
# A comma (1 step) = 22.6 cents. The basic intervals:
# Bakiye (B) = 4 commas ≈ 90 cents (like a limma)
# Küçük mücenneb (S) = 5 commas ≈ 113 cents
# Büyük mücenneb (K) = 8 commas ≈ 181 cents
# Tanini (T) = 9 commas ≈ 204 cents (like a whole tone)
TONES_TURKISH = [
("La",), # 0 — A (Dügah reference)
("La+1",), # 1
("La+2",), # 2
("La+3",), # 3
("Sib",), # 4 — Bb (4 commas from A)
("Sib+1",), # 5
("Sib+2",), # 6
("Sib+3",), # 7
("Sib+4",), # 8
("Si",), # 9 — B
("Si+1",), # 10
("Si+2",), # 11
("Si+3",), # 12
("Do",), # 13 — C (Rast)
("Do+1",), # 14
("Do+2",), # 15
("Do+3",), # 16
("Do+4",), # 17
("Reb",), # 18 — Db
("Reb+1",), # 19
("Reb+2",), # 20
("Reb+3",), # 21
("Re",), # 22 — D (Dügah)
("Re+1",), # 23
("Re+2",), # 24
("Re+3",), # 25
("Re+4",), # 26
("Mib",), # 27 — Eb
("Mib+1",), # 28
("Mib+2",), # 29
("Mib+3",), # 30
("Mi",), # 31 — E (Segah)
("Mi+1",), # 32
("Mi+2",), # 33
("Mi+3",), # 34
("Mi+4",), # 35
("Fa",), # 36 — F
("Fa+1",), # 37
("Fa+2",), # 38
("Fa+3",), # 39
("Fa#",), # 40 — F#
("Fa#+1",), # 41
("Fa#+2",), # 42
("Fa#+3",), # 43
("Sol",), # 44 — G (Neva)
("Sol+1",), # 45
("Sol+2",), # 46
("Sol+3",), # 47
("Lab",), # 48 — Ab
("Lab+1",), # 49
("Lab+2",), # 50
("Lab+3",), # 51
("Lab+4",), # 52
]
DEGREES_TURKISH = [(f"perde {i+1}", ()) for i in range(53)]
# Turkish makam scales in 53-TET commas.
# T=9 commas (whole tone), S=5 (small), K=8 (large), B=4 (limma)
TURKISH_SCALES = {
"chromatic": (53, {}),
"makam": [
7,
{
# Rast — the foundational makam. Uses segah (≈ neutral 3rd)
# T + T + S + T + T + T + S = 9+9+5+9+9+9+4 = 53...
# Actually: 9+8+5+9+9+8+5 = 53
"rast": {"intervals": (9, 8, 5, 9, 9, 8, 5)},
# Nihavend (≈ harmonic minor)
"nihavend": {"intervals": (9, 4, 9, 9, 4, 13, 5)},
# Hicaz — the augmented 2nd makam
"hicaz": {"intervals": (5, 12, 5, 9, 4, 9, 9)},
# Ussak — one of the most common makams
"ussak": {"intervals": (8, 5, 9, 9, 8, 5, 9)},
# Huseyni
"huseyni": {"intervals": (8, 5, 9, 9, 5, 8, 9)},
# Kurdi (≈ Phrygian)
"kurdi": {"intervals": (4, 9, 9, 9, 4, 9, 9)},
# Segah — starts on the neutral 3rd
"segah": {"intervals": (5, 9, 9, 8, 5, 9, 8)},
# Saba — descending differs from ascending
"saba": {"intervals": (8, 5, 4, 14, 4, 9, 9)},
# Hüzzam
"huzzam": {"intervals": (5, 9, 8, 5, 9, 8, 9)},
},
],
}
# Arabic maqam scales (12-TET approximations).
# True maqam uses quarter-tones; these are the closest 12-tone equivalents.
ARABIC_SCALES = {
+12
View File
@@ -4,6 +4,10 @@ from ._statics import (
BLUES_SCALES, GAMELAN_SCALES, SYSTEMS,
TONES_SHRUTI, DEGREES_SHRUTI, SHRUTI_SCALES,
TONES_ARABIC_24, DEGREES_ARABIC_24, ARABIC_24_SCALES,
TONES_SLENDRO, DEGREES_SLENDRO, SLENDRO_SCALES,
TONES_PELOG, DEGREES_PELOG, PELOG_SCALES,
TONES_THAI, DEGREES_THAI, THAI_SCALES,
TONES_TURKISH, DEGREES_TURKISH, TURKISH_SCALES,
)
@@ -340,4 +344,12 @@ SYSTEMS = {
scales=SHRUTI_SCALES, c_index=5),
"maqam": System(tone_names=TONES_ARABIC_24, degrees=DEGREES_ARABIC_24,
scales=ARABIC_24_SCALES, c_index=5),
"slendro": System(tone_names=TONES_SLENDRO, degrees=DEGREES_SLENDRO,
scales=SLENDRO_SCALES, c_index=1),
"pelog": System(tone_names=TONES_PELOG, degrees=DEGREES_PELOG,
scales=PELOG_SCALES, c_index=2),
"thai": System(tone_names=TONES_THAI, degrees=DEGREES_THAI,
scales=THAI_SCALES, c_index=0),
"makam": System(tone_names=TONES_TURKISH, degrees=DEGREES_TURKISH,
scales=TURKISH_SCALES, c_index=13),
}
+43 -23
View File
@@ -58,17 +58,15 @@ class Tone:
if len(name) >= 2 and name[1] in ('x', 'X') and name[0].isalpha():
name = name[0] + '##' + name[2:]
# 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.
# Only parse trailing digits as octave (e.g. "C4" → "C", octave=4).
# Digits embedded in the name (e.g. "Mib+1") are NOT octaves.
# Numeric pitch class names ("0", "11") are also left alone.
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), "")
import re as _re
m = _re.search(r'(\d+)$', name)
if m:
parsed_octave = int(m.group(1))
name = name[:m.start()]
if octave is None:
octave = parsed_octave
@@ -358,15 +356,15 @@ class Tone:
Returns:
A new ``Tone`` instance.
"""
import re as _re
octave = None
# Only parse octave from trailing digits if name starts with a letter
tone = s
# Only parse trailing digits as octave
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
m = _re.search(r'(\d+)$', s)
if m:
octave = int(m.group(1))
tone = s[:m.start()]
if system:
return klass(name=tone, octave=octave, system=system)
@@ -475,7 +473,19 @@ class Tone:
break
else:
tone = tone_names[0] # primary spelling
return klass(name=tone, octave=octave, system=system)
# Bypass parsing and validation — name comes from a known system index
obj = klass.__new__(klass)
obj.name = tone
obj.octave = octave
obj.alt_names = list(tone_names[1:]) if len(tone_names) > 1 else []
obj._frequency = None
if isinstance(system, str):
obj.system_name = system
obj._system = None
else:
obj.system_name = None
obj._system = system
return obj
@property
def _index(self) -> int:
@@ -491,7 +501,15 @@ class Tone:
canonical = self.system.resolve_name(self.name)
if canonical is None:
raise ValueError(f"Tone {self.name!r} not found in system")
return self.system.tones.index(canonical)
# Use _name_to_index for direct lookup (avoids creating Tone objects)
idx = self.system._name_to_index(canonical)
if idx is not None:
return idx
# Fallback: linear search through tone_names
for i, names in enumerate(self.system.tone_names):
if canonical in names:
return i
raise ValueError(f"Tone {self.name!r} not found in system")
except AttributeError:
raise ValueError("Tone index cannot be referenced without a system!")
@@ -505,19 +523,21 @@ class Tone:
octave = self.octave or 0
try:
mod = len(self.system.tones)
mod = len(self.system.tone_names)
except AttributeError:
raise ValueError(
"Tone math can only be computed with an associated system!"
)
# Convert to absolute semitones from C0
note_from_c0 = ((self._index - C_INDEX) % mod) + (octave * mod)
c_idx = getattr(self.system, 'c_index', C_INDEX)
# Convert to absolute steps from C0
note_from_c0 = ((self._index - c_idx) % mod) + (octave * mod)
note_from_c0 += interval
new_octave = note_from_c0 // mod
relative = note_from_c0 % mod
new_index = (relative + C_INDEX) % mod
new_index = (relative + c_idx) % mod
return (new_index, new_octave)