Compare commits

..

6 Commits

Author SHA1 Message Date
kennethreitz a5e47c37cd v0.6.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:04:00 -04:00
kennethreitz 8a9651f989 Add tests for flat note name support
14 tests covering: flat tone creation, frequency matching with sharp
equivalents, all enharmonic pairs, arithmetic, intervals, exists
property, index resolution, chords built from flats, and
System.resolve_name().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:02:26 -04:00
kennethreitz cc4a25e70d Support flat note names (Db, Bb, Eb, etc.) throughout the system
Flat names are now resolved to their canonical sharp equivalents when
looking up tones in a system. This means Tone.from_string("Db4") now
works for frequency, arithmetic, intervals, and chord building —
previously it raised a ValueError.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:01:11 -04:00
kennethreitz 904c61b2d6 Show enharmonic property in tones docs instead of from_tuple
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:59:30 -04:00
kennethreitz d23de92713 Update docs to use newer APIs (Key, Fingering, convenience constructors)
- Circle of fifths: use tone.circle_of_fifths() instead of manual loop
- Fingerings: show labeled Fingering class with string names, identify()
- Chords: document from_tones(), from_name(), from_intervals(), from_midi_message()
- Scales: add Key class, Key.detect(), Key.progression(), nashville()
- Playback: simplify examples with Chord.from_name()
- README: add Keys section, update fingering output format
- Quickstart: add chord identification from fret positions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:57:06 -04:00
kennethreitz e8bfeb884a Add Fingering class for labeled chord fingerings (#25)
Replace plain tuples from fingering() methods with a Fingering object
that labels each fret position with its string name, supporting both
named (f['A']) and index (f[1]) access while remaining backward
compatible with tuple equality.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:11:37 -04:00
14 changed files with 374 additions and 55 deletions
+20 -1
View File
@@ -62,6 +62,22 @@ $ pip install pytheory
['C major', 'G major', 'A minor', 'F major']
```
## Keys and Progressions
```pycon
>>> from pytheory import Key
>>> key = Key("G", "major")
>>> key.chords
['G major', 'A minor', 'B minor', 'C major', 'D major', 'E minor', 'F# diminished']
>>> [c.identify() for c in key.progression("I", "V", "vi", "IV")]
['G major', 'D major', 'E minor', 'C major']
>>> Key.detect("C", "E", "G", "A", "D")
<Key C major>
```
## Chord Analysis
```pycon
@@ -116,7 +132,10 @@ $ pip install pytheory
>>> Fretboard.keyboard(25, "C3") # 25-key MIDI controller
>>> CHARTS['western']['Am'].fingering(fretboard=Fretboard.guitar())
(0, 1, 2, 2, 0, 0)
Fingering(e=0, B=1, G=2, D=2, A=0, E=0)
>>> Fretboard.guitar().fingering(0, 1, 0, 2, 3, 0).identify()
'C major'
```
## Audio Playback
+23 -3
View File
@@ -127,13 +127,33 @@ Quality Intervals Example tones (from C)
>>> chart["Cm7"].acceptable_tone_names
('C', 'D#', 'G', 'A#') # Eb and Bb shown as sharps
Building Chords Manually
-------------------------
Building Chords
---------------
Several convenience constructors make chord creation concise:
.. code-block:: python
from pytheory import Tone, Chord
from pytheory import Chord
# From note names (simplest)
Chord.from_tones("C", "E", "G") # <Chord C major>
Chord.from_tones("A", "C", "E") # <Chord A minor>
# From a chord name (uses the built-in chart)
Chord.from_name("Am7") # <Chord A minor 7th>
Chord.from_name("G7") # <Chord G dominant 7th>
# From root + semitone intervals
Chord.from_intervals("C", 4, 7) # <Chord C major>
Chord.from_intervals("D", 3, 7) # <Chord D minor>
Chord.from_intervals("G", 4, 7, 10) # <Chord G dominant 7th>
# From MIDI note numbers
Chord.from_midi_message(60, 64, 67) # <Chord C major>
# Full manual construction
from pytheory import Tone
c_major = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
+45 -5
View File
@@ -179,9 +179,22 @@ on any instrument. It scores each possibility by:
fb = Fretboard.guitar()
c = CHARTS["western"]["C"]
# Best single fingering
print(c.fingering(fretboard=fb))
# (0, 1, 0, 2, 3, 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)
# Access by string name or index
f['A'] # 3
f[1] # 1 (B string)
# Identify the chord directly from a fingering
f.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
all_c = c.fingering(fretboard=fb, multiple=True)
@@ -190,11 +203,22 @@ on any instrument. It scores each possibility by:
f = CHARTS["western"]["F"]
print(f.fingering(fretboard=fb))
You can also go from fret positions to chord identification:
.. code-block:: python
# "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
Reading Fingerings
~~~~~~~~~~~~~~~~~~
The tuple ``(0, 1, 0, 2, 3, 0)`` reads from the highest string to the
lowest::
Each position is labeled with its string name. Duplicate string names
are disambiguated — on a standard guitar, high E appears as ``e`` and
low E as ``E``::
e|--0-- (open — E)
B|--1-- (fret 1 — C)
@@ -205,6 +229,22 @@ lowest::
A value of ``None`` means the string is muted (not played).
ASCII Tablature
~~~~~~~~~~~~~~~
For a more visual representation, use ``tab()``:
.. code-block:: python
>>> print(CHARTS["western"]["C"].tab(fretboard=fb))
C
E|--0--
B|--1--
G|--0--
D|--2--
A|--3--
E|--0--
Generating Full Charts
----------------------
+6 -7
View File
@@ -25,14 +25,13 @@ Playing a Chord
.. code-block:: python
from pytheory import Chord, Tone, play
from pytheory import Chord, play
c_major = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
])
play(c_major, t=2_000) # Play for 2 seconds
# From a chord name
play(Chord.from_name("Am7"), t=2_000)
# From note names
play(Chord.from_tones("C", "E", "G"), t=2_000)
Waveform Types
--------------
+12 -5
View File
@@ -40,10 +40,14 @@ Create tones, build scales, and explore music theory:
IV = c_major.triad(3) # F A C (F major)
V = c_major.triad(4) # G B D (G major)
# Guitar chord fingerings
# Guitar chord fingerings — labeled with string names
fb = Fretboard.guitar()
fingering = CHARTS["western"]["Am"].fingering(fretboard=fb)
print(fingering) # (0, 1, 2, 2, 0, 0)
print(fingering) # Fingering(e=0, B=1, G=2, D=2, A=0, E=0)
# Identify a chord from fret positions
f = fb.fingering(0, 1, 0, 2, 3, 0)
print(f.identify()) # C major
What's Included
---------------
@@ -54,9 +58,12 @@ What's Included
10 maqamat, 6 Japanese pentatonic scales, blues, pentatonic,
slendro, pelog, and more
- **Pitch calculation** in equal, Pythagorean, and meantone temperaments
- **Chord identification**: name any chord from its notes, intervals, or
MIDI numbers (17 chord types recognized)
- **Chord charts** with 144 pre-built chords (12 roots x 12 qualities)
- **Chord analysis**: consonance scoring, Plomp-Levelt dissonance,
beat frequency calculation
- **Fingering generation** for guitar (8 tunings), bass, ukulele, or
any custom fretted instrument
beat frequency calculation, harmonic tension, voice leading
- **Key detection** and **Roman numeral analysis** (I-IV-V-I progressions)
- **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
+29
View File
@@ -215,6 +215,35 @@ Some of the most-used chord progressions in Western music:
My Heart Will Go On)
- **IIVviV** — axis of awesome (many, many pop songs)
The :class:`~pytheory.scales.Key` class makes working with progressions
easy:
.. code-block:: python
from pytheory import Key
key = Key("G", "major")
# Build a progression from Roman numerals
chords = key.progression("I", "V", "vi", "IV")
for c in chords:
print(c.identify())
# G major, D major, E minor, C major
# Nashville number system (same thing, with integers)
key.nashville(1, 5, 6, 4)
# All diatonic triads in the key
key.chords
# ['G major', 'A minor', 'B minor', 'C major', ...]
# All diatonic seventh chords
key.seventh_chords
# ['G major 7th', 'A minor 7th', ...]
# Detect the key from a set of notes
Key.detect("C", "E", "G", "A", "D") # <Key C major>
The 12-Bar Blues
~~~~~~~~~~~~~~~~
+19 -8
View File
@@ -286,12 +286,19 @@ D major scale is D E F# G A B C# — not D E Gb G A B Db, even though
F#=Gb and C#=Db.
PyTheory uses sharps by default (following the tone list ordering), but
tones carry their enharmonic equivalents:
every tone knows its enharmonic spelling:
.. code-block:: python
>>> Tone.from_tuple(("C#", "Db")).names()
['C#', 'Db']
>>> Tone.from_string("C#4", system="western").enharmonic
'Db'
>>> Tone.from_string("A#4", system="western").enharmonic
'Bb'
# Natural notes have no enharmonic
>>> Tone.from_string("C4", system="western").enharmonic is None
True
The Circle of Fifths
--------------------
@@ -303,11 +310,15 @@ to the starting note:
.. code-block:: python
>>> t = Tone.from_string("C4", system="western")
>>> for i in range(12):
... print(t.name, end=" ")
... t = t + 7
C G D A E B F# C# G# D# A# F
>>> c4 = Tone.from_string("C4", system="western")
# Clockwise — ascending fifths (adds sharps)
>>> [t.name for t in c4.circle_of_fifths()]
['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'C#', 'G#', 'D#', 'A#', 'F']
# Counter-clockwise — ascending fourths (adds flats)
>>> [t.name for t in c4.circle_of_fourths()]
['C', 'F', 'A#', 'D#', 'G#', 'C#', 'F#', 'B', 'E', 'A', 'D', 'G']
Each step clockwise adds one sharp to the key signature; each step
counter-clockwise (ascending by fourths = 5 semitones) adds one flat.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.5.1"
version = "0.6.0"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
+3 -3
View File
@@ -1,12 +1,12 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.4.1"
__version__ = "0.6.0"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
from .scales import Scale, TonedScale, Key, PROGRESSIONS
from .chords import Chord, Fretboard, analyze_progression
from .charts import CHARTS, charts_for_fretboard
from .charts import CHARTS, Fingering, charts_for_fretboard
try:
from .play import play, Synth
@@ -19,7 +19,7 @@ Note = Tone
__all__ = [
"Tone", "Note", "Interval", "Scale", "TonedScale", "Key",
"PROGRESSIONS", "Chord", "Fretboard", "analyze_progression",
"PROGRESSIONS", "Chord", "Fretboard", "Fingering", "analyze_progression",
"System", "SYSTEMS", "CHARTS", "charts_for_fretboard",
"play", "Synth",
]
+104 -2
View File
@@ -1,4 +1,5 @@
import itertools
from typing import Optional
from .systems import SYSTEMS
from .tones import Tone
@@ -6,6 +7,106 @@ from .tones import Tone
QUALITIES = ("", "maj", "m", "5", "7", "9", "dim", "m6", "m7", "m9", "maj7", "maj9")
MAX_FRET = 7
class Fingering:
"""A chord fingering labeled with string names.
Provides both index and named access to fret positions, making it
clear which string each position corresponds to.
Example::
>>> f = Fingering(positions=(0, 3, 2, 0, 1, 0),
... string_names=('E', 'A', 'D', 'G', 'B', 'e'))
>>> f
Fingering(E=0, A=3, D=2, G=0, B=1, e=0)
>>> f['A']
3
>>> f[1]
3
"""
def __init__(self, positions: tuple, string_names: tuple[str, ...], *, fretboard=None) -> None:
self.positions = tuple(positions)
self._fretboard = fretboard
# Disambiguate duplicate names: for standard guitar tuning
# (high-to-low), the first occurrence of a duplicate becomes
# lowercase (e.g. high E → 'e') while the last keeps uppercase.
from collections import Counter
name_counts = Counter(string_names)
seen: dict[str, int] = {}
unique_names: list[str] = []
for name in string_names:
seen[name] = seen.get(name, 0) + 1
if name_counts[name] > 1 and seen[name] < name_counts[name]:
unique_names.append(name.lower())
else:
unique_names.append(name)
self.string_names = tuple(unique_names)
self._map = dict(zip(self.string_names, self.positions))
def __repr__(self) -> str:
pairs = ", ".join(
f"{name}={'x' if pos is None else pos}"
for name, pos in zip(self.string_names, self.positions)
)
return f"Fingering({pairs})"
def __getitem__(self, key):
if isinstance(key, int):
return self.positions[key]
return self._map[key]
def __iter__(self):
return iter(self.positions)
def __len__(self):
return len(self.positions)
def __eq__(self, other):
if isinstance(other, Fingering):
return self.positions == other.positions and self.string_names == other.string_names
if isinstance(other, tuple):
return self.positions == other
return NotImplemented
@property
def tones(self):
"""Return the sounding tones for this fingering.
Requires that the Fingering was created with a fretboard reference.
Muted strings (``None``) are excluded.
"""
if self._fretboard is None:
raise ValueError("Cannot resolve tones without a fretboard reference.")
tones = []
for pos, tone in zip(self.positions, self._fretboard.tones):
if pos is not None:
tones.append(tone.add(pos))
return tones
def to_chord(self, fretboard=None) -> "Chord":
"""Apply this fingering to a fretboard, returning a Chord.
Strings with ``None`` positions (muted) are excluded.
If no fretboard is given, uses the one stored at creation time.
"""
from .chords import Chord
fb = fretboard or self._fretboard
if fb is None:
raise ValueError("No fretboard provided.")
tones = []
for pos, tone in zip(self.positions, fb.tones):
if pos is not None:
tones.append(tone.add(pos))
return Chord(tones=tones)
def identify(self) -> Optional[str]:
"""Identify the chord name from this fingering."""
return self.to_chord().identify()
CHARTS = {}
CHARTS["western"] = []
@@ -148,11 +249,12 @@ class NamedChord:
if fingering_score(possible_fingering) == max_score:
yield possible_fingering
string_names = tuple(t.name for t in fretboard.tones)
best_fingerings = tuple([g for g in gen()])
if not multiple:
return self.fix_fingering(best_fingerings[0])
return Fingering(self.fix_fingering(best_fingerings[0]), string_names, fretboard=fretboard)
else:
return tuple([self.fix_fingering(f) for f in best_fingerings])
return tuple([Fingering(self.fix_fingering(f), string_names, fretboard=fretboard) for f in best_fingerings])
def tab(self, *, fretboard):
"""Render this chord as ASCII guitar tablature.
+15 -16
View File
@@ -680,8 +680,8 @@ class Chord:
"""
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.
def fingering(self, *positions: int) -> "Fingering":
"""Apply fret positions to each tone, returning a Fingering.
Each position value is added (in semitones) to the corresponding
tone. The number of positions must match the number of tones.
@@ -690,22 +690,21 @@ class Chord:
*positions: One integer per tone indicating the fret offset.
Returns:
A new :class:`Chord` with each tone shifted by its position.
A :class:`Fingering` labeled with tone names.
Raises:
ValueError: If the number of positions doesn't match the
number of tones.
"""
from .charts import Fingering
if not len(positions) == len(self.tones):
raise ValueError(
"The number of positions must match the number of tones (strings)."
)
tones = []
for i, tone in enumerate(self.tones):
tones.append(tone.add(positions[i]))
return Chord(tones=tones)
string_names = tuple(t.name for t in self.tones)
return Fingering(positions, string_names)
class Fretboard:
@@ -1252,8 +1251,8 @@ class Fretboard:
return "\n".join(lines)
def fingering(self, *positions: int) -> Chord:
"""Apply fret positions to each string, returning a Chord.
def fingering(self, *positions: int) -> "Fingering":
"""Apply fret positions to each string, returning a Fingering.
Each position value is added (in semitones) to the corresponding
open-string tone. The number of positions must match the number
@@ -1263,22 +1262,22 @@ class Fretboard:
*positions: One integer per string indicating the fret number.
Returns:
A :class:`Chord` with each tone shifted by its fret position.
A :class:`Fingering` labeled with string names. Call
``.to_chord(fretboard)`` or use the resulting chord directly.
Raises:
ValueError: If the number of positions doesn't match the
number of strings.
"""
from .charts import Fingering
if not len(positions) == len(self.tones):
raise ValueError(
"The number of positions must match the number of tones (strings)."
)
tones = []
for i, tone in enumerate(self.tones):
tones.append(tone.add(positions[i]))
return Chord(tones=tones)
string_names = tuple(t.name for t in self.tones)
return Fingering(positions, string_names, fretboard=self)
def analyze_progression(chords: list[Chord], key: str = "C", mode: str = "major") -> list[str | None]:
+10
View File
@@ -24,6 +24,16 @@ class System:
from . import Tone
return tuple([Tone.from_tuple(tone) for tone in self.tone_names])
def resolve_name(self, name: str) -> str | None:
"""Resolve a note name (including flats) to the canonical name.
Returns the primary name if found, or None if not recognized.
"""
for names in self.tone_names:
if name in names:
return names[0]
return None
@property
def scales(self):
+9 -3
View File
@@ -71,7 +71,7 @@ class Tone:
@property
def exists(self) -> bool:
"""True if this tone's name is found in the associated system."""
return self.name in self.system.tones
return self.system.resolve_name(self.name) is not None
@property
def system(self) -> object:
@@ -331,11 +331,17 @@ class Tone:
def _index(self) -> int:
"""The index of this tone within its associated system's tone list.
Resolves enharmonic names (e.g. 'Db''C#') before lookup.
Raises:
ValueError: If no system is associated with this tone.
ValueError: If no system is associated with this tone or
the name is not found.
"""
try:
return self.system.tones.index(self.name)
canonical = self.system.resolve_name(self.name)
if canonical is None:
raise ValueError(f"Tone {self.name!r} not found in system")
return self.system.tones.index(canonical)
except AttributeError:
raise ValueError("Tone index cannot be referenced without a system!")
+78 -1
View File
@@ -2622,7 +2622,7 @@ def test_tension_empty():
def test_version():
import pytheory
assert pytheory.__version__ == "0.4.1"
assert pytheory.__version__ == "0.6.0"
def test_all_exports():
@@ -3647,3 +3647,80 @@ def test_charts_muted_string():
nc = NamedChord(tone_name="C", quality="")
fixed = nc.fix_fingering((0, -1, 2))
assert fixed == (0, None, 2)
# ── Flat note support ─────────────────────────────────────────────────────────
def test_flat_tone_from_string():
db = Tone.from_string("Db4", system="western")
assert db.name == "Db"
assert db.octave == 4
def test_flat_tone_frequency_matches_sharp():
db = Tone.from_string("Db4", system="western")
cs = Tone.from_string("C#4", system="western")
assert db.frequency == cs.frequency
def test_flat_tone_frequency_all_enharmonics():
pairs = [("Bb3", "A#3"), ("Eb4", "D#4"), ("Gb4", "F#4"), ("Ab4", "G#4")]
for flat, sharp in pairs:
f = Tone.from_string(flat, system="western").frequency
s = Tone.from_string(sharp, system="western").frequency
assert f == s, f"{flat} != {sharp}"
def test_flat_tone_arithmetic():
db = Tone.from_string("Db4", system="western")
result = db + 2
assert result.name == "D#"
assert result.octave == 4
def test_flat_tone_interval():
c4 = Tone.from_string("C4", system="western")
db4 = Tone.from_string("Db4", system="western")
assert db4 - c4 == 1
def test_flat_tone_exists():
db = Tone.from_string("Db4", system="western")
assert db.exists is True
def test_flat_tone_index_resolves():
db = Tone.from_string("Db4", system="western")
cs = Tone.from_string("C#4", system="western")
assert db._index == cs._index
def test_flat_chord_from_tones():
chord = Chord.from_tones("Db", "F", "Ab")
assert chord.identify() == "Db major"
def test_flat_chord_from_tones_minor():
chord = Chord.from_tones("Bb", "Db", "F")
assert chord.identify() == "Bb minor"
def test_flat_chord_from_tones_seventh():
chord = Chord.from_tones("Eb", "G", "Bb", "Db")
assert chord.identify() == "Eb dominant 7th"
def test_system_resolve_name_sharp():
assert SYSTEMS["western"].resolve_name("C#") == "C#"
def test_system_resolve_name_flat():
assert SYSTEMS["western"].resolve_name("Db") == "C#"
def test_system_resolve_name_natural():
assert SYSTEMS["western"].resolve_name("C") == "C"
def test_system_resolve_name_unknown():
assert SYSTEMS["western"].resolve_name("X") is None