Compare commits

..

1 Commits

Author SHA1 Message Date
kennethreitz da40189845 v0.4.0: key signatures, scale diagrams, chord building, progression analysis
New features:
- Key.signature — sharps/flats count and accidental names
- Key.borrowed_chords — modal interchange from parallel key
- Key.random_progression(n) — weighted random diatonic progressions
- Chord.from_intervals("C", 4, 7) — build from root + semitones
- Chord.from_midi_message(60, 64, 67) — build from MIDI note numbers
- Chord.add_tone(tone) / remove_tone("B") — modify chords immutably
- Tone.letter — "C" from "C#" (letter without accidental)
- Fretboard.scale_diagram(scale) — ASCII neck diagram
- analyze_progression([chords], key="C") → ["I", "vi", "IV", "V"]

443 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:48:04 -04:00
8 changed files with 331 additions and 7 deletions
+1 -1
View File
@@ -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
View File
@@ -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"
+3 -3
View File
@@ -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",
]
+108
View File
@@ -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]
+103
View File
@@ -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.
+15
View File
@@ -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
View File
@@ -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
Generated
+1 -1
View File
@@ -612,7 +612,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.3.2"
version = "0.4.0"
source = { editable = "." }
dependencies = [
{ name = "numeral" },