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