diff --git a/docs/_static/logo.png b/docs/_static/logo.png new file mode 100644 index 0000000..521802d Binary files /dev/null and b/docs/_static/logo.png differ diff --git a/docs/conf.py b/docs/conf.py index bced75b..e9fba13 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,5 +35,6 @@ exclude_patterns = ["_build"] html_theme = "alabaster" html_title = "PyTheory" +html_logo = "_static/logo.png" html_static_path = ["_static"] html_extra_path = ["CNAME"] diff --git a/docs/guide/systems.rst b/docs/guide/systems.rst new file mode 100644 index 0000000..acf2c11 --- /dev/null +++ b/docs/guide/systems.rst @@ -0,0 +1,142 @@ +Musical Systems +=============== + +PyTheory supports four musical systems, each with its own tone names +and scale patterns. + +Western +------- + +The standard 12-tone equal temperament system with major/minor scales +and all seven modes. + +.. code-block:: python + + from pytheory import TonedScale + + c = TonedScale(tonic="C4") + c["major"].note_names + # ['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C'] + + c["dorian"].note_names + # ['C', 'D', 'D#', 'F', 'G', 'A', 'A#', 'C'] + +**Scales:** major, minor, harmonic minor, ionian, dorian, phrygian, +lydian, mixolydian, aeolian, locrian, chromatic + +Indian Classical (Hindustani) +----------------------------- + +The Hindustani system uses **swaras** (Sa, Re, Ga, Ma, Pa, Dha, Ni) and +organizes scales into **thaats** — the 10 parent scales from which ragas +are derived. + +.. code-block:: python + + from pytheory import TonedScale + from pytheory.systems import SYSTEMS + + sa = TonedScale(tonic="Sa4", system=SYSTEMS["indian"]) + + sa["bilawal"].note_names # = major scale + # ['Sa', 'Re', 'Ga', 'Ma', 'Pa', 'Dha', 'Ni', 'Sa'] + + sa["bhairav"].note_names # unique to Indian music + # ['Sa', 'komal Re', 'Ga', 'Ma', 'Pa', 'komal Dha', 'Ni', 'Sa'] + + sa["todi"].note_names + # ['Sa', 'komal Re', 'komal Ga', 'tivra Ma', 'Pa', 'komal Dha', 'Ni', 'Sa'] + +**Thaats:** bilawal, khamaj, kafi, asavari, bhairavi, kalyan, bhairav, +poorvi, marwa, todi + +**Swara notation:** + +- Uppercase = shuddha (natural): Sa, Re, Ga, Ma, Pa, Dha, Ni +- ``komal`` prefix = flat: komal Re, komal Ga, komal Dha, komal Ni +- ``tivra`` prefix = sharp: tivra Ma + +Arabic Maqam +------------ + +The Arabic system uses **solfège-based names** (Do, Re, Mi, Fa, Sol, La, Si) +and organizes scales into **maqamat** (plural of maqam). + +.. note:: + + True maqam music uses quarter-tones that cannot be represented in + 12-tone equal temperament. These scales are the closest 12-TET + approximations. + +.. code-block:: python + + from pytheory import TonedScale + from pytheory.systems import SYSTEMS + + do = TonedScale(tonic="Do4", system=SYSTEMS["arabic"]) + + do["ajam"].note_names # = major scale + # ['Do', 'Re', 'Mi', 'Fa', 'Sol', 'La', 'Si', 'Do'] + + do["hijaz"].note_names # characteristic augmented 2nd + # ['Do', 'Reb', 'Mi', 'Fa', 'Sol', 'Solb', 'Sib', 'Do'] + + do["nikriz"].note_names + # ['Do', 'Re', 'Mib', 'Fa#', 'Sol', 'La', 'Sib', 'Do'] + +**Maqamat:** ajam, nahawand, kurd, hijaz, nikriz, bayati, rast, saba, +sikah, jiharkah + +Japanese +-------- + +The Japanese system uses Western note names with traditional pentatonic +and heptatonic scales from Japanese music. + +.. code-block:: python + + from pytheory import TonedScale + from pytheory.systems import SYSTEMS + + c = TonedScale(tonic="C4", system=SYSTEMS["japanese"]) + + c["hirajoshi"].note_names # most iconic Japanese scale + # ['C', 'D', 'D#', 'G', 'G#', 'C'] + + c["in"].note_names # Miyako-bushi, used in koto music + # ['C', 'C#', 'F', 'G', 'G#', 'C'] + + c["yo"].note_names # folk music scale + # ['C', 'D', 'F', 'G', 'A#', 'C'] + + c["ritsu"].note_names # gagaku court music (= Dorian) + # ['C', 'D', 'D#', 'F', 'G', 'A', 'A#', 'C'] + +**Pentatonic scales:** hirajoshi, in, yo, iwato, kumoi, insen + +**Heptatonic scales:** ritsu, ryo + +Cross-System Comparison +----------------------- + +Since all systems use 12-tone equal temperament, equivalent scales +produce the same pitches: + +.. code-block:: python + + from pytheory import TonedScale, Tone + from pytheory.systems import SYSTEMS + + # These are all the same scale with different names + western = TonedScale(tonic="C4")["major"] + indian = TonedScale(tonic="Sa4", system=SYSTEMS["indian"])["bilawal"] + arabic = TonedScale(tonic="Do4", system=SYSTEMS["arabic"])["ajam"] + + # Same pitches + c4 = Tone.from_string("C4", system="western") + sa4 = Tone.from_string("Sa4", system="indian") + do4 = Tone.from_string("Do4", system="arabic") + + c4.frequency # 261.63 + sa4.frequency # 261.63 + do4.frequency # 261.63 diff --git a/docs/index.rst b/docs/index.rst index f050efe..26abb52 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -31,6 +31,7 @@ Work with tones, scales, chords, and fretboards using a clean, Pythonic API. guide/scales guide/chords guide/fretboard + guide/systems guide/playback .. toctree:: diff --git a/pytheory/_statics.py b/pytheory/_statics.py index 02ad71e..599e79f 100644 --- a/pytheory/_statics.py +++ b/pytheory/_statics.py @@ -38,6 +38,36 @@ TONES = { ("Pa",), # G — pancham ("komal Dha",), # Ab — komal dhaivat ], + # Arabic maqam system — Arabic solfège names. + "arabic": [ + ("La",), # A + ("Sib",), # Bb — Si bemol + ("Si",), # B + ("Do",), # C + ("Reb",), # Db — Re bemol + ("Re",), # D + ("Mib",), # Eb — Mi bemol + ("Mi",), # E + ("Fa",), # F + ("Fa#",), # F# + ("Sol",), # G + ("Solb",), # Ab — Sol bemol + ], + # Japanese system — uses Western names; scales are the unique part. + "japanese": [ + ("A",), + ("A#", "Bb"), + ("B",), + ("C",), + ("C#", "Db"), + ("D",), + ("D#", "Eb"), + ("E",), + ("F",), + ("F#", "Gb"), + ("G",), + ("G#", "Ab"), + ], } DEGREES = { @@ -61,6 +91,24 @@ DEGREES = { ("nishad", ()), # Ni — 7th ("saptak", ()), # Sa — octave ], + "arabic": [ + ("qarar", ()), # 1st — root + ("nawa", ()), # 2nd + ("thalth", ()), # 3rd + ("arba", ()), # 4th + ("khamis", ()), # 5th + ("sadis", ()), # 6th + ("sabi", ()), # 7th + ("jawab", ()), # octave + ], + "japanese": [ + ("ichi", ()), # 1st + ("ni", ()), # 2nd + ("san", ()), # 3rd + ("shi", ()), # 4th + ("go", ()), # 5th + ("roku", ()), # 6th (pentatonic scales skip some) + ], } SCALES = { @@ -132,6 +180,80 @@ INDIAN_SCALES = { } } +# Arabic maqam scales (12-TET approximations). +# True maqam uses quarter-tones; these are the closest 12-tone equivalents. +ARABIC_SCALES = { + 12: { + "chromatic": (12, {}), + "maqam": [ + 7, + { + # Ajam = Western major + "ajam": {"intervals": (2, 2, 1, 2, 2, 2, 1)}, + # Nahawand = Western harmonic minor + "nahawand": {"intervals": (2, 1, 2, 2, 1, 3, 1)}, + # Kurd = Western Phrygian + "kurd": {"intervals": (1, 2, 2, 2, 1, 2, 2)}, + # Hijaz — augmented 2nd between 2nd and 3rd degrees + "hijaz": {"intervals": (1, 3, 1, 2, 1, 2, 2)}, + # Nikriz — augmented 2nd between 3rd and 4th + "nikriz": {"intervals": (2, 1, 3, 1, 2, 1, 2)}, + # Bayati (12-TET approx) — true bayati has quarter-flat 2nd + "bayati": {"intervals": (1, 2, 2, 2, 1, 2, 2)}, + # Rast (12-TET approx) — true rast has quarter-flat 3rd and 7th + "rast": {"intervals": (2, 1, 2, 2, 2, 1, 2)}, + # Saba (12-TET approx) — true saba has quarter-flat 2nd + "saba": {"intervals": (1, 2, 1, 3, 1, 2, 2)}, + # Sikah (12-TET approx) — true sikah starts on quarter-flat + "sikah": {"intervals": (1, 2, 2, 2, 1, 2, 2)}, + # Jiharkah + "jiharkah": {"intervals": (2, 2, 1, 2, 2, 1, 2)}, + }, + ], + } +} + +# Japanese pentatonic scales. +JAPANESE_SCALES = { + 12: { + "chromatic": (12, {}), + "pentatonic": [ + 5, + { + # Hirajoshi — the most well-known Japanese scale + # C D Eb G Ab + "hirajoshi": {"intervals": (2, 1, 4, 1, 4)}, + # In (Miyako-bushi) — used in koto music + # C Db F G Ab + "in": {"intervals": (1, 4, 2, 1, 4)}, + # Yo — folk music scale + # C D F G Bb + "yo": {"intervals": (2, 3, 2, 3, 2)}, + # Iwato — dark, dissonant pentatonic + # C Db F Gb Bb + "iwato": {"intervals": (1, 4, 1, 4, 2)}, + # Kumoi — similar to minor pentatonic + # C D Eb G A + "kumoi": {"intervals": (2, 1, 4, 2, 3)}, + # Insen — modern Japanese scale + # C Db F G Bb + "insen": {"intervals": (1, 4, 2, 3, 2)}, + }, + ], + "heptatonic": [ + 7, + { + # Ritsu — gagaku court music scale + # C D Eb F G A Bb (= Dorian) + "ritsu": {"intervals": (2, 1, 2, 2, 2, 1, 2)}, + # Ryo — gagaku court music scale + # C D E F# G A B (= Lydian) + "ryo": {"intervals": (2, 2, 2, 1, 2, 2, 1)}, + }, + ], + } +} + SYSTEMS = NotImplemented # Modes are rotations of the major scale pattern. diff --git a/pytheory/chords.py b/pytheory/chords.py index 3d3d201..8f9d4a1 100644 --- a/pytheory/chords.py +++ b/pytheory/chords.py @@ -104,29 +104,43 @@ class Fretboard: def __len__(self): return len(self.tones) - @classmethod - def guitar(cls): - """Standard guitar tuning (E4 B3 G3 D3 A2 E2).""" - from .tones import Tone - return cls(tones=[ - Tone.from_string("E4", system="western"), - Tone.from_string("B3", system="western"), - Tone.from_string("G3", system="western"), - Tone.from_string("D3", system="western"), - Tone.from_string("A2", system="western"), - Tone.from_string("E2", system="western"), - ]) + TUNINGS = { + "standard": ("E4", "B3", "G3", "D3", "A2", "E2"), + "drop d": ("E4", "B3", "G3", "D3", "A2", "D2"), + "open g": ("D4", "B3", "G3", "D3", "G2", "D2"), + "open d": ("D4", "A3", "F#3", "D3", "A2", "D2"), + "open e": ("E4", "B3", "G#3", "E3", "B2", "E2"), + "open a": ("E4", "C#4", "A3", "E3", "A2", "E2"), + "dadgad": ("D4", "A3", "G3", "D3", "A2", "D2"), + "half step down": ("D#4", "A#3", "F#3", "C#3", "G#2", "D#2"), + } @classmethod - def bass(cls): - """Standard bass guitar tuning (G2 D2 A1 E1).""" + def guitar(cls, tuning="standard"): + """Guitar with the given tuning. + + Args: + tuning: Tuning name or tuple of tone strings (high to low). + Built-in tunings: standard, drop d, open g, open d, + open e, open a, dadgad, half step down. + """ from .tones import Tone - return cls(tones=[ - Tone.from_string("G2", system="western"), - Tone.from_string("D2", system="western"), - Tone.from_string("A1", system="western"), - Tone.from_string("E1", system="western"), - ]) + if isinstance(tuning, str): + tuning = cls.TUNINGS[tuning] + return cls(tones=[Tone.from_string(t, system="western") for t in tuning]) + + @classmethod + def bass(cls, five_string=False): + """Standard bass guitar tuning. + + Args: + five_string: If True, adds a low B string (B0). + """ + from .tones import Tone + strings = ["G2", "D2", "A1", "E1"] + if five_string: + strings.append("B0") + return cls(tones=[Tone.from_string(t, system="western") for t in strings]) @classmethod def ukulele(cls): diff --git a/pytheory/systems.py b/pytheory/systems.py index 3b1c62a..ba83615 100644 --- a/pytheory/systems.py +++ b/pytheory/systems.py @@ -1,4 +1,7 @@ -from ._statics import TEMPERAMENTS, TONES, DEGREES, SCALES, INDIAN_SCALES, SYSTEMS +from ._statics import ( + TEMPERAMENTS, TONES, DEGREES, SCALES, + INDIAN_SCALES, ARABIC_SCALES, JAPANESE_SCALES, SYSTEMS, +) class System: @@ -129,4 +132,6 @@ class System: SYSTEMS = { "western": System(tone_names=TONES["western"], degrees=DEGREES["western"]), "indian": System(tone_names=TONES["indian"], degrees=DEGREES["indian"], scales=INDIAN_SCALES[12]), + "arabic": System(tone_names=TONES["arabic"], degrees=DEGREES["arabic"], scales=ARABIC_SCALES[12]), + "japanese": System(tone_names=TONES["japanese"], degrees=DEGREES["japanese"], scales=JAPANESE_SCALES[12]), } diff --git a/test_pytheory.py b/test_pytheory.py index 1207a69..567508b 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -1725,6 +1725,37 @@ def test_fretboard_ukulele_fingerings(): assert len(fingering) == 4 +def test_fretboard_guitar_drop_d(): + fb = Fretboard.guitar("drop d") + assert len(fb) == 6 + assert fb.tones[-1].name == "D" + assert fb.tones[-1].octave == 2 + + +def test_fretboard_guitar_open_g(): + fb = Fretboard.guitar("open g") + assert len(fb) == 6 + assert fb.tones[0].name == "D" + + +def test_fretboard_guitar_custom_tuple(): + fb = Fretboard.guitar(("E4", "B3", "G3", "D3", "A2", "D2")) + assert len(fb) == 6 + assert fb.tones[-1].name == "D" + + +def test_fretboard_bass_five_string(): + fb = Fretboard.bass(five_string=True) + assert len(fb) == 5 + assert fb.tones[-1].name == "B" + + +def test_fretboard_tunings_dict(): + for name in Fretboard.TUNINGS: + fb = Fretboard.guitar(name) + assert len(fb) == 6, f"Tuning {name} should have 6 strings" + + # ── Ergonomic integration tests ───────────────────────────────────────────── def test_ergonomic_workflow(): @@ -1910,3 +1941,146 @@ def test_indian_scale_degree_access(): assert bilawal[4].name == "Pa" assert bilawal["I"].name == "Sa" assert bilawal["V"].name == "Pa" + + +# ── Arabic system ─────────────────────────────────────────────────────────── + +def test_arabic_system_exists(): + assert "arabic" in SYSTEMS + assert SYSTEMS["arabic"].semitones == 12 + + +def test_arabic_tones(): + arabic = SYSTEMS["arabic"] + names = [t.name for t in arabic.tones] + assert "Do" in names + assert "Re" in names + assert "Sol" in names + + +def test_arabic_do_pitch(): + """Do4 should equal C4 = 261.63 Hz.""" + do = Tone.from_string("Do4", system="arabic") + assert abs(do.frequency - 261.63) < 0.01 + + +def test_arabic_ajam_maqam(): + """Ajam = major scale.""" + do = TonedScale(tonic="Do4", system=SYSTEMS["arabic"]) + ajam = do["ajam"] + names = [t.name for t in ajam] + assert names == ["Do", "Re", "Mi", "Fa", "Sol", "La", "Si", "Do"] + + +def test_arabic_hijaz_maqam(): + """Hijaz has augmented 2nd between 2nd and 3rd degrees.""" + do = TonedScale(tonic="Do4", system=SYSTEMS["arabic"]) + hijaz = do["hijaz"] + names = [t.name for t in hijaz] + assert names[0] == "Do" + assert names[1] == "Reb" # flat 2nd + assert names[2] == "Mi" # natural 3rd (augmented 2nd interval) + + +def test_arabic_all_maqamat_available(): + do = TonedScale(tonic="Do4", system=SYSTEMS["arabic"]) + for maqam in ["ajam", "nahawand", "kurd", "hijaz", "nikriz", + "bayati", "rast", "saba", "sikah", "jiharkah"]: + assert maqam in do.scales, f"Missing maqam: {maqam}" + + +def test_arabic_all_maqam_intervals_sum_to_12(): + arabic = SYSTEMS["arabic"] + for name, scale in arabic.scales["maqam"].items(): + total = sum(scale["intervals"]) + assert total == 12, f"{name} intervals sum to {total}, not 12" + + +def test_arabic_ajam_equals_western_major(): + arabic = SYSTEMS["arabic"] + western = SYSTEMS["western"] + ajam = arabic.scales["maqam"]["ajam"]["intervals"] + major = western.scales["heptatonic"]["major"]["intervals"] + assert ajam == major + + +def test_arabic_tone_arithmetic(): + do = Tone.from_string("Do4", system="arabic") + assert (do + 2).name == "Re" + assert (do + 4).name == "Mi" + assert (do + 7).name == "Sol" + + +# ── Japanese system ───────────────────────────────────────────────────────── + +def test_japanese_system_exists(): + assert "japanese" in SYSTEMS + assert SYSTEMS["japanese"].semitones == 12 + + +def test_japanese_hirajoshi(): + """Hirajoshi: C D Eb G Ab.""" + c = TonedScale(tonic="C4", system=SYSTEMS["japanese"]) + h = c["hirajoshi"] + names = [t.name for t in h] + assert names == ["C", "D", "D#", "G", "G#", "C"] + + +def test_japanese_in_scale(): + """In (Miyako-bushi): C Db F G Ab.""" + c = TonedScale(tonic="C4", system=SYSTEMS["japanese"]) + s = c["in"] + names = [t.name for t in s] + assert names == ["C", "C#", "F", "G", "G#", "C"] + + +def test_japanese_yo_scale(): + """Yo: C D F G Bb.""" + c = TonedScale(tonic="C4", system=SYSTEMS["japanese"]) + s = c["yo"] + names = [t.name for t in s] + assert names == ["C", "D", "F", "G", "A#", "C"] + + +def test_japanese_iwato(): + """Iwato: C Db F Gb Bb.""" + c = TonedScale(tonic="C4", system=SYSTEMS["japanese"]) + s = c["iwato"] + names = [t.name for t in s] + assert names == ["C", "C#", "F", "F#", "A#", "C"] + + +def test_japanese_kumoi(): + """Kumoi: C D Eb G A.""" + c = TonedScale(tonic="C4", system=SYSTEMS["japanese"]) + s = c["kumoi"] + names = [t.name for t in s] + assert names == ["C", "D", "D#", "G", "A", "C"] + + +def test_japanese_ritsu(): + """Ritsu (gagaku): C D Eb F G A Bb = Dorian.""" + c = TonedScale(tonic="C4", system=SYSTEMS["japanese"]) + s = c["ritsu"] + names = [t.name for t in s] + assert names == ["C", "D", "D#", "F", "G", "A", "A#", "C"] + + +def test_japanese_all_scales_available(): + c = TonedScale(tonic="C4", system=SYSTEMS["japanese"]) + for scale in ["hirajoshi", "in", "yo", "iwato", "kumoi", "insen", "ritsu", "ryo"]: + assert scale in c.scales, f"Missing scale: {scale}" + + +def test_japanese_pentatonic_intervals_sum_to_12(): + japanese = SYSTEMS["japanese"] + for name, scale in japanese.scales["pentatonic"].items(): + total = sum(scale["intervals"]) + assert total == 12, f"{name} intervals sum to {total}, not 12" + + +def test_japanese_heptatonic_intervals_sum_to_12(): + japanese = SYSTEMS["japanese"] + for name, scale in japanese.scales["heptatonic"].items(): + total = sum(scale["intervals"]) + assert total == 12, f"{name} intervals sum to {total}, not 12"