Compare commits

...

9 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
kennethreitz 6aad427fb8 Fix 'pytheory play' chord name parsing for names containing digits
Chord names like Cmaj7 and G7 were incorrectly treated as tone names
because they contain digits. Now tries chord name lookup first. v0.5.1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 14:53:12 -04:00
kennethreitz e9c630705e Add 'pytheory play' CLI command for playing notes and chords
Supports single tones and chords, with --synth (sine/saw/triangle),
--duration, and --temperament flags. Bumps version to v0.5.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 14:47:20 -04:00
kennethreitz e78ba203d9 Add Symbolic Pitch section to tones docs
Dedicated section explaining symbolic=True with examples across
all three temperaments, showing exact SymPy expressions, arbitrary
precision evaluation, and why the math reveals temperament differences.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 13:40:20 -04:00
15 changed files with 462 additions and 56 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
~~~~~~~~~~~~~~~~
+58 -9
View File
@@ -125,9 +125,47 @@ same note name:
>>> c5.pitch(temperament="pythagorean")
521.48 # Slightly different!
# Symbolic output (SymPy expression)
Symbolic Pitch
~~~~~~~~~~~~~~
Pass ``symbolic=True`` to get exact pitch ratios as
`SymPy <https://en.wikipedia.org/wiki/SymPy>`_ expressions instead of
floating-point approximations. This is useful for mathematical analysis,
proving tuning relationships, or comparing temperaments with exact
arithmetic.
.. code-block:: python
>>> a4 = Tone.from_string("A4", system="western")
# Equal temperament: irrational ratios (roots of 2)
>>> a4.pitch(symbolic=True)
440
>>> Tone.from_string("C5", system="western").pitch(symbolic=True)
440*2**(1/4)
# Pythagorean: pure rational ratios (powers of 3/2)
>>> Tone.from_string("G4", system="western").pitch(
... temperament="pythagorean", symbolic=True)
660
# Compare the major third across temperaments
>>> e4 = Tone.from_string("E4", system="western")
>>> e4.pitch(temperament="equal", symbolic=True)
440*2**(1/3)
>>> e4.pitch(temperament="pythagorean", symbolic=True)
12160/27
>>> e4.pitch(temperament="meantone", symbolic=True)
550
# Symbolic expressions can be evaluated to any precision
>>> e4.pitch(symbolic=True).evalf(50)
329.62755691286991583007431157433859631791591649985
The symbolic output reveals *why* temperaments differ: equal temperament
uses irrational numbers (roots of 2), Pythagorean uses powers of 3/2
(rational but accumulating error), and meantone tunes thirds to the
pure 5/4 ratio (sacrificing fifths).
Intervals and Arithmetic
-------------------------
@@ -248,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
--------------------
@@ -265,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.4.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]:
+49
View File
@@ -91,6 +91,42 @@ def cmd_progression(args):
print(f" {numeral:6s} {chord}")
def cmd_play(args):
from .tones import Tone
from .chords import Chord
from .play import play, Synth
synth_map = {"sine": Synth.SINE, "saw": Synth.SAW, "triangle": Synth.TRIANGLE}
synth = synth_map[args.synth]
duration = args.duration
# Try chord name first (e.g. "Am", "Cmaj7"), then fall back to individual notes.
if len(args.notes) == 1:
note = args.notes[0]
# Try as chord name first (Am, G7, Cmaj7, etc.)
try:
target = Chord.from_name(note)
name = target.identify() or note
label = f"{name} ({' '.join(t.full_name for t in target.tones)})"
except (ValueError, KeyError):
# Fall back to single tone
target = Tone.from_string(
note if any(c.isdigit() for c in note) else f"{note}4",
system="western")
label = target.full_name
else:
tones = [Tone.from_string(n if any(c.isdigit() for c in n) else f"{n}4",
system="western") for n in args.notes]
target = Chord(tones=tones)
name = target.identify() or "Custom"
label = f"{name} ({' '.join(t.full_name for t in tones)})"
print(f" Playing: {label}")
print(f" Synth: {args.synth}")
print(f" Duration: {duration} ms")
play(target, temperament=args.temperament, synth=synth, t=duration)
def cmd_detect(args):
from .scales import Key
key = Key.detect(*args.notes)
@@ -141,6 +177,18 @@ def main():
p.add_argument("mode", help="Mode (e.g. major, minor)")
p.add_argument("numerals", nargs="+", help="Roman numerals (e.g. I V vi IV)")
# play
p = sub.add_parser("play", help="Play notes or chords (e.g. pytheory play C E G)")
p.add_argument("notes", nargs="+", help="Note names, with optional octave (e.g. C4, A#3, or just C E G)")
p.add_argument("--synth", "-s", default="sine",
choices=["sine", "saw", "triangle"],
help="Waveform (default: sine)")
p.add_argument("--duration", "-d", type=int, default=1000,
help="Duration in milliseconds (default: 1000)")
p.add_argument("--temperament", "-t", default="equal",
choices=["equal", "pythagorean", "meantone"],
help="Tuning temperament (default: equal)")
# detect
p = sub.add_parser("detect", help="Detect key from notes (e.g. pytheory detect C E G)")
p.add_argument("notes", nargs="+", help="Note names")
@@ -157,6 +205,7 @@ def main():
"key": cmd_key,
"fingering": cmd_fingering,
"progression": cmd_progression,
"play": cmd_play,
"detect": cmd_detect,
}
commands[args.command](args)
+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