mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5e47c37cd | |||
| 8a9651f989 | |||
| cc4a25e70d | |||
| 904c61b2d6 | |||
| d23de92713 | |||
| e8bfeb884a | |||
| 6aad427fb8 | |||
| e9c630705e | |||
| e78ba203d9 |
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
----------------------
|
||||
|
||||
|
||||
@@ -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
|
||||
--------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -215,6 +215,35 @@ Some of the most-used chord progressions in Western music:
|
||||
My Heart Will Go On)
|
||||
- **I–IV–vi–V** — 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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user