From 35f5f35dc5a61e729fc62c6005496ea1c908bd2e Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Fri, 27 Mar 2026 00:18:09 -0400 Subject: [PATCH] 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) --- pytheory/_statics.py | 77 ++++++++++++++++++ pytheory/rhythm.py | 7 +- pytheory/systems.py | 3 + test_pytheory.py | 183 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 268 insertions(+), 2 deletions(-) diff --git a/pytheory/_statics.py b/pytheory/_statics.py index 188898b..0a4fbaa 100644 --- a/pytheory/_statics.py +++ b/pytheory/_statics.py @@ -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 = { diff --git a/pytheory/rhythm.py b/pytheory/rhythm.py index 5041383..196c492 100644 --- a/pytheory/rhythm.py +++ b/pytheory/rhythm.py @@ -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 diff --git a/pytheory/systems.py b/pytheory/systems.py index dd98073..360bda9 100644 --- a/pytheory/systems.py +++ b/pytheory/systems.py @@ -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 } diff --git a/test_pytheory.py b/test_pytheory.py index 48ffd16..905fe45 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -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