Carnatic 72-TET, Score system param, 22 microtonal tests

- Carnatic (72-TET): 10 melakartas including shankarabharanam,
  kalyani, mayamalavagowla, kharaharapriya, etc.
- Score(system=) param passes tuning system to all parts, so
  Part.add("Sa") resolves through the correct system
- 22 new tests covering all microtonal systems: TET factory,
  19/31-TET, shruti, maqam, slendro, pelog, thai, makam,
  carnatic, circle of fifths, from_frequency, Score integration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 00:18:09 -04:00
parent 47ca94111f
commit 35f5f35dc5
4 changed files with 268 additions and 2 deletions
+77
View File
@@ -579,6 +579,83 @@ TURKISH_SCALES = {
],
}
# ── 72-TET Carnatic (South Indian) ───────────────────────────────────────────
# The 72 melakarta system classifies all possible 7-note scales with
# fixed Sa and Pa. 72-TET (16.67 cents/step) captures the srutis used
# in Carnatic music with high precision. Each 12-TET semitone = 6 steps.
#
# Tone names: 12 swaras × 6 microtonal variants each.
# Main swaras at positions: Sa=0, Ri1=6, Ri2=12, Ga1=12, Ga2=18,
# Ma1=30, Ma2=36, Pa=42, Da1=48, Da2=54, Ni1=60, Ni2=66
TONES_CARNATIC = []
_SWARA_NAMES = [
"Sa", "atikomal Ri", "komal Ri", "shuddha Ri",
"Ri", "tivra Ri", "komal Ga", "atikomal Ga",
"Ga", "shuddha Ga", "tivra Ga", "antara Ga",
"komal Ma", "shuddha Ma", "Ma", "tivra shuddha Ma",
"ekashruti Ma", "chatushruti Ma", "tivra Ma", "atitivra Ma",
"prati Ma", "tivratara Ma", "atikomal Pa-", "komal Pa-",
"shuddha Pa-", "Pa-", "Pa-+1", "Pa-+2",
"Pa-+3", "Pa-+4", "Pa", "Pa+1",
"Pa+2", "Pa+3", "Pa+4", "Pa+5",
"komal Da", "atikomal Da", "Da-", "shuddha Da-",
"Da", "shuddha Da", "tivra Da", "atitivra Da",
"komal Ni", "atikomal Ni", "Ni-", "shuddha Ni-",
"Ni", "shuddha Ni", "tivra Ni", "chatushruti Ni",
"kakali Ni", "atikakali Ni",
]
# Generate 72 tone names: use standard names for the 12 main positions,
# numbered variants for the intermediates
for i in range(72):
main_pos = i // 6 # which semitone group (0-11)
micro = i % 6 # microtonal position within group
_base_names = ["Sa", "komal Ri", "Ri", "komal Ga", "Ga", "Ma",
"tivra Ma", "Pa", "komal Da", "Da", "komal Ni", "Ni"]
if micro == 0:
TONES_CARNATIC.append((_base_names[main_pos],))
else:
TONES_CARNATIC.append((f"{_base_names[main_pos]}+{micro}",))
DEGREES_CARNATIC = [(f"swara {i+1}", ()) for i in range(72)]
# A selection of important melakartas in 72-TET intervals.
# Each step = 1/72 of an octave ≈ 16.67 cents.
CARNATIC_SCALES = {
"chromatic": (72, {}),
"melakarta": [
7,
{
# Kanakangi (melakarta 1) — Sa Ri1 Ga1 Ma1 Pa Da1 Ni1
"kanakangi": {"intervals": (6, 6, 18, 12, 6, 6, 18)},
# Shankarabharanam (melakarta 29) — Sa Ri2 Ga3 Ma1 Pa Da2 Ni3
# The Carnatic equivalent of the major scale
"shankarabharanam": {"intervals": (12, 12, 6, 12, 12, 12, 6)},
# Kalyani (melakarta 65) — Sa Ri2 Ga3 Ma2 Pa Da2 Ni3
# Carnatic Lydian equivalent
"kalyani": {"intervals": (12, 12, 12, 6, 12, 12, 6)},
# Kharaharapriya (melakarta 22) — Sa Ri2 Ga2 Ma1 Pa Da2 Ni2
# Carnatic Dorian equivalent
"kharaharapriya": {"intervals": (12, 6, 12, 12, 12, 6, 12)},
# Hanumathodi (melakarta 8) — Sa Ri1 Ga2 Ma1 Pa Da1 Ni2
# Carnatic Phrygian equivalent
"hanumathodi": {"intervals": (6, 12, 12, 12, 6, 12, 12)},
# Natabhairavi (melakarta 20) — Sa Ri2 Ga2 Ma1 Pa Da1 Ni2
# Natural minor equivalent
"natabhairavi": {"intervals": (12, 6, 12, 12, 6, 12, 12)},
# Mayamalavagowla (melakarta 15) — Sa Ri1 Ga3 Ma1 Pa Da1 Ni3
# The "lesson scale" — first raga taught to students
"mayamalavagowla": {"intervals": (6, 18, 6, 12, 6, 18, 6)},
# Simhendramadhyamam (melakarta 57) — Sa Ri2 Ga3 Ma2 Pa Da1 Ni3
"simhendramadhyamam": {"intervals": (12, 12, 12, 6, 6, 18, 6)},
# Charukesi (melakarta 26) — Sa Ri2 Ga3 Ma1 Pa Da1 Ni2
"charukesi": {"intervals": (12, 12, 6, 12, 6, 12, 12)},
# Harikambhoji (melakarta 28) — Sa Ri2 Ga3 Ma1 Pa Da2 Ni2
# Mixolydian equivalent
"harikambhoji": {"intervals": (12, 12, 6, 12, 12, 6, 12)},
},
],
}
# Arabic maqam scales (12-TET approximations).
# True maqam uses quarter-tones; these are the closest 12-tone equivalents.
ARABIC_SCALES = {
+5 -2
View File
@@ -1677,6 +1677,7 @@ class Part:
self.phaser_rate = phaser_rate
self.fm_ratio = fm_ratio
self.fm_index = fm_index
self._system = "western" # default, overridden by Score.part()
self.notes: list[Note] = []
self._drum_hits: list[_Hit] = []
self._drum_pattern_beats: float = 0.0
@@ -1692,7 +1693,7 @@ class Part:
"""
if isinstance(tone_or_string, str):
from .tones import Tone
tone_or_string = Tone.from_string(tone_or_string, system="western")
tone_or_string = Tone.from_string(tone_or_string, system=self._system)
if isinstance(duration, (int, float)):
duration = _RawDuration(duration)
self.notes.append(Note(tone=tone_or_string, duration=duration, velocity=velocity))
@@ -2072,13 +2073,14 @@ class Score:
"""
def __init__(self, time_signature="4/4", bpm=120, swing: float = 0.0,
drum_humanize: float = 0.15):
drum_humanize: float = 0.15, system: str = "western"):
if isinstance(time_signature, str):
self.time_signature = TimeSignature.from_string(time_signature)
else:
self.time_signature = time_signature
self.bpm = bpm
self.swing = swing
self.system = system
self._drum_humanize = drum_humanize
self.notes: list[Note] = []
self.parts: dict[str, Part] = {}
@@ -2294,6 +2296,7 @@ class Score:
merged = {**_defaults, **explicit}
p = Part(name, **merged)
p._system = self.system
self.parts[name] = p
return p
+3
View File
@@ -8,6 +8,7 @@ from ._statics import (
TONES_PELOG, DEGREES_PELOG, PELOG_SCALES,
TONES_THAI, DEGREES_THAI, THAI_SCALES,
TONES_TURKISH, DEGREES_TURKISH, TURKISH_SCALES,
TONES_CARNATIC, DEGREES_CARNATIC, CARNATIC_SCALES,
)
@@ -352,4 +353,6 @@ SYSTEMS = {
scales=THAI_SCALES, c_index=0),
"makam": System(tone_names=TONES_TURKISH, degrees=DEGREES_TURKISH,
scales=TURKISH_SCALES, c_index=13),
"carnatic": System(tone_names=TONES_CARNATIC, degrees=DEGREES_CARNATIC,
scales=CARNATIC_SCALES, c_index=18), # Sa ≈ C, 18 steps from A
}
+183
View File
@@ -6534,3 +6534,186 @@ def test_instrument_808_bass():
assert p.lowpass_q == 1.5
assert p.synth == "sine"
assert p.envelope == "pluck"
# ── Non-12-TET / Microtonal systems ─────────────────────────────────────────
from pytheory import TET
def test_tet_factory_creates_system():
edo17 = TET(17)
assert len(edo17.tone_names) == 17
assert edo17.semitones == 17
def test_tet_factory_numbered_tones():
edo17 = TET(17)
t = Tone("0", octave=4, system=edo17)
assert t.frequency == pytest.approx(440.0, rel=1e-3)
# One octave up
t_up = t.add(17)
assert t_up.frequency == pytest.approx(880.0, rel=1e-3)
def test_tet_factory_custom_names():
names = ["A", "B", "C", "D", "E"]
edo5 = TET(5, names=names)
assert len(edo5.tone_names) == 5
t = Tone("A", octave=4, system=edo5)
assert t.frequency == pytest.approx(440.0, rel=1e-3)
def test_tet_factory_wrong_name_count():
with pytest.raises(ValueError):
TET(5, names=["A", "B", "C"])
def test_19tet_system():
sys19 = SYSTEMS["19-tet"]
assert sys19.semitones == 19
a = Tone("A", octave=4, system=sys19)
assert a.frequency == pytest.approx(440.0, rel=1e-3)
# Octave should double
a5 = a.add(19)
assert a5.frequency == pytest.approx(880.0, rel=1e-3)
def test_19tet_scale():
sys19 = SYSTEMS["19-tet"]
ts = TonedScale(system=sys19, tonic=Tone("C", octave=4, system=sys19))
major = ts["major"]
assert len(major.tones) == 8 # 7 + octave
def test_31tet_system():
sys31 = SYSTEMS["31-tet"]
assert sys31.semitones == 31
a = Tone("A", octave=4, system=sys31)
assert a.frequency == pytest.approx(440.0, rel=1e-3)
def test_shruti_system():
shruti = SYSTEMS["shruti"]
assert shruti.semitones == 22
sa = Tone("Sa", octave=4, system=shruti)
# Sa should be near C4 (261.63 Hz) — not exact due to 22-TET
assert 250 < sa.frequency < 270
def test_shruti_octave():
shruti = SYSTEMS["shruti"]
sa4 = Tone("Sa", octave=4, system=shruti)
sa5 = sa4.add(22)
assert sa5.frequency == pytest.approx(sa4.frequency * 2, rel=1e-3)
def test_shruti_bhairav_scale():
shruti = SYSTEMS["shruti"]
ts = TonedScale(system=shruti, tonic=Tone("Sa", octave=4, system=shruti))
bhairav = ts["bhairav"]
names = [t.name for t in bhairav.tones]
assert names[0] == "Sa"
assert "komal Re" in names # the microtonal komal Re
assert len(bhairav.tones) == 8
def test_maqam_system():
maqam = SYSTEMS["maqam"]
assert maqam.semitones == 24
do = Tone("Do", octave=4, system=maqam)
assert 250 < do.frequency < 270
def test_maqam_rast_has_quarter_tones():
maqam = SYSTEMS["maqam"]
ts = TonedScale(system=maqam, tonic=Tone("Do", octave=4, system=maqam))
rast = ts["rast"]
names = [t.name for t in rast.tones]
# Rast should contain quarter-tone positions
assert any("" in n or "" in n for n in names)
def test_slendro_system():
slendro = SYSTEMS["slendro"]
assert slendro.semitones == 5
ji = Tone("ji", octave=4, system=slendro)
# 5 steps = octave
ji_up = ji.add(5)
assert ji_up.frequency == pytest.approx(ji.frequency * 2, rel=1e-3)
def test_pelog_system():
pelog = SYSTEMS["pelog"]
assert pelog.semitones == 9
ts = TonedScale(system=pelog, tonic=Tone("ji", octave=4, system=pelog))
full_pelog = ts["pelog"]
assert len(full_pelog.tones) == 8
def test_thai_system():
thai = SYSTEMS["thai"]
assert thai.semitones == 7
do = Tone("do", octave=4, system=thai)
# 7 steps = octave
do_up = do.add(7)
assert do_up.frequency == pytest.approx(do.frequency * 2, rel=1e-3)
def test_turkish_makam_system():
makam = SYSTEMS["makam"]
assert makam.semitones == 53
ts = TonedScale(system=makam, tonic=Tone("Do", octave=4, system=makam))
rast = ts["rast"]
assert len(rast.tones) == 8
def test_carnatic_system():
carnatic = SYSTEMS["carnatic"]
assert carnatic.semitones == 72
ts = TonedScale(system=carnatic, tonic=Tone("Sa", octave=4, system=carnatic))
shankarabharanam = ts["shankarabharanam"]
assert len(shankarabharanam.tones) == 8
def test_circle_of_fifths_19tet():
sys19 = SYSTEMS["19-tet"]
c = Tone("C", octave=4, system=sys19)
cof = c.circle_of_fifths()
assert len(cof) == 19 # should cycle through all 19 tones
def test_circle_of_fifths_western_unchanged():
"""Existing 12-TET circle of fifths should not be affected."""
c = Tone("C", octave=4, system="western")
cof = c.circle_of_fifths()
assert len(cof) == 12
assert cof[0].name == "C"
assert cof[1].name == "G"
def test_from_frequency_non12():
sys19 = SYSTEMS["19-tet"]
t = Tone.from_frequency(440.0, system=sys19)
assert t.name == "A"
assert t.octave == 4
def test_score_system_param():
"""Score passes system to parts for string→Tone resolution."""
from pytheory import Score, Duration
shruti = SYSTEMS["shruti"]
score = Score("4/4", bpm=120, system=shruti)
p = score.part("test", synth="sine")
assert p._system is shruti
# String "Sa" should resolve via shruti system, not western
p.add(Tone("Sa", octave=4, system=shruti), Duration.QUARTER)
assert len(p.notes) == 1
def test_interval_to_non12():
sys19 = SYSTEMS["19-tet"]
a = Tone("A", octave=4, system=sys19)
a5 = a.add(19)
result = a.interval_to(a5)
assert "octave" in result