"""Arabic maqamat — the named melodic modes of Arabic, Turkish, and
related music, in their real quarter-tone (just) intonation.
A *maqam* is more than a scale. It's built from **ajnas** (sing. *jins*),
the trichord/tetrachord cells that stack into the full mode, and it has a
**seyir**, the conventional way a melody moves through it (where it
starts, where it rests, whether it favours the ascent or the descent).
Most of all, a maqam is defined by notes a piano can't play: the
**half-flats**, the neutral 2nds, 3rds, 6ths and 7ths that sit a
quarter-tone away from the tempered grid. Rast's third (a neutral 27/22,
~355 cents) is the sound of the whole tradition, and it lives in the
crack between E-flat and E.
This module encodes the best-known maqamat with their scale degrees, the
ajnas they're built from, the seyir, and — crucially — the 5-limit /
quarter-tone ratios, so they render in true intonation rather than
snapped to 12-TET.
Degrees are positions in the Do-based Arabic layout (Do = the tonic);
``↓`` marks a quarter-flat. Like a raga, the tonic is movable.
Example::
>>> from pytheory import Maqam
>>> rast = Maqam.get("rast")
>>> rast.degree_names()
['Do', 'Re', 'Mi↓', 'Fa', 'Sol', 'La', 'Si↓']
>>> rast.maqam_table("C")[2]["cents_off"] # the neutral 3rd
-45.5
>>> rast.play("C") # oud, just intonation
"""
from __future__ import annotations
import math
from fractions import Fraction
from typing import Optional
from .ragas import _JustNote
# Just ratios for each position in the Do-based maqam layout (the ones the
# catalogued maqamat use). The neutral (Zalzalian) steps are the quarter
# tones that 12-TET can't reach.
_MAQAM_JI = {
0: Fraction(1, 1), # Do unison
2: Fraction(16, 15), # Reb minor 2nd
3: Fraction(12, 11), # Re↓ neutral 2nd
4: Fraction(9, 8), # Re major 2nd
6: Fraction(6, 5), # Mib minor 3rd
7: Fraction(27, 22), # Mi↓ neutral 3rd ← the Rast note
8: Fraction(5, 4), # Mi major 3rd
9: Fraction(4, 3), # Fa perfect 4th
10: Fraction(32, 25), # Fab diminished 4th ← Saba's signature
11: Fraction(45, 32), # Fa# augmented 4th
13: Fraction(3, 2), # Sol perfect 5th
15: Fraction(8, 5), # Lab minor 6th
16: Fraction(18, 11), # La↓ neutral 6th
17: Fraction(5, 3), # La major 6th
19: Fraction(16, 9), # Sib minor 7th
20: Fraction(11, 6), # Si↓ neutral 7th ← the awj
21: Fraction(15, 8), # Si major 7th
24: Fraction(2, 1), # Do' octave
}
# Arabic note name for each layout position (Do = tonic).
_MAQAM_NAME = {
0: "Do", 2: "Reb", 3: "Re↓", 4: "Re", 6: "Mib", 7: "Mi↓", 8: "Mi",
9: "Fa", 10: "Fab", 11: "Fa#", 13: "Sol", 15: "Lab", 16: "La↓", 17: "La",
19: "Sib", 20: "Si↓", 21: "Si", 24: "Do'",
}
_FLAT12 = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"]
[docs]
class Maqam:
"""An Arabic maqam: its scale, ajnas, seyir, and quarter-tone tuning.
Attributes:
name: the maqam's name.
family: the maqam family (fasila) it belongs to.
degrees: scale positions in the Do-based layout (tonic = ``0``).
ajnas: the jins cells it's built from.
seyir: how a melody conventionally moves through it.
mood: its emotional character.
aka: alternative names.
"""
[docs]
def __init__(self, name, *, family, degrees, ajnas, seyir, mood,
aka=None):
self.name = name
self.family = family
self.degrees = list(degrees)
self.ajnas = ajnas
self.seyir = seyir
self.mood = mood
self.aka = aka or []
# ── degrees & names ──────────────────────────────────────────────
[docs]
def degree_names(self) -> list[str]:
"""The ascending scale as Arabic note names (``↓`` = quarter-flat)."""
return [_MAQAM_NAME[d] for d in self.degrees]
@property
def has_quartertones(self) -> bool:
"""Whether the maqam uses any neutral (quarter-tone) degree."""
return any(d in (3, 7, 16, 20) for d in self.degrees)
[docs]
def note_names(self, tonic="C") -> list[str]:
"""Nearest 12-TET note names in the key of *tonic*.
Quarter-tones round to the closest piano key, so half-flats lose
their bend here — use :meth:`maqam_table` or :meth:`play` for the
true intonation.
"""
from .tones import Tone
base = Tone.from_string(f"{tonic}4", system="western").midi
out = []
for d in self.degrees:
semis = round(1200 * math.log2(float(_MAQAM_JI[d])) / 100)
out.append(_FLAT12[(base + semis) % 12])
return out
# ── just intonation (quarter-tones) ──────────────────────────────
[docs]
def just_ratios(self) -> dict:
"""Each degree's just ratio from the tonic (keyed by Arabic name)."""
return {_MAQAM_NAME[d]: _MAQAM_JI[d] for d in self.degrees}
[docs]
def maqam_table(self, tonic="C") -> list[dict]:
"""Per-degree tuning: ratio, ≈ note, Hz, and cents off 12-TET.
The neutral degrees come out tens of cents from the piano — that
gap *is* the maqam.
"""
from .tones import Tone
sa = Tone.from_string(f"{tonic}4", system="western")
rows = []
for d in self.degrees:
ratio = _MAQAM_JI[d]
cents = 1200 * math.log2(float(ratio))
nearest = round(cents / 100)
rows.append({
"degree": _MAQAM_NAME[d],
"ratio": f"{ratio.numerator}/{ratio.denominator}",
"note": _FLAT12[(sa.midi + nearest) % 12],
"hz": round(sa.frequency * float(ratio), 2),
"cents_off": round(cents - nearest * 100, 1),
})
return rows
[docs]
def just_frequencies(self, tonic="C4") -> list[float]:
"""The ascending then descending run, just-intoned (Hz)."""
from .tones import Tone
if not isinstance(tonic, Tone):
tonic = Tone.from_string(
tonic if any(c.isdigit() for c in str(tonic)) else f"{tonic}4",
system="western")
hz = tonic.frequency
up = [hz * float(_MAQAM_JI[d]) for d in self.degrees]
up.append(hz * 2.0) # the octave
return up + up[-2::-1] # add the descent
[docs]
def render(self, tonic="C4", *, synth="oud", t=420, envelope="pluck",
reverb=0.3):
"""Render the ascending→descending run to a mono sample buffer.
Quarter-tone accurate (the half-flats are voiced where they
actually sit), with a reverb tail tying the phrase together.
Returns a NumPy array (no audio device needed).
"""
import numpy
from .play import _render, _apply_reverb, Synth, Envelope, SAMPLE_RATE
s = Synth[synth.upper()]
env = Envelope[envelope.upper()]
buffers = [_render(_JustNote(hz), synth=s, t=t, envelope=env)
for hz in self.just_frequencies(tonic)]
buf = numpy.concatenate(buffers).astype(numpy.float32)
if reverb and reverb > 0:
tail = numpy.zeros(int(SAMPLE_RATE * 1.5), dtype=numpy.float32)
buf = _apply_reverb(numpy.concatenate([buf, tail]), mix=reverb)
return buf
[docs]
def play(self, tonic="C4", *, synth="oud", t=420, envelope="pluck",
reverb=0.3):
"""Play the maqam (ascending then descending) in just intonation.
Defaults to the oud voice. Like a raga, this is quarter-tone
accurate — the half-flats are voiced where they actually sit.
"""
from .play import _play_for, SAMPLE_RATE
buf = self.render(tonic, synth=synth, t=t, envelope=envelope,
reverb=reverb)
_play_for(buf, ms=int(len(buf) / SAMPLE_RATE * 1000))
# ── lookup ───────────────────────────────────────────────────────
[docs]
@classmethod
def get(cls, name: str) -> "Maqam":
"""Look up a maqam by name; an exact name beats any alias."""
def norm(s):
return s.lower().replace(" ", "").replace("-", "")
key = norm(name)
for m in _MAQAMS.values():
if key == norm(m.name):
return m
for m in _MAQAMS.values():
if any(key == norm(a) for a in m.aka):
return m
raise KeyError(f"Unknown maqam: {name!r}. Try Maqam.names().")
[docs]
@classmethod
def all(cls) -> list["Maqam"]:
return [_MAQAMS[k] for k in sorted(_MAQAMS)]
[docs]
@classmethod
def names(cls) -> list[str]:
return sorted(m.name for m in _MAQAMS.values())
[docs]
@classmethod
def by_family(cls, family: str) -> list["Maqam"]:
f = family.lower()
return [m for m in cls.all() if m.family.lower() == f]
[docs]
def __repr__(self) -> str:
return f"<Maqam {self.name} (family {self.family})>"
def __str__(self) -> str:
return self.name
# ── the maqamat ──────────────────────────────────────────────────────
# Common forms, with the tonic set to Do (C). Ajnas and seyir follow the
# mainstream (Egyptian/Levantine) practice; regional variants exist.
_MAQAM_LIST = [
Maqam("Rast", family="Rast",
degrees=[0, 4, 7, 9, 13, 17, 20],
ajnas="Jins Rast on Do + Jins Rast on Sol",
seyir="ascending from the tonic; rests on Sol and the neutral 3rd",
mood="dignified, sober, the 'mother' maqam"),
Maqam("Bayati", family="Bayati",
degrees=[0, 3, 6, 9, 13, 15, 19],
ajnas="Jins Bayati on Do + Jins Nahawand on Sol",
seyir="from the tonic; the neutral 2nd is its signature",
mood="tender, intimate, the everyday maqam", aka=["Bayat"]),
Maqam("Hijaz", family="Hijaz",
degrees=[0, 2, 8, 9, 13, 15, 19],
ajnas="Jins Hijaz on Do + Jins Rast/Nahawand on Sol",
seyir="ascending; the augmented 2nd (Reb→Mi) is the colour",
mood="yearning, devotional, desert-evening"),
Maqam("Nahawand", family="Nahawand",
degrees=[0, 4, 6, 9, 13, 15, 19],
ajnas="Jins Nahawand on Do + Jins Hijaz/Kurd on Sol",
seyir="the Arabic minor; ascends, raising the 7th in cadence",
mood="romantic, wistful", aka=["Nihavend"]),
Maqam("Kurd", family="Kurd",
degrees=[0, 2, 6, 9, 13, 15, 19],
ajnas="Jins Kurd on Do + Jins Kurd on Sol",
seyir="Phrygian colour; from the tonic",
mood="plaintive, modern", aka=["Kurdi"]),
Maqam("Ajam", family="Ajam",
degrees=[0, 4, 8, 9, 13, 17, 21],
ajnas="Jins Ajam on Do + Jins Ajam on Sol",
seyir="the major scale; bright and direct",
mood="bright, celebratory", aka=["Ajam Ushayran"]),
Maqam("Hijazkar", family="Hijaz",
degrees=[0, 2, 8, 9, 13, 15, 21],
ajnas="Jins Hijaz on Do + Jins Hijaz on Sol",
seyir="symmetric double-Hijaz; descending tendency",
mood="dramatic, ornate", aka=["Shahnaz", "Shedaraban relative"]),
Maqam("Saba", family="Saba",
degrees=[0, 3, 6, 10, 13, 15, 19],
ajnas="Jins Saba on Do (its diminished 4th is unmistakable)",
seyir="from the tonic; the lowered 4th gives its grief",
mood="sorrowful, aching, the maqam of lament"),
Maqam("Suznak", family="Rast",
degrees=[0, 4, 7, 9, 13, 15, 21],
ajnas="Jins Rast on Do + Jins Hijaz on Sol",
seyir="Rast below, Hijaz above; ascending",
mood="warm then yearning"),
Maqam("Nakriz", family="Nahawand",
degrees=[0, 4, 6, 11, 13, 17, 19],
ajnas="Jins Nakriz on Do (minor with a raised 4th)",
seyir="the raised 4th pulls toward the 5th",
mood="mysterious, restless", aka=["Nigriz"]),
]
_MAQAMS = {m.name.lower(): m for m in _MAQAM_LIST}