Compare commits

..

11 Commits

Author SHA1 Message Date
kennethreitz 7e1d9e76bd v0.7.0: Add Fretboard.chord() method for named chord lookups
New `fb.chord("G")` API lets you look up fingerings by chord name
instead of knowing fret positions upfront. Updates all docs to use
REPL-style examples with verified output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:12:42 -04:00
kennethreitz 447d03a2d2 Update homepage code example to REPL style with verified output
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:05:30 -04:00
kennethreitz 7b82d70ad6 Document save() in playback guide and tritone_sub() in chords guide
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:20:35 -04:00
kennethreitz 44f8b902e2 Document capo support in fretboard guitars section
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:10:30 -04:00
kennethreitz 03eb61cd5d Rewrite docs landing page with richer examples
Show Key class, chord progressions, chord identification, interval
naming, and labeled fingerings in the hero code block. Add pip install
line, CLI examples, and a Highlights section summarizing all features.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:07:12 -04:00
kennethreitz eba299d406 Rewrite quickstart with sections for each feature area
Breaks the single code block into focused sections: Tones, Scales,
Keys and Chords, Guitar Fingerings, Audio Playback, and Command Line.
Adds installation notes for PortAudio, shows from_frequency/from_midi,
enharmonics, Key class, Chord convenience constructors, tab output,
WAV export, and CLI commands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:59:55 -04:00
kennethreitz d11c930308 Fix key_explorer.py: borrowed_chords returns strings not Chords
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:56:44 -04:00
kennethreitz 19663ed6c5 Fix world_scales.py: correct gamelan tonic and scale names
Gamelan uses its own tone names (nem, pi, barang, etc.), not Western
note names. Fixed tonic from C4 to nem4 and added pelog nem/barang
modes. Replaced miyako-bushi with iwato and kumoi (actual scale names
in the system). Added ValueError to exception handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:55:52 -04:00
kennethreitz f949ca5b45 Show version number in docs sidebar via extra_nav_links
Links to PyPI page for the current version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:45:28 -04:00
kennethreitz d9f847603a Show version in docs sidebar and switch to GitHub star button
Version is now pulled from pytheory.__version__ instead of hardcoded.
GitHub button changed from watch to star with count.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:43:25 -04:00
kennethreitz ee41691728 Skip play module tests when PortAudio is not available
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:41:39 -04:00
12 changed files with 339 additions and 80 deletions
+10 -1
View File
@@ -10,7 +10,9 @@ sys.modules["sounddevice"] = MagicMock()
project = "PyTheory"
copyright = "2026, Kenneth Reitz"
author = "Kenneth Reitz"
release = "0.4.1"
import pytheory
release = pytheory.__version__
version = pytheory.__version__
extensions = [
"sphinx.ext.autodoc",
@@ -38,7 +40,14 @@ html_theme_options = {
"github_user": "kennethreitz",
"github_repo": "pytheory",
"github_banner": True,
"github_button": True,
"github_type": "star",
"github_count": True,
"description": "Music Theory for Humans",
"extra_nav_links": {
f"v{pytheory.__version__}": "https://pypi.org/project/pytheory/",
},
"show_powered_by": False,
}
html_static_path = ["_static"]
html_extra_path = ["CNAME"]
+23
View File
@@ -389,6 +389,29 @@ gold standard — every voice moves by step whenever possible.
print(f"{src} -> {dst} ({motion:+d} semitones)")
# Each voice moves the minimum distance to reach the target chord
Tritone Substitution
--------------------
In jazz harmony, any `dominant chord <https://en.wikipedia.org/wiki/Dominant_seventh_chord>`_
can be replaced by the dominant chord a
`tritone <https://en.wikipedia.org/wiki/Tritone_substitution>`_ (6
semitones) away. This works because the two chords share the same
tritone interval — the 3rd and 7th simply swap roles.
Common tritone subs: G7 <-> Db7, C7 <-> F#7, D7 <-> Ab7.
.. code-block:: python
from pytheory import Chord
g7 = Chord.from_name("G7")
sub = g7.tritone_sub()
sub.identify() # 'C# dominant 7th' (enharmonic Db7)
# Both resolve to C — try them in a ii-V-I:
# Dm7 → G7 → Cmaj7 (standard)
# Dm7 → Db7 → Cmaj7 (with tritone sub — chromatic bass line!)
The Overtone Series
-------------------
+42 -29
View File
@@ -55,6 +55,19 @@ strings, except between G and B which is a major 3rd (4 semitones).
# Custom tuning with any notes
Fretboard.guitar(("C4", "G3", "C3", "G2", "C2", "G1"))
**Capo** — a `capo <https://en.wikipedia.org/wiki/Capo>`_ raises all
strings by a number of frets, letting you play open chord shapes in
higher keys:
.. code-block:: python
# Capo on fret 2 — open G shape now sounds as A major
fb = Fretboard.guitar(capo=2)
# Or apply a capo to an existing fretboard
fb = Fretboard.guitar()
fb_capo3 = fb.capo(3)
The Mandolin Family
-------------------
@@ -172,46 +185,46 @@ on any instrument. It scores each possibility by:
2. Preferring **ascending** fret patterns — easier hand position
3. Minimizing the number of **fingers needed**
.. code-block:: python
.. code-block:: pycon
from pytheory import Fretboard, CHARTS
>>> from pytheory import Fretboard, CHARTS
fb = Fretboard.guitar()
c = CHARTS["western"]["C"]
>>> fb = Fretboard.guitar()
>>> f = fb.chord("C")
>>> f
Fingering(e=0, B=1, G=0, D=2, A=3, E=0)
# Fingerings return a Fingering object with labeled strings
f = c.fingering(fretboard=fb)
print(f)
# Fingering(e=0, B=1, G=0, D=2, A=3, E=0)
>>> f['A']
3
>>> f[1]
1
# Access by string name or index
f['A'] # 3
f[1] # 1 (B string)
>>> f.identify()
'C major'
# Identify the chord directly from a fingering
f.identify() # 'C major'
>>> chord = f.to_chord()
>>> chord.identify()
'C major'
# Convert to a Chord for further analysis
chord = f.to_chord()
chord.harmony # consonance score
chord.intervals # [4, 3] — major triad
>>> # All equally-scored fingerings via CHARTS
>>> CHARTS["western"]["C"].fingering(fretboard=fb, multiple=True)
[...]
# All equally-scored fingerings
all_c = c.fingering(fretboard=fb, multiple=True)
# Muted strings appear as None
f = CHARTS["western"]["F"]
print(f.fingering(fretboard=fb))
>>> # Muted strings appear as None
>>> CHARTS["western"]["F"].fingering(fretboard=fb)
...
You can also go from fret positions to chord identification:
.. code-block:: python
.. code-block:: pycon
# "What chord am I playing?"
fb = Fretboard.guitar()
f = fb.fingering(0, 0, 0, 2, 2, 0)
print(f) # Fingering(e=0, B=0, G=0, D=2, A=2, E=0)
print(f.identify()) # E minor
>>> # "What chord am I playing?"
>>> fb = Fretboard.guitar()
>>> f = fb.fingering(0, 0, 0, 2, 2, 0)
>>> f
Fingering(e=0, B=0, G=0, D=2, A=2, E=0)
>>> f.identify()
'E minor'
Reading Fingerings
~~~~~~~~~~~~~~~~~~
+20
View File
@@ -77,3 +77,23 @@ Try playing a C major chord in each temperament — you'll hear subtle
differences in the "color" of the major third. Equal temperament is
a compromise; the other systems sacrifice some keys to make the good
keys sound better.
Saving to WAV
-------------
Render tones or chords to a WAV file instead of playing them live.
This works even without speakers or PortAudio:
.. code-block:: python
from pytheory import save, Chord, Tone, Synth
# Save a single tone
save(Tone.from_string("A4"), "a440.wav", t=1_000)
# Save a chord
save(Chord.from_name("Am7"), "am7.wav", t=2_000)
# Choose waveform and temperament
save(Chord.from_name("C"), "c_triangle.wav",
synth=Synth.TRIANGLE, temperament="meantone", t=3_000)
+150 -26
View File
@@ -8,46 +8,169 @@ Installation
$ pip install pytheory
Basic Usage
-----------
For audio playback, you'll also need `PortAudio <http://www.portaudio.com/>`_:
Create tones, build scales, and explore music theory:
- macOS: ``brew install portaudio``
- Ubuntu: ``apt install libportaudio2``
- Windows: included with the ``sounddevice`` package
Tones
-----
A :class:`~pytheory.tones.Tone` is a single musical note:
.. code-block:: python
from pytheory import Tone, TonedScale, Fretboard, CHARTS
from pytheory import Tone
# Create a tone — A4 is the tuning standard (440 Hz)
# Create tonessharps and flats both work
a4 = Tone.from_string("A4", system="western")
print(a4.frequency) # 440.0
a4.frequency # 440.0 Hz — the tuning standard
# Tone arithmetic — add semitones to move up the chromatic scale
c4 = Tone.from_string("C4", system="western")
e4 = c4 + 4 # Major third up (4 semitones)
g4 = c4 + 7 # Perfect fifth up (7 semitones)
print(e4, g4) # E4 G4
c4.midi # 60 — middle C
# Measure intervals between tones
print(g4 - c4) # 7 (semitones — a perfect fifth)
# From a frequency or MIDI number
Tone.from_frequency(440) # <Tone A4>
Tone.from_midi(60) # <Tone C4>
# Build a C major scale
c_major = TonedScale(tonic="C4")["major"]
print(c_major.note_names)
# Tone arithmetic
c4 + 4 # <Tone E4> — major third up
c4 + 7 # <Tone G4> — perfect fifth up
# Interval between two tones
g4 = c4 + 7
g4 - c4 # 7 semitones
c4.interval_to(g4) # 'perfect 5th'
# Enharmonics
Tone.from_string("C#4", system="western").enharmonic # 'Db'
Scales
------
Build scales in any key and mode:
.. code-block:: python
from pytheory import TonedScale
c = TonedScale(tonic="C4")
c["major"].note_names
# ['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
# Build diatonic triads from the scale
I = c_major.triad(0) # C E G (C major)
IV = c_major.triad(3) # F A C (F major)
V = c_major.triad(4) # G B D (G major)
c["minor"].note_names
# ['C', 'D', 'D#', 'F', 'G', 'G#', 'A#', 'C']
# Guitar chord fingerings — labeled with string names
fb = Fretboard.guitar()
fingering = CHARTS["western"]["Am"].fingering(fretboard=fb)
print(fingering) # Fingering(e=0, B=1, G=2, D=2, A=0, E=0)
c["dorian"].note_names
# ['C', 'D', 'D#', 'F', 'G', 'A', 'A#', 'C']
# Identify a chord from fret positions
f = fb.fingering(0, 1, 0, 2, 3, 0)
print(f.identify()) # C major
# Access scale degrees by name or numeral
major = c["major"]
major["tonic"] # C4
major["dominant"] # G4
major["V"] # G4
Keys and Chords
---------------
The :class:`~pytheory.scales.Key` class ties everything together —
scales, chords, and progressions:
.. code-block:: python
from pytheory import Key
key = Key("G", "major")
key.note_names # ['G', 'A', 'B', 'C', 'D', 'E', 'F#', 'G']
# All diatonic triads
key.chords
# ['G major', 'A minor', 'B minor', 'C major',
# 'D major', 'E minor', 'F# diminished']
# Build progressions from Roman numerals
chords = key.progression("I", "V", "vi", "IV")
[c.identify() for c in chords]
# ['G major', 'D major', 'E minor', 'C major']
# Detect the key from notes
Key.detect("C", "E", "G", "A", "D") # <Key C major>
Build chords directly:
.. code-block:: python
from pytheory import Chord
Chord.from_tones("C", "E", "G") # <Chord C major>
Chord.from_name("Am7") # <Chord A minor 7th>
Chord.from_intervals("G", 4, 7, 10) # <Chord G dominant 7th>
# Identify any chord
Chord.from_tones("Bb", "D", "F").identify() # 'Bb major'
# Analyze in a key
Chord.from_name("G7").analyze("C") # 'V7'
Guitar Fingerings
-----------------
.. code-block:: pycon
>>> from pytheory import Fretboard
>>> fb = Fretboard.guitar()
>>> fb.chord("C")
Fingering(e=0, B=1, G=0, D=2, A=3, E=0)
>>> fb.chord("C")['A']
3
>>> fb.fingering(0, 0, 0, 2, 2, 0).identify()
'E minor'
>>> from pytheory import CHARTS
>>> print(CHARTS["western"]["Am"].tab(fretboard=fb))
Am
E|--0--
B|--1--
G|--2--
D|--2--
A|--0--
E|--0--
Audio Playback
--------------
.. code-block:: python
from pytheory import Tone, Chord, play, save, Synth
# Play a tone
play(Tone.from_string("A4"), t=1_000)
# Play a chord with a different waveform
play(Chord.from_name("Am7"), synth=Synth.TRIANGLE, t=2_000)
# Save to a WAV file
save(Chord.from_name("C"), "c_major.wav", t=2_000)
Command Line
------------
PyTheory also works from the terminal::
$ pytheory tone A4
$ pytheory chord C E G
$ pytheory key G major
$ pytheory scale C dorian
$ pytheory fingering Am
$ pytheory progression C major I V vi IV
$ pytheory detect C E G A D
$ pytheory play Am7 --synth triangle
What's Included
---------------
@@ -67,3 +190,4 @@ What's Included
- **Fingering generation** for 25 instruments with labeled string names,
including guitar (8 tunings), bass, ukulele, mandolin, and more
- **Audio playback** with sine, sawtooth, and triangle wave synthesis
- **WAV export** for saving rendered audio to disk
+58 -15
View File
@@ -1,26 +1,69 @@
PyTheory: Music Theory for Humans
=================================
**PyTheory** is a Python library that makes exploring music theory approachable.
Work with tones, scales, chords, and fretboards using a clean, Pythonic API.
**PyTheory** is a Python library that makes exploring music theory
approachable and fun. Work with tones, scales, chords, keys, and
instruments using a clean, Pythonic API.
.. code-block:: python
::
from pytheory import TonedScale, Fretboard, CHARTS
$ pip install pytheory
# Build a C major scale
c_major = TonedScale(tonic="C4")["major"]
print(c_major.note_names)
# ['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
.. code-block:: pycon
# Build a triad from the scale
chord = c_major.triad(0) # C major triad
for tone in chord:
print(f"{tone}: {tone.frequency:.1f} Hz")
>>> from pytheory import Key, Chord, Tone, Fretboard
# Get guitar fingerings
fb = Fretboard.guitar()
print(CHARTS["western"]["C"].fingering(fretboard=fb))
>>> key = Key("C", "major")
>>> key.chords
['C major', 'D minor', 'E minor', 'F major',
'G major', 'A minor', 'B diminished']
>>> [c.identify() for c in key.progression("I", "V", "vi", "IV")]
['C major', 'G major', 'A minor', 'F major']
>>> Chord.from_tones("Bb", "D", "F").identify()
'Bb major'
>>> c4 = Tone.from_string("C4", system="western")
>>> c4.interval_to(c4 + 7)
'perfect 5th'
>>> fb = Fretboard.guitar()
>>> fb.chord("G")
Fingering(e=3, B=0, G=0, D=0, A=2, E=3)
It also works from the command line::
$ pytheory key G major
Key: G major
Signature: 1 sharps, 0 flats (F#)
Scale: G A B C D E F# G
...
$ pytheory chord C E G
Chord: C major
Tones: C4 E4 G4
Intervals: [4, 3]
...
$ pytheory play Am7 --synth triangle
Playing: A minor 7th (A4 C4 E4 G4)
Synth: triangle
Highlights
----------
- **Tones**: frequencies, MIDI, intervals, transposition, circle of fifths,
overtone series, 3 temperaments (equal, Pythagorean, meantone)
- **Scales**: 40+ scales across 6 musical systems — Western, Indian,
Arabic, Japanese, Blues, Javanese Gamelan
- **Chords**: 17 chord types identified automatically, Roman numeral
analysis, tension scoring, voice leading, consonance/dissonance
- **Keys**: key detection, signatures, progressions (Roman numerals and
Nashville numbers), borrowed chords, secondary dominants
- **Instruments**: 25 presets (guitar, bass, ukulele, mandolin, violin,
banjo, oud, sitar, erhu, and more) with fingering generation
- **Audio**: sine, sawtooth, and triangle wave playback + WAV export
.. toctree::
:maxdepth: 2
+2 -2
View File
@@ -48,8 +48,8 @@ def explore_key(tonic, mode="major"):
borrowed = key.borrowed_chords
if borrowed:
print(f" Borrowed from {key.parallel}:")
for chord in borrowed[:4]:
print(f" {chord.identify()}")
for name in borrowed[:4]:
print(f" {name}")
print()
+8 -5
View File
@@ -28,19 +28,22 @@ systems = [
]),
("japanese", "C4", [
("hirajoshi", "Haunting pentatonic — koto music"),
("miyako-bushi", "Urban folk — shamisen music"),
("yo", "Bright pentatonic — folk songs, festival music"),
("in", "Dark pentatonic — court music, Buddhist chant"),
("ritsu", "Elegant pentatonic — gagaku court music"),
("yo", "Bright pentatonic — folk songs, festival music"),
("iwato", "Sparse, mysterious — zen atmosphere"),
("kumoi", "Gentle pentatonic — lyrical, nostalgic"),
("ritsu", "Elegant heptatonic — gagaku court music"),
]),
("blues", "C4", [
("blues", "The 6-note blues scale with the 'blue note'"),
("minor pentatonic", "The backbone of rock guitar solos"),
("major pentatonic", "Bright, open — country, folk, pop"),
]),
("gamelan", "C4", [
("gamelan", "nem4", [
("slendro", "5-note near-equal division — metallic, shimmering"),
("pelog", "7-note unequal — mysterious, otherworldly"),
("pelog nem", "Pelog mode on nem — the most common mode"),
("pelog barang", "Pelog mode on barang — bright, festive"),
]),
]
@@ -58,7 +61,7 @@ for system_name, tonic, scales in systems:
print(f" {scale_name:20s} {notes}")
print(f" {'':20s} {description}")
print()
except (KeyError, IndexError):
except (KeyError, IndexError, ValueError):
print(f" {scale_name:20s} (not available)")
print()
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.6.1"
version = "0.7.0"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.6.1"
__version__ = "0.7.0"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
+19
View File
@@ -1251,6 +1251,25 @@ class Fretboard:
return "\n".join(lines)
def chord(self, name: str, *, system: str = "western") -> "Fingering":
"""Look up a chord by name and return its best fingering.
Args:
name: Chord name like ``"G"``, ``"Am7"``, ``"Bb"``, ``"Dm"``.
system: Tonal system to use (default ``"western"``).
Returns:
A :class:`Fingering` for that chord on this fretboard.
Example::
>>> fb = Fretboard.guitar()
>>> fb.chord("G")
Fingering(e=3, B=0, G=0, D=0, A=2, E=3)
"""
from .charts import CHARTS
return CHARTS[system][name].fingering(fretboard=self)
def fingering(self, *positions: int) -> "Fingering":
"""Apply fret positions to each string, returning a Fingering.
+5
View File
@@ -3832,6 +3832,7 @@ def test_cli_main_no_args(capsys):
# ── Play module tests ─────────────────────────────────────────────────────────
@needs_portaudio
def test_play_render():
"""_render produces a numpy array of the right length."""
from pytheory.play import _render, Synth, SAMPLE_RATE
@@ -3841,6 +3842,7 @@ def test_play_render():
assert len(samples) == expected
@needs_portaudio
def test_play_render_chord():
from pytheory.play import _render, Synth
chord = Chord.from_tones("C", "E", "G")
@@ -3848,6 +3850,7 @@ def test_play_render_chord():
assert len(samples) > 0
@needs_portaudio
def test_play_render_all_synths():
from pytheory.play import _render, Synth
tone = Tone.from_string("C4", system="western")
@@ -3856,6 +3859,7 @@ def test_play_render_all_synths():
assert len(samples) > 0
@needs_portaudio
def test_play_save(tmp_path):
"""save() writes a valid WAV file."""
from pytheory.play import save, Synth
@@ -3866,6 +3870,7 @@ def test_play_save(tmp_path):
assert path.stat().st_size > 44 # WAV header is 44 bytes
@needs_portaudio
def test_play_save_chord(tmp_path):
from pytheory.play import save
path = tmp_path / "chord.wav"