mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da40189845 |
+1
-1
@@ -10,7 +10,7 @@ sys.modules["sounddevice"] = MagicMock()
|
||||
project = "PyTheory"
|
||||
copyright = "2026, Kenneth Reitz"
|
||||
author = "Kenneth Reitz"
|
||||
release = "0.3.2"
|
||||
release = "0.4.0"
|
||||
|
||||
extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pytheory"
|
||||
version = "0.3.2"
|
||||
version = "0.4.0"
|
||||
description = "Music Theory for Humans"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""PyTheory: Music Theory for Humans."""
|
||||
|
||||
__version__ = "0.3.2"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
from .tones import Tone, Interval
|
||||
from .systems import System, SYSTEMS
|
||||
from .scales import Scale, TonedScale, Key, PROGRESSIONS
|
||||
from .chords import Chord, Fretboard
|
||||
from .chords import Chord, Fretboard, analyze_progression
|
||||
from .charts import CHARTS, charts_for_fretboard
|
||||
|
||||
try:
|
||||
@@ -19,7 +19,7 @@ Note = Tone
|
||||
|
||||
__all__ = [
|
||||
"Tone", "Note", "Interval", "Scale", "TonedScale", "Key",
|
||||
"PROGRESSIONS", "Chord", "Fretboard",
|
||||
"PROGRESSIONS", "Chord", "Fretboard", "analyze_progression",
|
||||
"System", "SYSTEMS", "CHARTS", "charts_for_fretboard",
|
||||
"play", "Synth",
|
||||
]
|
||||
|
||||
@@ -60,6 +60,36 @@ class Chord:
|
||||
f"{t.name}{octave}", system="western"))
|
||||
return cls(tones=tones)
|
||||
|
||||
@classmethod
|
||||
def from_intervals(cls, root: str, *intervals: int, octave: int = 4) -> Chord:
|
||||
"""Create a Chord from a root note and semitone intervals.
|
||||
|
||||
Example::
|
||||
|
||||
>>> Chord.from_intervals("C", 4, 7) # C major
|
||||
<Chord C major>
|
||||
>>> Chord.from_intervals("G", 4, 7, 10) # G7
|
||||
<Chord G dominant 7th>
|
||||
>>> Chord.from_intervals("D", 3, 7) # D minor
|
||||
<Chord D minor>
|
||||
"""
|
||||
from .tones import Tone
|
||||
root_tone = Tone.from_string(f"{root}{octave}", system="western")
|
||||
tones = [root_tone] + [root_tone.add(i) for i in intervals]
|
||||
return cls(tones=tones)
|
||||
|
||||
@classmethod
|
||||
def from_midi_message(cls, *note_numbers: int) -> Chord:
|
||||
"""Create a Chord from MIDI note numbers.
|
||||
|
||||
Example::
|
||||
|
||||
>>> Chord.from_midi_message(60, 64, 67) # C4, E4, G4
|
||||
<Chord C major>
|
||||
"""
|
||||
from .tones import Tone
|
||||
return cls(tones=[Tone.from_midi(n) for n in note_numbers])
|
||||
|
||||
def __repr__(self) -> str:
|
||||
name = self.identify()
|
||||
if name:
|
||||
@@ -625,6 +655,31 @@ class Chord:
|
||||
"has_dominant_function": has_dominant,
|
||||
}
|
||||
|
||||
def add_tone(self, tone) -> Chord:
|
||||
"""Return a new Chord with an additional tone.
|
||||
|
||||
Example::
|
||||
|
||||
>>> c_major = Chord.from_tones("C", "E", "G")
|
||||
>>> c_major.add_tone(Tone.from_string("B4", system="western"))
|
||||
<Chord C major 7th>
|
||||
"""
|
||||
return Chord(tones=list(self.tones) + [tone])
|
||||
|
||||
def remove_tone(self, tone_name: str) -> Chord:
|
||||
"""Return a new Chord with tones of the given name removed.
|
||||
|
||||
Args:
|
||||
tone_name: The note name to remove (e.g. "G").
|
||||
|
||||
Example::
|
||||
|
||||
>>> cmaj7 = Chord.from_name("Cmaj7")
|
||||
>>> cmaj7.remove_tone("B") # Remove the 7th
|
||||
<Chord C major>
|
||||
"""
|
||||
return Chord(tones=[t for t in self.tones if t.name != tone_name])
|
||||
|
||||
def fingering(self, *positions: int) -> Chord:
|
||||
"""Apply fret positions to each tone, returning a new Chord.
|
||||
|
||||
@@ -1156,6 +1211,47 @@ class Fretboard:
|
||||
]
|
||||
return cls(tones=[Tone.from_string(t, system="western") for t in strings])
|
||||
|
||||
def scale_diagram(self, scale, frets: int = 12) -> str:
|
||||
"""Render an ASCII diagram showing where scale notes fall on the neck.
|
||||
|
||||
Each string is shown with dots on frets where scale notes appear.
|
||||
Useful for learning scale patterns on guitar, mandolin, etc.
|
||||
|
||||
Args:
|
||||
scale: A Scale object (or anything with a ``note_names`` attribute).
|
||||
frets: Number of frets to display (default 12).
|
||||
|
||||
Returns:
|
||||
A multi-line string showing the fretboard diagram.
|
||||
|
||||
Example::
|
||||
|
||||
>>> from pytheory import Fretboard, TonedScale
|
||||
>>> fb = Fretboard.guitar()
|
||||
>>> pentatonic = TonedScale(tonic="A4")["minor"]
|
||||
>>> print(fb.scale_diagram(pentatonic, frets=5))
|
||||
"""
|
||||
scale_notes = set(scale.note_names)
|
||||
max_name = max(len(t.name) for t in self.tones)
|
||||
lines = []
|
||||
|
||||
# Header with fret numbers
|
||||
header = " " * (max_name + 1) + " ".join(f"{f:<3d}" for f in range(frets + 1))
|
||||
lines.append(header)
|
||||
|
||||
for tone in self.tones:
|
||||
fret_marks = []
|
||||
for f in range(frets + 1):
|
||||
note = tone.add(f)
|
||||
if note.name in scale_notes:
|
||||
fret_marks.append(f" {note.name:<2s}")
|
||||
else:
|
||||
fret_marks.append(" - ")
|
||||
line = f"{tone.name:>{max_name}}|{'|'.join(fret_marks)}|"
|
||||
lines.append(line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def fingering(self, *positions: int) -> Chord:
|
||||
"""Apply fret positions to each string, returning a Chord.
|
||||
|
||||
@@ -1183,3 +1279,15 @@ class Fretboard:
|
||||
tones.append(tone.add(positions[i]))
|
||||
|
||||
return Chord(tones=tones)
|
||||
|
||||
|
||||
def analyze_progression(chords: list[Chord], key: str = "C", mode: str = "major") -> list[str | None]:
|
||||
"""Analyze a list of chords and return their Roman numeral functions.
|
||||
|
||||
Example::
|
||||
|
||||
>>> chords = [Chord.from_name("C"), Chord.from_name("Am"), Chord.from_name("F"), Chord.from_name("G")]
|
||||
>>> analyze_progression(chords, key="C")
|
||||
['I', 'vi', 'IV', 'V']
|
||||
"""
|
||||
return [c.analyze(key, mode) for c in chords]
|
||||
|
||||
@@ -486,6 +486,109 @@ class Key:
|
||||
keys.append(cls(tonic, "minor"))
|
||||
return keys
|
||||
|
||||
@property
|
||||
def signature(self) -> dict:
|
||||
"""The key signature — number and names of sharps or flats.
|
||||
|
||||
In Western music, each key has a unique key signature that tells
|
||||
you which notes are sharped or flatted throughout a piece.
|
||||
|
||||
Returns:
|
||||
A dict with:
|
||||
- ``sharps`` (int): number of sharps (0 if flat key)
|
||||
- ``flats`` (int): number of flats (0 if sharp key)
|
||||
- ``accidentals`` (list[str]): the sharped/flatted note names
|
||||
|
||||
Example::
|
||||
|
||||
>>> Key("G", "major").signature
|
||||
{'sharps': 1, 'flats': 0, 'accidentals': ['F#']}
|
||||
>>> Key("F", "major").signature
|
||||
{'sharps': 0, 'flats': 1, 'accidentals': ['Bb']}
|
||||
>>> Key("C", "major").signature
|
||||
{'sharps': 0, 'flats': 0, 'accidentals': []}
|
||||
"""
|
||||
# Compare scale notes against the natural notes C D E F G A B
|
||||
naturals = {"C", "D", "E", "F", "G", "A", "B"}
|
||||
scale_notes = set(self.note_names[:-1]) # exclude octave
|
||||
|
||||
sharps = [n for n in scale_notes if "#" in n]
|
||||
flats = [n for n in scale_notes if "b" in n[1:]] # skip first char for B
|
||||
|
||||
# Order sharps: F C G D A E B
|
||||
sharp_order = ["F#", "C#", "G#", "D#", "A#", "E#", "B#"]
|
||||
flat_order = ["Bb", "Eb", "Ab", "Db", "Gb", "Cb", "Fb"]
|
||||
|
||||
sharps_sorted = [s for s in sharp_order if s in sharps]
|
||||
flats_sorted = [f for f in flat_order if f in flats]
|
||||
|
||||
if sharps_sorted:
|
||||
return {"sharps": len(sharps_sorted), "flats": 0, "accidentals": sharps_sorted}
|
||||
elif flats_sorted:
|
||||
return {"sharps": 0, "flats": len(flats_sorted), "accidentals": flats_sorted}
|
||||
else:
|
||||
return {"sharps": 0, "flats": 0, "accidentals": []}
|
||||
|
||||
@property
|
||||
def borrowed_chords(self) -> list[str]:
|
||||
"""Chords borrowed from the parallel key.
|
||||
|
||||
Modal interchange (or modal mixture) borrows chords from the
|
||||
parallel major or minor key. In C major, the parallel minor
|
||||
is C minor, which provides chords like Ab major, Bb major,
|
||||
and Eb major — commonly heard in rock, film, and pop music.
|
||||
|
||||
Returns:
|
||||
A list of chord names from the parallel key that are NOT
|
||||
in the current key's diatonic chords.
|
||||
|
||||
Example::
|
||||
|
||||
>>> Key("C", "major").borrowed_chords
|
||||
['C minor', 'D diminished', 'D# major', ...]
|
||||
"""
|
||||
par = self.parallel
|
||||
if par is None:
|
||||
return []
|
||||
own = set(self.chords)
|
||||
return [c for c in par.chords if c not in own]
|
||||
|
||||
def random_progression(self, length: int = 4) -> list:
|
||||
"""Generate a random diatonic chord progression.
|
||||
|
||||
Uses weighted probabilities based on common chord function:
|
||||
I and vi are most common, IV and V are very common, ii is
|
||||
common, iii and viidim are rare. Always starts on I and
|
||||
ends on I or V.
|
||||
|
||||
Args:
|
||||
length: Number of chords (default 4).
|
||||
|
||||
Returns:
|
||||
A list of Chord objects.
|
||||
|
||||
Example::
|
||||
|
||||
>>> Key("C", "major").random_progression(4)
|
||||
[<Chord C major>, <Chord F major>, <Chord G major>, <Chord C major>]
|
||||
"""
|
||||
import random
|
||||
|
||||
harmonized = self._scale.harmonize()
|
||||
unique = len(harmonized)
|
||||
# Weights: I=high, ii=med, iii=low, IV=high, V=high, vi=med, vii=low
|
||||
weights = [10, 5, 2, 8, 8, 5, 1]
|
||||
if unique < len(weights):
|
||||
weights = weights[:unique]
|
||||
|
||||
chords = [harmonized[0]] # Start on I
|
||||
for _ in range(length - 2):
|
||||
chords.append(random.choices(harmonized, weights=weights, k=1)[0])
|
||||
if length > 1:
|
||||
# End on I or V
|
||||
chords.append(random.choice([harmonized[0], harmonized[4 % unique]]))
|
||||
return chords
|
||||
|
||||
@property
|
||||
def relative(self) -> Optional[Key]:
|
||||
"""The relative major or minor key.
|
||||
|
||||
@@ -115,6 +115,21 @@ class Tone:
|
||||
"""True if this tone has a flat (b after the first character)."""
|
||||
return "b" in self.name[1:]
|
||||
|
||||
@property
|
||||
def letter(self) -> str:
|
||||
"""The letter name without any accidental.
|
||||
|
||||
Example::
|
||||
|
||||
>>> Tone.from_string("C#4").letter
|
||||
'C'
|
||||
>>> Tone.from_string("Bb4").letter
|
||||
'B'
|
||||
>>> Tone.from_string("G4").letter
|
||||
'G'
|
||||
"""
|
||||
return self.name[0]
|
||||
|
||||
@property
|
||||
def enharmonic(self) -> Optional[str]:
|
||||
"""The enharmonic equivalent of this tone, or None if there isn't one.
|
||||
|
||||
+99
-1
@@ -2622,7 +2622,7 @@ def test_tension_empty():
|
||||
|
||||
def test_version():
|
||||
import pytheory
|
||||
assert pytheory.__version__ == "0.3.2"
|
||||
assert pytheory.__version__ == "0.4.0"
|
||||
|
||||
|
||||
def test_all_exports():
|
||||
@@ -3342,3 +3342,101 @@ def test_pachelbel_progression():
|
||||
prog = k.progression(*PROGRESSIONS["Pachelbel"])
|
||||
assert len(prog) == 8
|
||||
assert prog[0].identify() == "C major"
|
||||
|
||||
|
||||
# ── Tone.letter ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_tone_letter_natural():
|
||||
assert Tone.from_string("C4").letter == "C"
|
||||
|
||||
|
||||
def test_tone_letter_sharp():
|
||||
assert Tone.from_string("C#4").letter == "C"
|
||||
|
||||
|
||||
def test_tone_letter_flat():
|
||||
assert Tone(name="Bb", octave=4).letter == "B"
|
||||
|
||||
|
||||
# ── Key.signature ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_key_signature_c_major():
|
||||
sig = Key("C", "major").signature
|
||||
assert sig["sharps"] == 0
|
||||
assert sig["flats"] == 0
|
||||
|
||||
|
||||
def test_key_signature_g_major():
|
||||
sig = Key("G", "major").signature
|
||||
assert sig["sharps"] == 1
|
||||
assert sig["accidentals"] == ["F#"]
|
||||
|
||||
|
||||
def test_key_signature_d_major():
|
||||
sig = Key("D", "major").signature
|
||||
assert sig["sharps"] == 2
|
||||
|
||||
|
||||
# ── Chord.from_intervals ──────────────────────────────────────────────────
|
||||
|
||||
def test_chord_from_intervals_major():
|
||||
assert Chord.from_intervals("C", 4, 7).identify() == "C major"
|
||||
|
||||
|
||||
def test_chord_from_intervals_dom7():
|
||||
assert Chord.from_intervals("G", 4, 7, 10).identify() == "G dominant 7th"
|
||||
|
||||
|
||||
# ── Chord.from_midi_message ──────────────────────────────────────────────
|
||||
|
||||
def test_chord_from_midi_message():
|
||||
c = Chord.from_midi_message(60, 64, 67)
|
||||
assert c.identify() == "C major"
|
||||
|
||||
|
||||
# ── Chord.add_tone / remove_tone ──────────────────────────────────────────
|
||||
|
||||
def test_chord_add_tone():
|
||||
c = Chord.from_tones("C", "E", "G")
|
||||
cmaj7 = c.add_tone(Tone("B", octave=4))
|
||||
assert cmaj7.identify() == "C major 7th"
|
||||
|
||||
|
||||
def test_chord_remove_tone():
|
||||
cmaj7 = Chord.from_name("Cmaj7")
|
||||
c = cmaj7.remove_tone("B")
|
||||
assert c.identify() == "C major"
|
||||
|
||||
|
||||
# ── analyze_progression ──────────────────────────────────────────────────
|
||||
|
||||
def test_analyze_progression():
|
||||
from pytheory import analyze_progression
|
||||
prog = [Chord.from_name("C"), Chord.from_name("Am"),
|
||||
Chord.from_name("F"), Chord.from_name("G")]
|
||||
assert analyze_progression(prog, key="C") == ["I", "vi", "IV", "V"]
|
||||
|
||||
|
||||
# ── Key.borrowed_chords ─────────────────────────────────────────────────
|
||||
|
||||
def test_borrowed_chords():
|
||||
borrowed = Key("C", "major").borrowed_chords
|
||||
assert len(borrowed) > 0
|
||||
|
||||
|
||||
# ── Key.random_progression ──────────────────────────────────────────────
|
||||
|
||||
def test_random_progression():
|
||||
prog = Key("C", "major").random_progression(4)
|
||||
assert len(prog) == 4
|
||||
|
||||
|
||||
# ── Fretboard.scale_diagram ────────────────────────────────────────────
|
||||
|
||||
def test_scale_diagram():
|
||||
fb = Fretboard.guitar()
|
||||
scale = TonedScale(tonic="C4")["major"]
|
||||
diagram = fb.scale_diagram(scale, frets=5)
|
||||
assert "E|" in diagram
|
||||
lines = diagram.strip().split("\n")
|
||||
assert len(lines) == 7
|
||||
|
||||
Reference in New Issue
Block a user