mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e1d9e76bd | |||
| 447d03a2d2 | |||
| 7b82d70ad6 | |||
| 44f8b902e2 | |||
| 03eb61cd5d | |||
| eba299d406 | |||
| d11c930308 | |||
| 19663ed6c5 | |||
| f949ca5b45 | |||
| d9f847603a | |||
| ee41691728 | |||
| 02df87af09 | |||
| b3110c6e0e | |||
| fd82dccbfd | |||
| 6f7f9008b0 | |||
| acb92171a1 | |||
| c006f5b3da | |||
| 9da3ac8b28 | |||
| e94ef5dcfd | |||
| a5e47c37cd | |||
| 8a9651f989 | |||
| cc4a25e70d | |||
| 904c61b2d6 | |||
| d23de92713 | |||
| e8bfeb884a | |||
| 6aad427fb8 | |||
| e9c630705e | |||
| e78ba203d9 | |||
| c307c1e41f | |||
| b1f6996cd7 | |||
| 51ca98779d | |||
| 37b41e1bbf | |||
| da40189845 |
@@ -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
|
||||
@@ -127,6 +146,22 @@ $ pip install pytheory
|
||||
>>> tone = Tone.from_string("A4", system="western")
|
||||
>>> play(tone, t=1_000) # sine wave, 1 second
|
||||
>>> play(tone, synth=Synth.SAW, t=1_000) # sawtooth wave
|
||||
|
||||
>>> from pytheory import save, Chord
|
||||
>>> save(Chord.from_name("Am7"), "am7.wav", t=2_000) # save to WAV
|
||||
```
|
||||
|
||||
## Command-Line Interface
|
||||
|
||||
```
|
||||
$ pytheory tone A4 # frequency, MIDI, overtones
|
||||
$ pytheory chord C E G # identify chord from notes
|
||||
$ pytheory key G major # explore a key
|
||||
$ pytheory scale C dorian # show a scale
|
||||
$ pytheory fingering Am --capo 2 # guitar fingering
|
||||
$ pytheory progression C major I V vi IV # build a progression
|
||||
$ pytheory detect C E G A D # detect key from notes
|
||||
$ pytheory play Am7 --synth triangle # play a chord
|
||||
```
|
||||
|
||||
## Features
|
||||
@@ -138,7 +173,7 @@ $ pip install pytheory
|
||||
- **25 instrument presets**: guitar (8 tunings), 12-string, bass, mandolin family, violin family, banjo, harp, oud, sitar, shamisen, erhu, charango, pipa, balalaika, lute, pedal steel, keyboard
|
||||
- **Pitch tools**: frequency ↔ tone conversion, MIDI ↔ tone, interval naming, circle of fifths, overtone series, transposition
|
||||
- **3 temperaments**: equal, Pythagorean, quarter-comma meantone
|
||||
- **Audio synthesis**: sine, sawtooth, and triangle wave playback
|
||||
- **Audio synthesis**: sine, sawtooth, and triangle wave playback + WAV export
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
+10
-1
@@ -10,7 +10,9 @@ sys.modules["sounddevice"] = MagicMock()
|
||||
project = "PyTheory"
|
||||
copyright = "2026, Kenneth Reitz"
|
||||
author = "Kenneth Reitz"
|
||||
release = "0.3.2"
|
||||
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"]
|
||||
|
||||
+95
-17
@@ -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"),
|
||||
@@ -238,6 +258,39 @@ you hear a pulsing at the **beat frequency**: ``|f1 - f2|`` Hz.
|
||||
# The slowest (most perceptible) beat
|
||||
chord.beat_pulse # 189.6 Hz
|
||||
|
||||
Transposition
|
||||
-------------
|
||||
|
||||
Shift an entire chord up or down by any number of semitones:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
>>> Chord.from_name("C").transpose(7).identify()
|
||||
'G major'
|
||||
|
||||
>>> Chord.from_name("Am7").transpose(-2).identify()
|
||||
'G minor 7th'
|
||||
|
||||
Chord Manipulation
|
||||
------------------
|
||||
|
||||
Add or remove individual tones from a chord:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pytheory import Chord, Tone
|
||||
|
||||
c_major = Chord.from_tones("C", "E", "G")
|
||||
|
||||
# Add a tone to build a seventh chord
|
||||
b4 = Tone.from_string("B4", system="western")
|
||||
cmaj7 = c_major.add_tone(b4)
|
||||
cmaj7.identify() # 'C major 7th'
|
||||
|
||||
# Remove a tone
|
||||
c_again = cmaj7.remove_tone("B")
|
||||
c_again.identify() # 'C major'
|
||||
|
||||
Chord Identification
|
||||
--------------------
|
||||
|
||||
@@ -247,23 +300,25 @@ against 17 known chord types (triads, 7ths, 9ths, sus, power chords).
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pytheory import Chord, Tone
|
||||
from pytheory import Chord
|
||||
|
||||
# Build a chord and identify it
|
||||
chord = Chord([
|
||||
Tone.from_string("A4", system="western"),
|
||||
Tone.from_string("C5", system="western"),
|
||||
Tone.from_string("E5", system="western"),
|
||||
])
|
||||
chord.identify() # 'A minor'
|
||||
# From note names
|
||||
Chord.from_tones("A", "C", "E").identify() # 'A minor'
|
||||
Chord.from_tones("G", "B", "D", "F").identify() # 'G dominant 7th'
|
||||
|
||||
# Works with any voicing or inversion
|
||||
chord2 = Chord([
|
||||
Tone.from_string("E4", system="western"),
|
||||
Tone.from_string("G4", system="western"),
|
||||
Tone.from_string("C5", system="western"),
|
||||
])
|
||||
chord2.identify() # 'C major' (first inversion detected)
|
||||
Chord.from_tones("E", "G", "C").identify() # 'C major'
|
||||
|
||||
# Flats work too
|
||||
Chord.from_tones("Bb", "D", "F").identify() # 'Bb major'
|
||||
|
||||
You can also access the root and quality separately:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
chord = Chord.from_name("Am7")
|
||||
chord.root # <Tone A4>
|
||||
chord.quality # 'minor 7th'
|
||||
|
||||
Harmonic Analysis
|
||||
-----------------
|
||||
@@ -334,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
|
||||
-------------------
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
Command-Line Interface
|
||||
======================
|
||||
|
||||
PyTheory includes a CLI for quick music theory lookups from the terminal.
|
||||
|
||||
Tone Lookup
|
||||
-----------
|
||||
|
||||
Look up any note's frequency, MIDI number, enharmonic spelling, and
|
||||
overtones::
|
||||
|
||||
$ pytheory tone A4
|
||||
Note: A4
|
||||
Frequency: 440.00 Hz (equal temperament)
|
||||
MIDI: 69
|
||||
Overtones: 440.0, 880.0, 1320.0, 1760.0, 2200.0, 2640.0
|
||||
|
||||
Compare temperaments with ``--temperament``::
|
||||
|
||||
$ pytheory tone C5 --temperament pythagorean
|
||||
Note: C5
|
||||
Frequency: 521.48 Hz (pythagorean temperament)
|
||||
Equal temp: 523.25 Hz (diff: -5.9 cents)
|
||||
|
||||
Scale Display
|
||||
-------------
|
||||
|
||||
Show any scale in any system::
|
||||
|
||||
$ pytheory scale C major
|
||||
C major: C D E F G A B C
|
||||
Intervals: C4 -2- D4 -2- E4 -1- F4 -2- G4 -2- A4 -2- B4 -1- C5
|
||||
|
||||
$ pytheory scale C dorian
|
||||
$ pytheory scale Sa bhairav --system indian
|
||||
|
||||
Chord Identification
|
||||
--------------------
|
||||
|
||||
Identify a chord from its notes::
|
||||
|
||||
$ pytheory chord C E G
|
||||
Chord: C major
|
||||
Tones: C4 E4 G4
|
||||
Intervals: [4, 3]
|
||||
Harmony: 0.5833
|
||||
Dissonance: 0.0712
|
||||
Tension: 0.00 (tritones=0)
|
||||
|
||||
$ pytheory chord G B D F
|
||||
Chord: G dominant 7th
|
||||
|
||||
Key Explorer
|
||||
------------
|
||||
|
||||
Get a complete breakdown of any key — signature, diatonic triads,
|
||||
seventh chords, relative and parallel keys::
|
||||
|
||||
$ pytheory key G major
|
||||
Key: G major
|
||||
Signature: 1 sharps, 0 flats (F#)
|
||||
Scale: G A B C D E F#
|
||||
Triads:
|
||||
I G major
|
||||
ii A minor
|
||||
iii B minor
|
||||
IV C major
|
||||
V D major
|
||||
vi E minor
|
||||
vii° F# diminished
|
||||
7th chords:
|
||||
G major 7th
|
||||
A minor 7th
|
||||
...
|
||||
Relative: <Key E minor>
|
||||
Parallel: <Key G minor>
|
||||
|
||||
Guitar Fingerings
|
||||
-----------------
|
||||
|
||||
Get tablature for any of the 144 built-in chords::
|
||||
|
||||
$ pytheory fingering Am
|
||||
Am
|
||||
E|--0--
|
||||
B|--1--
|
||||
G|--2--
|
||||
D|--2--
|
||||
A|--0--
|
||||
E|--0--
|
||||
|
||||
Use ``--capo`` to see fingerings with a capo::
|
||||
|
||||
$ pytheory fingering G --capo 2
|
||||
|
||||
Chord Progressions
|
||||
------------------
|
||||
|
||||
Build progressions from Roman numerals::
|
||||
|
||||
$ pytheory progression G major I V vi IV
|
||||
Key: G major
|
||||
Progression: I → V → vi → IV
|
||||
|
||||
I G major
|
||||
V D major
|
||||
vi E minor
|
||||
IV C major
|
||||
|
||||
Key Detection
|
||||
-------------
|
||||
|
||||
Detect the most likely key from a set of notes::
|
||||
|
||||
$ pytheory detect C E G A D
|
||||
Detected key: C major
|
||||
Scale: C D E F G A B C
|
||||
|
||||
Audio Playback
|
||||
--------------
|
||||
|
||||
Play individual notes or chords (requires PortAudio)::
|
||||
|
||||
$ pytheory play A4 # Single note
|
||||
$ pytheory play C E G # Notes as chord
|
||||
$ pytheory play Am7 # Chord by name
|
||||
$ pytheory play C E G --synth saw # Sawtooth wave
|
||||
$ pytheory play A4 --duration 2000 # 2 seconds
|
||||
$ pytheory play C E G --temperament meantone
|
||||
+67
-14
@@ -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,29 +185,53 @@ 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)
|
||||
|
||||
# Best single fingering
|
||||
print(c.fingering(fretboard=fb))
|
||||
# (0, 1, 0, 2, 3, 0)
|
||||
>>> f['A']
|
||||
3
|
||||
>>> f[1]
|
||||
1
|
||||
|
||||
# All equally-scored fingerings
|
||||
all_c = c.fingering(fretboard=fb, multiple=True)
|
||||
>>> f.identify()
|
||||
'C major'
|
||||
|
||||
# Muted strings appear as None
|
||||
f = CHARTS["western"]["F"]
|
||||
print(f.fingering(fretboard=fb))
|
||||
>>> chord = f.to_chord()
|
||||
>>> chord.identify()
|
||||
'C major'
|
||||
|
||||
>>> # All equally-scored fingerings via CHARTS
|
||||
>>> CHARTS["western"]["C"].fingering(fretboard=fb, multiple=True)
|
||||
[...]
|
||||
|
||||
>>> # Muted strings appear as None
|
||||
>>> CHARTS["western"]["F"].fingering(fretboard=fb)
|
||||
...
|
||||
|
||||
You can also go from fret positions to chord identification:
|
||||
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> # "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
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
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 +242,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
|
||||
----------------------
|
||||
|
||||
|
||||
+26
-7
@@ -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
|
||||
--------------
|
||||
@@ -78,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)
|
||||
|
||||
+157
-26
@@ -8,42 +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 tones — sharps 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
|
||||
fb = Fretboard.guitar()
|
||||
fingering = CHARTS["western"]["Am"].fingering(fretboard=fb)
|
||||
print(fingering) # (0, 1, 2, 2, 0, 0)
|
||||
c["dorian"].note_names
|
||||
# ['C', 'D', 'D#', 'F', 'G', 'A', 'A#', 'C']
|
||||
|
||||
# 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
|
||||
---------------
|
||||
@@ -54,9 +181,13 @@ 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
|
||||
- **WAV export** for saving rendered audio to disk
|
||||
|
||||
+121
-14
@@ -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
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -245,23 +274,101 @@ structure. In the key of A::
|
||||
# The 12-bar blues progression
|
||||
blues_12 = [I, I, I, I, IV, IV, I, I, V, IV, I, V]
|
||||
|
||||
Parallel Major and Minor
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Key Signatures
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
Two scales are **relative** if they share the same notes (C major and
|
||||
A minor). Two scales are `parallel <https://en.wikipedia.org/wiki/Parallel_key>`_ if they share the same tonic but
|
||||
have different notes (C major and C minor).
|
||||
The ``signature`` property tells you how many sharps or flats a key has:
|
||||
|
||||
Mixing parallel major and minor is a powerful compositional tool —
|
||||
borrowing chords from the parallel minor in a major key creates
|
||||
dramatic color shifts. The bVI and bVII chords (Ab and Bb in C major)
|
||||
are borrowed from C minor and appear constantly in rock and film music.
|
||||
.. code-block:: python
|
||||
|
||||
>>> Key("G", "major").signature
|
||||
{'sharps': 1, 'flats': 0, 'accidentals': ['F#']}
|
||||
|
||||
>>> Key("F", "major").signature
|
||||
{'sharps': 0, 'flats': 1, 'accidentals': ['Bb']}
|
||||
|
||||
>>> Key("C", "major").signature
|
||||
{'sharps': 0, 'flats': 0, 'accidentals': []}
|
||||
|
||||
Relative and Parallel Keys
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Two keys are **relative** if they share the same notes (C major and
|
||||
A minor). Two keys are `parallel <https://en.wikipedia.org/wiki/Parallel_key>`_ if they share the same tonic but
|
||||
have different notes (C major and C minor):
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
>>> Key("C", "major").relative
|
||||
<Key A minor>
|
||||
|
||||
>>> Key("A", "minor").relative
|
||||
<Key C major>
|
||||
|
||||
>>> Key("C", "major").parallel
|
||||
<Key C minor>
|
||||
|
||||
Borrowed Chords
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
`Modal interchange <https://en.wikipedia.org/wiki/Borrowed_chord>`_ —
|
||||
borrowing chords from the parallel key — is one of the most powerful
|
||||
tools in songwriting. The bVI and bVII chords (Ab and Bb in C major)
|
||||
are borrowed from C minor and appear constantly in rock and film music:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
>>> Key("C", "major").borrowed_chords
|
||||
# Chords from C minor that aren't in C major
|
||||
|
||||
Secondary Dominants
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
A `secondary dominant <https://en.wikipedia.org/wiki/Secondary_dominant>`_
|
||||
is the V chord *of* a non-tonic chord. It creates a momentary pull
|
||||
toward that chord, adding harmonic color:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
key = Key("C", "major")
|
||||
|
||||
# V/V — the dominant of the dominant (D7 → G)
|
||||
key.secondary_dominant(5) # D dominant 7th
|
||||
|
||||
# V/ii — the dominant of the supertonic (A7 → Dm)
|
||||
key.secondary_dominant(2) # A dominant 7th
|
||||
|
||||
Random Progressions
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Need inspiration? Generate weighted random progressions. The weights
|
||||
favor common chord functions (I and vi most likely, vii least):
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
key = Key("C", "major")
|
||||
chords = key.random_progression(4) # 4 chords
|
||||
[c.identify() for c in chords]
|
||||
# e.g. ['C major', 'F major', 'A minor', 'G major']
|
||||
|
||||
All Keys
|
||||
~~~~~~~~
|
||||
|
||||
Enumerate all 24 major and minor keys:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
>>> Key.all_keys()
|
||||
[<Key C major>, <Key C minor>, <Key C# major>, <Key C# minor>, ...]
|
||||
|
||||
Scale Transposition
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Transpose an entire scale by a number of semitones:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
c_major = TonedScale(tonic="C4")["major"]
|
||||
c_minor = TonedScale(tonic="C4")["minor"]
|
||||
|
||||
# Compare: same tonic, different notes
|
||||
c_major.note_names # ['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
|
||||
c_minor.note_names # ['C', 'D', 'D#', 'F', 'G', 'G#', 'A#', 'C']
|
||||
d_major = c_major.transpose(2) # Up a whole step
|
||||
d_major.note_names
|
||||
# ['D', 'E', 'F#', 'G', 'A', 'B', 'C#', 'D']
|
||||
|
||||
+127
-13
@@ -44,9 +44,10 @@ Creating Tones
|
||||
|
||||
from pytheory import Tone
|
||||
|
||||
# From a string (most common)
|
||||
# From a string (most common) — sharps and flats both work
|
||||
c4 = Tone.from_string("C4")
|
||||
cs4 = Tone.from_string("C#4")
|
||||
db4 = Tone.from_string("Db4") # Same pitch as C#4
|
||||
|
||||
# Direct construction
|
||||
d = Tone(name="D", octave=3)
|
||||
@@ -54,20 +55,32 @@ Creating Tones
|
||||
# With a specific system
|
||||
a4 = Tone.from_string("A4", system="western")
|
||||
|
||||
# From a frequency (finds the nearest note)
|
||||
Tone.from_frequency(440) # <Tone A4>
|
||||
Tone.from_frequency(261.63) # <Tone C4>
|
||||
|
||||
# From a MIDI note number
|
||||
Tone.from_midi(60) # <Tone C4> (middle C)
|
||||
Tone.from_midi(69) # <Tone A4>
|
||||
|
||||
Properties
|
||||
----------
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
>>> c4 = Tone.from_string("C4")
|
||||
>>> c4 = Tone.from_string("C4", system="western")
|
||||
>>> c4.name
|
||||
'C'
|
||||
>>> c4.octave
|
||||
4
|
||||
>>> c4.full_name
|
||||
'C4'
|
||||
>>> str(c4)
|
||||
'C4'
|
||||
>>> c4.letter # Note letter without accidentals
|
||||
'C'
|
||||
>>> c4.midi # MIDI note number
|
||||
60
|
||||
>>> c4.exists # Is this note in the system?
|
||||
True
|
||||
|
||||
Pitch and Frequency
|
||||
-------------------
|
||||
@@ -125,9 +138,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
|
||||
-------------------------
|
||||
@@ -178,6 +229,58 @@ Subtracting two tones gives the semitone distance:
|
||||
>>> c5 - c4 # Octave = 12 semitones
|
||||
12
|
||||
|
||||
Naming Intervals
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
The ``interval_to`` method gives the musical name of the interval
|
||||
between two tones, including compound intervals that span more than
|
||||
one octave:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
>>> c4.interval_to(g4)
|
||||
'perfect 5th'
|
||||
>>> c4.interval_to(c4 + 4)
|
||||
'major 3rd'
|
||||
>>> c4.interval_to(c5)
|
||||
'octave'
|
||||
|
||||
# Compound intervals (more than an octave)
|
||||
>>> c4.interval_to(c4 + 19) # Octave + perfect 5th
|
||||
'perfect 5th + 1 octave'
|
||||
|
||||
Transposition
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
The ``transpose`` method returns a new tone shifted by a number of
|
||||
semitones — equivalent to the ``+`` operator but reads more clearly
|
||||
in some contexts:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
>>> c4.transpose(7) # Same as c4 + 7
|
||||
<Tone G4>
|
||||
>>> c4.transpose(-2) # Two semitones down
|
||||
<Tone A#3>
|
||||
|
||||
MIDI
|
||||
~~~~
|
||||
|
||||
Every tone maps to a `MIDI note number <https://en.wikipedia.org/wiki/MIDI>`_
|
||||
(0–127), the standard for communicating with synthesizers, DAWs, and
|
||||
digital instruments:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
>>> c4.midi
|
||||
60 # Middle C
|
||||
>>> Tone.from_string("A4", system="western").midi
|
||||
69 # Concert A
|
||||
|
||||
# Round-trip: MIDI → Tone → MIDI
|
||||
>>> Tone.from_midi(60).midi
|
||||
60
|
||||
|
||||
Comparison and Sorting
|
||||
----------------------
|
||||
|
||||
@@ -248,12 +351,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 +375,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.
|
||||
|
||||
+59
-15
@@ -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
|
||||
@@ -34,6 +77,7 @@ Work with tones, scales, chords, and fretboards using a clean, Pythonic API.
|
||||
guide/fretboard
|
||||
guide/systems
|
||||
guide/playback
|
||||
guide/cli
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Identify chords from notes or guitar fingerings."""
|
||||
|
||||
from pytheory import Chord, Fretboard
|
||||
|
||||
print("=== Chord Identification from Notes ===")
|
||||
print()
|
||||
|
||||
test_chords = [
|
||||
("C", "E", "G"),
|
||||
("A", "C", "E"),
|
||||
("G", "B", "D", "F"),
|
||||
("D", "F#", "A"),
|
||||
("Bb", "D", "F"),
|
||||
("E", "G#", "B"),
|
||||
("C", "Eb", "Gb"),
|
||||
("C", "G"),
|
||||
("C", "F", "G"),
|
||||
("C", "D", "G"),
|
||||
]
|
||||
|
||||
for notes in test_chords:
|
||||
chord = Chord.from_tones(*notes)
|
||||
name = chord.identify() or "Unknown"
|
||||
print(f" {', '.join(notes):20s} → {name}")
|
||||
|
||||
print()
|
||||
print("=== Chord Identification from Guitar Fingerings ===")
|
||||
print()
|
||||
|
||||
fb = Fretboard.guitar()
|
||||
|
||||
# Common guitar chord shapes
|
||||
shapes = [
|
||||
("Open C", (0, 1, 0, 2, 3, 0)),
|
||||
("Open G", (3, 0, 0, 0, 2, 3)),
|
||||
("Open D", (2, 3, 2, 0, 0, 0)),
|
||||
("Open Am", (0, 1, 2, 2, 0, 0)),
|
||||
("Open Em", (0, 0, 0, 2, 2, 0)),
|
||||
("Barre F", (1, 1, 2, 3, 3, 1)),
|
||||
("Power E5", (0, 0, 0, 0, 2, 0)),
|
||||
]
|
||||
|
||||
for label, positions in shapes:
|
||||
f = fb.fingering(*positions)
|
||||
name = f.identify() or "Unknown"
|
||||
print(f" {label:12s} {f} → {name}")
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Analyze harmonic tension and resolution across chords."""
|
||||
|
||||
from pytheory import Chord
|
||||
|
||||
print("Chord Tension Analysis")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print(f"{'Chord':>20s} {'Tension':>8s} {'Harmony':>8s} {'Dissonance':>11s} {'Notes'}")
|
||||
print(f"{'─' * 20} {'─' * 8} {'─' * 8} {'─' * 11} {'─' * 15}")
|
||||
|
||||
chords = [
|
||||
# Stable chords
|
||||
"C", "Am",
|
||||
# Moderate tension
|
||||
"Dm7", "Cmaj7",
|
||||
# High tension
|
||||
"G7", "Bdim",
|
||||
# Extended
|
||||
"Am7", "Cmaj9",
|
||||
]
|
||||
|
||||
for name in chords:
|
||||
chord = Chord.from_name(name)
|
||||
t = chord.tension
|
||||
tones = " ".join(tone.name for tone in chord.tones)
|
||||
print(
|
||||
f"{name:>20s} {t['score']:>8.2f} {chord.harmony:>8.4f}"
|
||||
f" {chord.dissonance:>11.4f} {tones}"
|
||||
)
|
||||
|
||||
# Show the V7 → I resolution
|
||||
print()
|
||||
print("─" * 70)
|
||||
print()
|
||||
print("The V7 → I resolution (the strongest pull in tonal music):")
|
||||
print()
|
||||
|
||||
g7 = Chord.from_name("G7")
|
||||
c = Chord.from_name("C")
|
||||
|
||||
print(f" G7 (dominant): tension={g7.tension['score']:.2f} "
|
||||
f"tritones={g7.tension['tritones']} "
|
||||
f"dominant_function={g7.tension['has_dominant_function']}")
|
||||
print(f" C (tonic): tension={c.tension['score']:.2f} "
|
||||
f"tritones={c.tension['tritones']} "
|
||||
f"dominant_function={c.tension['has_dominant_function']}")
|
||||
|
||||
print()
|
||||
print("Voice leading (G7 → C):")
|
||||
for src, dst, motion in g7.voice_leading(c):
|
||||
direction = "↑" if motion > 0 else "↓" if motion < 0 else "="
|
||||
print(f" {src.name:3s} → {dst.name:3s} ({direction} {abs(motion)} semitones)")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Visualize the circle of fifths with key signatures."""
|
||||
|
||||
from pytheory import Tone, Key
|
||||
|
||||
c = Tone.from_string("C4", system="western")
|
||||
|
||||
print("╔══════════════════════════════════════════════╗")
|
||||
print("║ THE CIRCLE OF FIFTHS ║")
|
||||
print("╠══════════════════════════════════════════════╣")
|
||||
print("║ Key Sig Accidentals ║")
|
||||
print("╠══════════════════════════════════════════════╣")
|
||||
|
||||
for tone in c.circle_of_fifths():
|
||||
key = Key(tone.name, "major")
|
||||
sig = key.signature
|
||||
relative = key.relative
|
||||
|
||||
if sig["sharps"]:
|
||||
mark = f'{sig["sharps"]}#'
|
||||
elif sig["flats"]:
|
||||
mark = f'{sig["flats"]}b'
|
||||
else:
|
||||
mark = "--"
|
||||
|
||||
accidentals = ", ".join(sig["accidentals"]) if sig["accidentals"] else "none"
|
||||
print(f"║ {tone.name:3s} {mark:3s} {accidentals:20s} rel: {relative.tonic_name} {relative.mode:5s} ║")
|
||||
|
||||
print("╚══════════════════════════════════════════════╝")
|
||||
|
||||
# Show that 12 fifths returns to the start
|
||||
print()
|
||||
print("Proof: 12 perfect fifths cycle through all 12 tones")
|
||||
names = [t.name for t in c.circle_of_fifths()]
|
||||
print(f" {' → '.join(names)} → {names[0]}")
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Explore instruments, tunings, and chord fingerings."""
|
||||
|
||||
from pytheory import Fretboard, CHARTS
|
||||
|
||||
# ── Compare Instruments ─────────────────────────────────────────────────
|
||||
|
||||
print("Instrument Tunings")
|
||||
print("=" * 55)
|
||||
|
||||
instruments = [
|
||||
("Guitar (standard)", Fretboard.guitar()),
|
||||
("Guitar (drop D)", Fretboard.guitar("drop d")),
|
||||
("Guitar (open G)", Fretboard.guitar("open g")),
|
||||
("Guitar (DADGAD)", Fretboard.guitar("dadgad")),
|
||||
("Bass", Fretboard.bass()),
|
||||
("Ukulele", Fretboard.ukulele()),
|
||||
("Mandolin", Fretboard.mandolin()),
|
||||
("Violin", Fretboard.violin()),
|
||||
("Banjo", Fretboard.banjo()),
|
||||
("Bouzouki (Irish)", Fretboard.bouzouki()),
|
||||
]
|
||||
|
||||
for name, fb in instruments:
|
||||
tuning = " ".join(t.full_name for t in fb.tones)
|
||||
print(f" {name:22s} {tuning}")
|
||||
|
||||
# ── Guitar Chord Chart ──────────────────────────────────────────────────
|
||||
|
||||
print()
|
||||
print("Guitar Chord Chart (standard tuning)")
|
||||
print("=" * 55)
|
||||
|
||||
fb = Fretboard.guitar()
|
||||
chart = CHARTS["western"]
|
||||
|
||||
for chord_name in ["C", "G", "D", "Am", "Em", "F", "A", "E", "Dm", "G7", "C7", "Am7"]:
|
||||
f = chart[chord_name].fingering(fretboard=fb)
|
||||
print(f" {chord_name:5s} {f}")
|
||||
|
||||
# ── Capo Magic ──────────────────────────────────────────────────────────
|
||||
|
||||
print()
|
||||
print("Capo Transposition")
|
||||
print("=" * 55)
|
||||
print(" Playing open chord shapes with a capo changes the key:")
|
||||
print()
|
||||
|
||||
open_shapes = ["C", "G", "D", "Am", "Em"]
|
||||
|
||||
for capo_fret in range(1, 6):
|
||||
fb_capo = Fretboard.guitar(capo=capo_fret)
|
||||
results = []
|
||||
for shape in open_shapes:
|
||||
f = chart[shape].fingering(fretboard=fb_capo)
|
||||
actual = f.identify() or "?"
|
||||
results.append(f"{shape}→{actual.split()[0]}")
|
||||
print(f" Capo {capo_fret}: {', '.join(results)}")
|
||||
|
||||
# ── Same Chord on Different Instruments ─────────────────────────────────
|
||||
|
||||
print()
|
||||
print("C Major on Different Instruments")
|
||||
print("=" * 55)
|
||||
|
||||
c_chord = chart["C"]
|
||||
for name, fb in [("Guitar", Fretboard.guitar()),
|
||||
("Ukulele", Fretboard.ukulele()),
|
||||
("Mandolin", Fretboard.mandolin()),
|
||||
("Banjo", Fretboard.banjo())]:
|
||||
try:
|
||||
f = c_chord.fingering(fretboard=fb)
|
||||
print(f" {name:12s} {f}")
|
||||
except Exception:
|
||||
print(f" {name:12s} (not available for this tuning)")
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Learn intervals — names, sounds, and relationships."""
|
||||
|
||||
from pytheory import Tone, Chord, Interval
|
||||
|
||||
c4 = Tone.from_string("C4", system="western")
|
||||
|
||||
# ── Interval Reference ──────────────────────────────────────────────────
|
||||
|
||||
print("Interval Reference (from C4)")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print(f"{'Semitones':>10s} {'Note':>5s} {'Interval Name':>18s} {'Sound / Song'}")
|
||||
print(f"{'─' * 10} {'─' * 5} {'─' * 18} {'─' * 30}")
|
||||
|
||||
songs = {
|
||||
0: "Same note",
|
||||
1: "Jaws",
|
||||
2: "Happy Birthday",
|
||||
3: "Greensleeves",
|
||||
4: "Here Comes the Sun",
|
||||
5: "Here Comes the Bride",
|
||||
6: "The Simpsons",
|
||||
7: "Star Wars (main theme)",
|
||||
8: "Love Story",
|
||||
9: "My Bonnie Lies Over the Ocean",
|
||||
10: "Somewhere (West Side Story)",
|
||||
11: "Take On Me (chorus)",
|
||||
12: "Somewhere Over the Rainbow",
|
||||
}
|
||||
|
||||
for semitones in range(13):
|
||||
tone = c4 + semitones
|
||||
name = c4.interval_to(tone)
|
||||
song = songs.get(semitones, "")
|
||||
print(f"{semitones:>10d} {tone.name:>5s} {name:>18s} {song}")
|
||||
|
||||
# ── Interval Constants ──────────────────────────────────────────────────
|
||||
|
||||
print()
|
||||
print("Interval Constants (pytheory.Interval)")
|
||||
print("=" * 40)
|
||||
|
||||
constants = [
|
||||
("UNISON", Interval.UNISON),
|
||||
("MINOR_SECOND", Interval.MINOR_SECOND),
|
||||
("MAJOR_SECOND", Interval.MAJOR_SECOND),
|
||||
("MINOR_THIRD", Interval.MINOR_THIRD),
|
||||
("MAJOR_THIRD", Interval.MAJOR_THIRD),
|
||||
("PERFECT_FOURTH", Interval.PERFECT_FOURTH),
|
||||
("TRITONE", Interval.TRITONE),
|
||||
("PERFECT_FIFTH", Interval.PERFECT_FIFTH),
|
||||
("MINOR_SIXTH", Interval.MINOR_SIXTH),
|
||||
("MAJOR_SIXTH", Interval.MAJOR_SIXTH),
|
||||
("MINOR_SEVENTH", Interval.MINOR_SEVENTH),
|
||||
("MAJOR_SEVENTH", Interval.MAJOR_SEVENTH),
|
||||
("OCTAVE", Interval.OCTAVE),
|
||||
]
|
||||
|
||||
for name, value in constants:
|
||||
print(f" Interval.{name:16s} = {value}")
|
||||
|
||||
# ── Compound Intervals ─────────────────────────────────────────────────
|
||||
|
||||
print()
|
||||
print("Compound Intervals (beyond one octave)")
|
||||
print("=" * 50)
|
||||
|
||||
for semitones in [13, 14, 15, 16, 19, 24]:
|
||||
tone = c4 + semitones
|
||||
name = c4.interval_to(tone)
|
||||
print(f" {semitones:2d} semitones {tone.full_name:5s} {name}")
|
||||
|
||||
# ── Consonance Ranking ──────────────────────────────────────────────────
|
||||
|
||||
print()
|
||||
print("Intervals Ranked by Consonance")
|
||||
print("=" * 50)
|
||||
|
||||
intervals = []
|
||||
for semitones in range(1, 13):
|
||||
tone = c4 + semitones
|
||||
dyad = Chord.from_tones("C", tone.name)
|
||||
name = c4.interval_to(tone)
|
||||
intervals.append((dyad.harmony, dyad.dissonance, semitones, name))
|
||||
|
||||
# Sort by harmony score (descending)
|
||||
intervals.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
print(f"{'Rank':>5s} {'Interval':>18s} {'Harmony':>8s} {'Dissonance':>11s}")
|
||||
print(f"{'─' * 5} {'─' * 18} {'─' * 8} {'─' * 11}")
|
||||
|
||||
for rank, (harmony, dissonance, _, name) in enumerate(intervals, 1):
|
||||
print(f"{rank:>5d} {name:>18s} {harmony:>8.4f} {dissonance:>11.4f}")
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Detect the key of a melody or chord progression."""
|
||||
|
||||
from pytheory import Key, Chord
|
||||
|
||||
print("Key Detection")
|
||||
print("=" * 55)
|
||||
print()
|
||||
|
||||
# ── Detect from Melody Notes ────────────────────────────────────────────
|
||||
|
||||
melodies = [
|
||||
("Twinkle Twinkle", ["C", "G", "A", "F", "E", "D"]),
|
||||
("Happy Birthday", ["G", "A", "B", "C", "D", "F#"]),
|
||||
("Yesterday", ["F", "E", "D", "C", "Bb", "A", "G"]),
|
||||
("Minor melody", ["A", "B", "C", "D", "E", "F", "G"]),
|
||||
("Blues lick", ["E", "G", "A", "B", "D"]),
|
||||
("Chromatic fragment", ["C", "C#", "D", "D#", "E"]),
|
||||
]
|
||||
|
||||
print("Detecting key from melody notes:")
|
||||
print()
|
||||
for label, notes in melodies:
|
||||
key = Key.detect(*notes)
|
||||
print(f" {label:22s} {', '.join(notes):30s} → {key}")
|
||||
|
||||
# ── Detect from Chord Progression ──────────────────────────────────────
|
||||
|
||||
print()
|
||||
print("Detecting key from chord tones:")
|
||||
print()
|
||||
|
||||
progressions = [
|
||||
("I-IV-V", [("C", "E", "G"), ("F", "A", "C"), ("G", "B", "D")]),
|
||||
("Pop in G", [("G", "B", "D"), ("D", "F#", "A"), ("E", "G", "B"), ("C", "E", "G")]),
|
||||
("Jazz ii-V-I", [("D", "F", "A"), ("G", "B", "D", "F"), ("C", "E", "G", "B")]),
|
||||
]
|
||||
|
||||
for label, chord_tones in progressions:
|
||||
# Collect all unique note names
|
||||
all_notes = set()
|
||||
for tones in chord_tones:
|
||||
all_notes.update(tones)
|
||||
|
||||
key = Key.detect(*all_notes)
|
||||
chord_names = [Chord.from_tones(*t).identify() for t in chord_tones]
|
||||
print(f" {label:15s} {' → '.join(chord_names):40s} → {key}")
|
||||
|
||||
# ── All 24 Keys ─────────────────────────────────────────────────────────
|
||||
|
||||
print()
|
||||
print("All 24 Major and Minor Keys")
|
||||
print("=" * 55)
|
||||
print()
|
||||
|
||||
for key in Key.all_keys():
|
||||
sig = key.signature
|
||||
acc = ", ".join(sig["accidentals"]) if sig["accidentals"] else "none"
|
||||
rel = key.relative
|
||||
print(
|
||||
f" {str(key):12s} "
|
||||
f"{sig['sharps']}# {sig['flats']}b "
|
||||
f"({acc:15s}) "
|
||||
f"rel: {rel}"
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Explore a key — its chords, progressions, and relationships."""
|
||||
|
||||
from pytheory import Key
|
||||
|
||||
def explore_key(tonic, mode="major"):
|
||||
key = Key(tonic, mode)
|
||||
sig = key.signature
|
||||
acc = ", ".join(sig["accidentals"]) or "none"
|
||||
|
||||
print(f"{'=' * 60}")
|
||||
print(f" {key}")
|
||||
print(f"{'=' * 60}")
|
||||
print()
|
||||
print(f" Scale: {' '.join(key.note_names)}")
|
||||
print(f" Signature: {sig['sharps']} sharps, {sig['flats']} flats ({acc})")
|
||||
print(f" Relative: {key.relative}")
|
||||
print(f" Parallel: {key.parallel}")
|
||||
print()
|
||||
|
||||
# Diatonic triads
|
||||
print(" Diatonic Triads:")
|
||||
for chord in key.scale.harmonize():
|
||||
numeral = chord.analyze(tonic, mode) or "?"
|
||||
print(f" {numeral:6s} {chord.identify()}")
|
||||
print()
|
||||
|
||||
# Seventh chords
|
||||
print(" Seventh Chords:")
|
||||
for name in key.seventh_chords:
|
||||
print(f" {name}")
|
||||
print()
|
||||
|
||||
# Common progressions
|
||||
print(" Common Progressions:")
|
||||
progressions = {
|
||||
"Pop": ("I", "V", "vi", "IV"),
|
||||
"Blues": ("I", "IV", "V"),
|
||||
"50s": ("I", "vi", "IV", "V"),
|
||||
"Jazz": ("ii", "V", "I"),
|
||||
}
|
||||
for label, numerals in progressions.items():
|
||||
chords = key.progression(*numerals)
|
||||
names = [c.identify() for c in chords]
|
||||
print(f" {label:8s} {' → '.join(numerals):20s} {' → '.join(names)}")
|
||||
print()
|
||||
|
||||
# Borrowed chords
|
||||
borrowed = key.borrowed_chords
|
||||
if borrowed:
|
||||
print(f" Borrowed from {key.parallel}:")
|
||||
for name in borrowed[:4]:
|
||||
print(f" {name}")
|
||||
print()
|
||||
|
||||
|
||||
# Explore several keys
|
||||
for tonic, mode in [("C", "major"), ("G", "major"), ("A", "minor"), ("E", "major")]:
|
||||
explore_key(tonic, mode)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Convert between MIDI note numbers, frequencies, and note names."""
|
||||
|
||||
from pytheory import Tone
|
||||
|
||||
print("MIDI ↔ Note ↔ Frequency Reference")
|
||||
print("=" * 50)
|
||||
print()
|
||||
print(f"{'MIDI':>5s} {'Note':>5s} {'Freq (Hz)':>10s} {'Octave':>6s}")
|
||||
print(f"{'─' * 5} {'─' * 5} {'─' * 10} {'─' * 6}")
|
||||
|
||||
# Show all notes from C2 to C7
|
||||
for midi in range(36, 97):
|
||||
tone = Tone.from_midi(midi)
|
||||
freq = tone.frequency
|
||||
print(f"{midi:>5d} {tone.full_name:>5s} {freq:>10.2f} {tone.octave:>6d}")
|
||||
|
||||
# Useful reference points
|
||||
print()
|
||||
print("Key Reference Points:")
|
||||
print(f" Lowest piano note: A0 = MIDI {Tone.from_string('A0', system='western').midi}")
|
||||
print(f" Middle C: C4 = MIDI {Tone.from_string('C4', system='western').midi}")
|
||||
print(f" Concert A: A4 = MIDI {Tone.from_string('A4', system='western').midi}")
|
||||
print(f" Highest piano note: C8 = MIDI {Tone.from_string('C8', system='western').midi}")
|
||||
|
||||
# Round-trip demo
|
||||
print()
|
||||
print("Round-trip conversions:")
|
||||
for start in ["C4", "A4", "F#3", "Bb5"]:
|
||||
tone = Tone.from_string(start, system="western")
|
||||
midi = tone.midi
|
||||
freq = tone.frequency
|
||||
from_midi = Tone.from_midi(midi)
|
||||
from_freq = Tone.from_frequency(freq)
|
||||
print(f" {start:4s} → MIDI {midi} → {from_midi.full_name:4s} | "
|
||||
f"{start:4s} → {freq:.2f} Hz → {from_freq.full_name}")
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Explore the overtone series — nature's chord."""
|
||||
|
||||
from pytheory import Tone, Chord
|
||||
|
||||
a4 = Tone.from_string("A4", system="western")
|
||||
|
||||
print("The Overtone Series")
|
||||
print("=" * 65)
|
||||
print()
|
||||
print("When you play a note, you're actually hearing many frequencies")
|
||||
print("at once. The fundamental plus its integer multiples:")
|
||||
print()
|
||||
print(f"{'Harmonic':>9s} {'Frequency':>10s} {'Nearest Note':>13s} {'Interval from Root'}")
|
||||
print(f"{'─' * 9} {'─' * 10} {'─' * 13} {'─' * 25}")
|
||||
|
||||
overtones = a4.overtones(16)
|
||||
|
||||
for i, hz in enumerate(overtones, 1):
|
||||
nearest = Tone.from_frequency(hz)
|
||||
if i == 1:
|
||||
interval = "Fundamental"
|
||||
else:
|
||||
interval = a4.interval_to(nearest)
|
||||
print(f"{i:>9d} {hz:>10.1f} {nearest.full_name:>13s} {interval}")
|
||||
|
||||
# ── Why Chords Sound Good ───────────────────────────────────────────────
|
||||
|
||||
print()
|
||||
print("Why the Major Triad Sounds 'Natural'")
|
||||
print("=" * 65)
|
||||
print()
|
||||
print("The first 6 harmonics contain: root, octave, 5th, 2nd octave, 3rd, 5th")
|
||||
print("That's a major triad! The major chord is literally embedded in physics.")
|
||||
print()
|
||||
|
||||
c4 = Tone.from_string("C4", system="western")
|
||||
harmonics = c4.overtones(6)
|
||||
harmonic_names = [Tone.from_frequency(hz).name for hz in harmonics]
|
||||
unique = []
|
||||
for n in harmonic_names:
|
||||
if n not in unique:
|
||||
unique.append(n)
|
||||
print(f" First 6 harmonics of C: {', '.join(harmonic_names)}")
|
||||
print(f" Unique pitch classes: {', '.join(unique)}")
|
||||
print(f" C major triad: C, E, G")
|
||||
print()
|
||||
|
||||
# ── Shared Overtones = Consonance ───────────────────────────────────────
|
||||
|
||||
print("Shared Overtones Between Intervals")
|
||||
print("=" * 65)
|
||||
print()
|
||||
print("The more overtones two notes share, the more consonant they sound.")
|
||||
print()
|
||||
|
||||
root = Tone.from_string("C4", system="western")
|
||||
root_overtones = set(round(h, 1) for h in root.overtones(12))
|
||||
|
||||
for semitones, label in [(7, "Perfect 5th (C→G)"),
|
||||
(4, "Major 3rd (C→E)"),
|
||||
(5, "Perfect 4th (C→F)"),
|
||||
(3, "Minor 3rd (C→Eb)"),
|
||||
(6, "Tritone (C→F#)"),
|
||||
(1, "Minor 2nd (C→C#)")]:
|
||||
other = root + semitones
|
||||
other_overtones = set(round(h, 1) for h in other.overtones(12))
|
||||
shared = root_overtones & other_overtones
|
||||
print(f" {label:25s} {len(shared):2d} shared overtones (of first 12)")
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Build and analyze chord progressions in any key."""
|
||||
|
||||
from pytheory import Key, Chord
|
||||
|
||||
def show_progression(key, numerals, label=""):
|
||||
chords = key.progression(*numerals)
|
||||
if label:
|
||||
print(f" {label}")
|
||||
print(f" Key: {key}")
|
||||
print(f" Progression: {' – '.join(numerals)}")
|
||||
print()
|
||||
for numeral, chord in zip(numerals, chords):
|
||||
t = chord.tension
|
||||
print(
|
||||
f" {numeral:6s} {chord.identify():20s} "
|
||||
f"tension={t['score']:.2f} "
|
||||
f"{'*** DOMINANT ***' if t['has_dominant_function'] else ''}"
|
||||
)
|
||||
print()
|
||||
|
||||
|
||||
# ── Famous Progressions ─────────────────────────────────────────────────
|
||||
|
||||
print("Famous Chord Progressions")
|
||||
print("=" * 65)
|
||||
print()
|
||||
|
||||
key_c = Key("C", "major")
|
||||
|
||||
show_progression(key_c, ("I", "V", "vi", "IV"),
|
||||
"The Pop Progression (Let It Be, No Woman No Cry, Someone Like You)")
|
||||
|
||||
show_progression(key_c, ("I", "vi", "IV", "V"),
|
||||
"The 50s Progression (Stand By Me, Every Breath You Take)")
|
||||
|
||||
show_progression(key_c, ("ii", "V", "I"),
|
||||
"Jazz ii–V–I (the backbone of jazz harmony)")
|
||||
|
||||
show_progression(key_c, ("I", "IV", "V", "I"),
|
||||
"The Three-Chord Trick (blues, rock, country)")
|
||||
|
||||
# ── Same Progression in Different Keys ──────────────────────────────────
|
||||
|
||||
print("─" * 65)
|
||||
print()
|
||||
print("I – V – vi – IV in every key:")
|
||||
print()
|
||||
|
||||
for tonic in ["C", "G", "D", "A", "E", "F", "Bb", "Eb"]:
|
||||
key = Key(tonic, "major")
|
||||
chords = key.progression("I", "V", "vi", "IV")
|
||||
names = [c.identify() for c in chords]
|
||||
print(f" {tonic} major: {' → '.join(names)}")
|
||||
|
||||
# ── Nashville Number System ─────────────────────────────────────────────
|
||||
|
||||
print()
|
||||
print("─" * 65)
|
||||
print()
|
||||
print("Nashville Number System:")
|
||||
print(" (Same thing as Roman numerals, but with integers)")
|
||||
print()
|
||||
|
||||
key_g = Key("G", "major")
|
||||
chords = key_g.nashville(1, 5, 6, 4)
|
||||
names = [c.identify() for c in chords]
|
||||
print(f" G major: 1 – 5 – 6 – 4 → {' → '.join(names)}")
|
||||
|
||||
# ── Random Progression Generator ────────────────────────────────────────
|
||||
|
||||
print()
|
||||
print("─" * 65)
|
||||
print()
|
||||
print("Random 8-bar progressions:")
|
||||
print()
|
||||
|
||||
for _ in range(3):
|
||||
key = Key("C", "major")
|
||||
chords = key.random_progression(8)
|
||||
names = [c.identify().split()[0] for c in chords] # Just root names
|
||||
print(f" | {' | '.join(names)} |")
|
||||
+201
-63
@@ -1,78 +1,216 @@
|
||||
from time import sleep
|
||||
"""Play melodies and chord progressions with PyTheory.
|
||||
|
||||
from pytheory import TonedScale, Tone, CHARTS, play
|
||||
Requires PortAudio: brew install portaudio (macOS)
|
||||
"""
|
||||
|
||||
from pytheory import Tone, Chord, Key, TonedScale, play, Synth
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
BPM = 180
|
||||
BEAT = 60_000 // BPM # ms per beat
|
||||
|
||||
|
||||
# Add this constant at the top of the file, after the imports
|
||||
EIGHTH_NOTE = 0.25
|
||||
QUARTER_NOTE = 0.5
|
||||
|
||||
# Add scale definition after the constants
|
||||
C_MAJOR = TonedScale(tonic="C4")
|
||||
def play_melody(notes, synth=Synth.SINE):
|
||||
"""Play a sequence of (note_string, beats) tuples."""
|
||||
try:
|
||||
for note, beats in notes:
|
||||
if note == "REST":
|
||||
import time
|
||||
time.sleep(beats * BEAT / 1000)
|
||||
else:
|
||||
tone = Tone.from_string(note, system="western")
|
||||
play(tone, synth=synth, t=int(beats * BEAT))
|
||||
except KeyboardInterrupt:
|
||||
print("\n Stopped.")
|
||||
|
||||
|
||||
def play_note(note, t=0.1):
|
||||
# Convert scale degree (1-7) to note name (0-based index)
|
||||
scale_notes = ["C4", "D4", "E4", "F4", "G4", "A4", "B4"]
|
||||
note_name = scale_notes[note - 1] # Subtract 1 because scale degrees are 1-based
|
||||
tone = Tone(note_name)
|
||||
play(tone, t=t * 1_000)
|
||||
sleep(t)
|
||||
def play_progression(chords, beats_each=2, synth=Synth.SINE):
|
||||
"""Play a list of Chord objects."""
|
||||
try:
|
||||
for chord in chords:
|
||||
name = chord.identify() or "?"
|
||||
tones = " ".join(t.full_name for t in chord.tones)
|
||||
print(f" {name:20s} {tones}")
|
||||
play(chord, synth=synth, t=int(beats_each * BEAT))
|
||||
except KeyboardInterrupt:
|
||||
print("\n Stopped.")
|
||||
|
||||
|
||||
# Twinkle Twinkle Little Star in C major
|
||||
# C C G G A A G (first line)
|
||||
# F F E E D D C (second line)
|
||||
# G G F F E E D (third line)
|
||||
# G G F F E E D (fourth line)
|
||||
# C C G G A A G (fifth line)
|
||||
# F F E E D D C (sixth line)
|
||||
# ── Songs ───────────────────────────────────────────────────────────────
|
||||
|
||||
def twinkle_twinkle():
|
||||
"""Twinkle Twinkle Little Star — C major."""
|
||||
print("Twinkle Twinkle Little Star")
|
||||
print("=" * 40)
|
||||
|
||||
def play_twinkle():
|
||||
# Define the patterns using scale degrees instead of note names
|
||||
line1 = [
|
||||
(1, EIGHTH_NOTE), # C4
|
||||
(1, EIGHTH_NOTE), # C4
|
||||
(5, EIGHTH_NOTE), # G4
|
||||
(5, EIGHTH_NOTE), # G4
|
||||
(6, EIGHTH_NOTE), # A4
|
||||
(6, EIGHTH_NOTE), # A4
|
||||
(5, QUARTER_NOTE), # G4
|
||||
]
|
||||
line2 = [
|
||||
(4, EIGHTH_NOTE), # F4
|
||||
(4, EIGHTH_NOTE), # F4
|
||||
(3, EIGHTH_NOTE), # E4
|
||||
(3, EIGHTH_NOTE), # E4
|
||||
(2, EIGHTH_NOTE), # D4
|
||||
(2, EIGHTH_NOTE), # D4
|
||||
(1, QUARTER_NOTE), # C4
|
||||
]
|
||||
line3 = [
|
||||
(5, EIGHTH_NOTE), # G4
|
||||
(5, EIGHTH_NOTE), # G4
|
||||
(4, EIGHTH_NOTE), # F4
|
||||
(4, EIGHTH_NOTE), # F4
|
||||
(3, EIGHTH_NOTE), # E4
|
||||
(3, EIGHTH_NOTE), # E4
|
||||
(2, QUARTER_NOTE), # D4
|
||||
melody = [
|
||||
# Twinkle twinkle little star
|
||||
("C4", 1), ("C4", 1), ("G4", 1), ("G4", 1),
|
||||
("A4", 1), ("A4", 1), ("G4", 2),
|
||||
# How I wonder what you are
|
||||
("F4", 1), ("F4", 1), ("E4", 1), ("E4", 1),
|
||||
("D4", 1), ("D4", 1), ("C4", 2),
|
||||
# Up above the world so high
|
||||
("G4", 1), ("G4", 1), ("F4", 1), ("F4", 1),
|
||||
("E4", 1), ("E4", 1), ("D4", 2),
|
||||
# Like a diamond in the sky
|
||||
("G4", 1), ("G4", 1), ("F4", 1), ("F4", 1),
|
||||
("E4", 1), ("E4", 1), ("D4", 2),
|
||||
# Twinkle twinkle little star
|
||||
("C4", 1), ("C4", 1), ("G4", 1), ("G4", 1),
|
||||
("A4", 1), ("A4", 1), ("G4", 2),
|
||||
# How I wonder what you are
|
||||
("F4", 1), ("F4", 1), ("E4", 1), ("E4", 1),
|
||||
("D4", 1), ("D4", 1), ("C4", 2),
|
||||
]
|
||||
|
||||
# Construct the full melody using the patterns
|
||||
melody = (
|
||||
line1 # Twinkle twinkle little star
|
||||
+ line2 # How I wonder what you are
|
||||
+ line3 # Up above the world so high
|
||||
+ line3 # Like a diamond in the sky
|
||||
+ line1 # Twinkle twinkle little star
|
||||
+ line2 # How I wonder what you are
|
||||
)
|
||||
play_melody(melody)
|
||||
|
||||
print("Playing Twinkle Twinkle Little Star...")
|
||||
for note, duration in melody:
|
||||
play_note(note, duration)
|
||||
|
||||
def ode_to_joy():
|
||||
"""Ode to Joy — Beethoven's 9th Symphony, D major."""
|
||||
print("Ode to Joy (Beethoven)")
|
||||
print("=" * 40)
|
||||
|
||||
melody = [
|
||||
# Main theme
|
||||
("F#4", 1), ("F#4", 1), ("G4", 1), ("A4", 1),
|
||||
("A4", 1), ("G4", 1), ("F#4", 1), ("E4", 1),
|
||||
("D4", 1), ("D4", 1), ("E4", 1), ("F#4", 1),
|
||||
("F#4", 1.5), ("E4", 0.5), ("E4", 2),
|
||||
# Repeat with variation
|
||||
("F#4", 1), ("F#4", 1), ("G4", 1), ("A4", 1),
|
||||
("A4", 1), ("G4", 1), ("F#4", 1), ("E4", 1),
|
||||
("D4", 1), ("D4", 1), ("E4", 1), ("F#4", 1),
|
||||
("E4", 1.5), ("D4", 0.5), ("D4", 2),
|
||||
]
|
||||
|
||||
play_melody(melody)
|
||||
|
||||
|
||||
def happy_birthday():
|
||||
"""Happy Birthday — G major."""
|
||||
print("Happy Birthday")
|
||||
print("=" * 40)
|
||||
|
||||
melody = [
|
||||
# Happy birthday to you
|
||||
("G4", 0.75), ("G4", 0.25), ("A4", 1), ("G4", 1),
|
||||
("C5", 1), ("B4", 2),
|
||||
# Happy birthday to you
|
||||
("G4", 0.75), ("G4", 0.25), ("A4", 1), ("G4", 1),
|
||||
("D5", 1), ("C5", 2),
|
||||
# Happy birthday dear [name]
|
||||
("G4", 0.75), ("G4", 0.25), ("G5", 1), ("E5", 1),
|
||||
("C5", 1), ("B4", 1), ("A4", 2),
|
||||
# Happy birthday to you
|
||||
("F5", 0.75), ("F5", 0.25), ("E5", 1), ("C5", 1),
|
||||
("D5", 1), ("C5", 2),
|
||||
]
|
||||
|
||||
play_melody(melody)
|
||||
|
||||
|
||||
def fur_elise():
|
||||
"""Fur Elise — opening bars (A minor)."""
|
||||
print("Fur Elise (opening)")
|
||||
print("=" * 40)
|
||||
|
||||
melody = [
|
||||
("E5", 0.5), ("D#5", 0.5), ("E5", 0.5), ("D#5", 0.5),
|
||||
("E5", 0.5), ("B4", 0.5), ("D5", 0.5), ("C5", 0.5),
|
||||
("A4", 1), ("REST", 0.5),
|
||||
("C4", 0.5), ("E4", 0.5), ("A4", 0.5),
|
||||
("B4", 1), ("REST", 0.5),
|
||||
("E4", 0.5), ("G#4", 0.5), ("B4", 0.5),
|
||||
("C5", 1), ("REST", 0.5),
|
||||
("E4", 0.5), ("E5", 0.5), ("D#5", 0.5),
|
||||
("E5", 0.5), ("D#5", 0.5), ("E5", 0.5), ("B4", 0.5),
|
||||
("D5", 0.5), ("C5", 0.5),
|
||||
("A4", 1),
|
||||
]
|
||||
|
||||
play_melody(melody)
|
||||
|
||||
|
||||
def pop_progression():
|
||||
"""The I–V–vi–IV pop progression in C major."""
|
||||
print("Pop Progression (I-V-vi-IV in C)")
|
||||
print("=" * 40)
|
||||
print()
|
||||
|
||||
key = Key("C", "major")
|
||||
chords = key.progression("I", "V", "vi", "IV")
|
||||
|
||||
# Play it twice
|
||||
play_progression(chords * 2)
|
||||
|
||||
|
||||
def blues_in_a():
|
||||
"""12-bar blues in A."""
|
||||
print("12-Bar Blues in A")
|
||||
print("=" * 40)
|
||||
print()
|
||||
|
||||
key = Key("A", "major")
|
||||
I = key.triad(0)
|
||||
IV = key.triad(3)
|
||||
V = key.triad(4)
|
||||
|
||||
bars = [I, I, I, I, IV, IV, I, I, V, IV, I, V]
|
||||
|
||||
play_progression(bars, beats_each=1.5)
|
||||
|
||||
|
||||
def jazz_ii_v_i():
|
||||
"""Jazz ii–V–I turnaround through several keys."""
|
||||
print("Jazz ii-V-I Turnaround")
|
||||
print("=" * 40)
|
||||
print()
|
||||
|
||||
for tonic in ["C", "F", "Bb", "Eb"]:
|
||||
key = Key(tonic, "major")
|
||||
chords = key.progression("ii", "V", "I")
|
||||
print(f" Key of {tonic}:")
|
||||
play_progression(chords, beats_each=1.5)
|
||||
print()
|
||||
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────────────
|
||||
|
||||
SONGS = {
|
||||
"1": ("Twinkle Twinkle Little Star", twinkle_twinkle),
|
||||
"2": ("Ode to Joy", ode_to_joy),
|
||||
"3": ("Happy Birthday", happy_birthday),
|
||||
"4": ("Fur Elise (opening)", fur_elise),
|
||||
"5": ("Pop Progression (I-V-vi-IV)", pop_progression),
|
||||
"6": ("12-Bar Blues in A", blues_in_a),
|
||||
"7": ("Jazz ii-V-I Turnaround", jazz_ii_v_i),
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
play_twinkle()
|
||||
try:
|
||||
print("PyTheory Song Player")
|
||||
print("=" * 40)
|
||||
print()
|
||||
|
||||
for key, (name, _) in SONGS.items():
|
||||
print(f" {key}. {name}")
|
||||
|
||||
print()
|
||||
choice = input("Pick a song (1-7, or 'all'): ").strip()
|
||||
|
||||
if choice == "all":
|
||||
for _, (_, fn) in SONGS.items():
|
||||
fn()
|
||||
print()
|
||||
elif choice in SONGS:
|
||||
SONGS[choice][1]()
|
||||
else:
|
||||
print("Playing all melodies...")
|
||||
for _, (_, fn) in SONGS.items():
|
||||
fn()
|
||||
print()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nBye!")
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Compare equal, Pythagorean, and meantone temperaments."""
|
||||
|
||||
import math
|
||||
from pytheory import Tone
|
||||
|
||||
a4 = Tone.from_string("A4", system="western")
|
||||
|
||||
print("Temperament Comparison")
|
||||
print("=" * 75)
|
||||
print()
|
||||
print(f"{'Note':>5s} {'Equal (Hz)':>12s} {'Pythag (Hz)':>12s} {'Meantone (Hz)':>14s} {'P diff':>8s} {'M diff':>8s}")
|
||||
print(f"{'─' * 5} {'─' * 12} {'─' * 12} {'─' * 14} {'─' * 8} {'─' * 8}")
|
||||
|
||||
for semitones in range(13):
|
||||
tone = a4 + semitones
|
||||
|
||||
equal = tone.pitch(temperament="equal")
|
||||
pyth = tone.pitch(temperament="pythagorean")
|
||||
mean = tone.pitch(temperament="meantone")
|
||||
|
||||
# Difference in cents (1 cent = 1/100 of a semitone)
|
||||
pyth_cents = 1200 * math.log2(pyth / equal) if pyth > 0 else 0
|
||||
mean_cents = 1200 * math.log2(mean / equal) if mean > 0 else 0
|
||||
|
||||
print(
|
||||
f"{tone.name:>5s} {equal:>12.3f} {pyth:>12.3f} {mean:>14.3f}"
|
||||
f" {pyth_cents:>+7.1f}¢ {mean_cents:>+7.1f}¢"
|
||||
)
|
||||
|
||||
print()
|
||||
print("Key intervals to listen for:")
|
||||
print()
|
||||
|
||||
intervals = [
|
||||
(4, "Major 3rd", "Meantone is pure (5:4), equal is sharp, Pythagorean sharper still"),
|
||||
(7, "Perfect 5th", "Pythagorean is pure (3:2), equal is slightly flat, meantone flatter"),
|
||||
(6, "Tritone", "The 'devil's interval' — all three temperaments handle it differently"),
|
||||
]
|
||||
|
||||
for semitones, name, note in intervals:
|
||||
tone = a4 + semitones
|
||||
equal = tone.pitch(temperament="equal")
|
||||
pyth = tone.pitch(temperament="pythagorean")
|
||||
mean = tone.pitch(temperament="meantone")
|
||||
|
||||
print(f" {name} ({a4.name}→{tone.name}):")
|
||||
print(f" Equal: {equal:.3f} Hz | Pythagorean: {pyth:.3f} Hz | Meantone: {mean:.3f} Hz")
|
||||
print(f" {note}")
|
||||
print()
|
||||
@@ -0,0 +1,677 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# PyTheory: Music Theory for Humans\n",
|
||||
"\n",
|
||||
"A hands-on tutorial exploring music theory with Python.\n",
|
||||
"\n",
|
||||
"PyTheory lets you reason about tones, scales, chords, and progressions\n",
|
||||
"using an intuitive, Pythonic API. Whether you're a musician who codes\n",
|
||||
"or a coder who plays music, this library gives you the building blocks\n",
|
||||
"to explore harmony, composition, and world music systems."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1. Getting Started\n",
|
||||
"\n",
|
||||
"Everything begins with a **Tone** -- the fundamental unit of music.\n",
|
||||
"A tone has a name (like `C`, `F#`, or `Bb`), an optional octave number,\n",
|
||||
"and a frequency in Hz computed from equal temperament tuning (A4 = 440 Hz)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"from pytheory import Tone, TonedScale, Key, Chord, Fretboard, CHARTS, Interval\n",
|
||||
"from pytheory import analyze_progression\n",
|
||||
"from pytheory.scales import PROGRESSIONS"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Create tones with octave numbers (scientific pitch notation)\n",
|
||||
"middle_c = Tone.from_string(\"C4\")\n",
|
||||
"concert_a = Tone.from_string(\"A4\")\n",
|
||||
"\n",
|
||||
"print(f\"Middle C: {middle_c} -> {middle_c.frequency:.2f} Hz\")\n",
|
||||
"print(f\"Concert A: {concert_a} -> {concert_a.frequency:.2f} Hz\")\n",
|
||||
"print(f\"MIDI note: {middle_c.midi}\")\n",
|
||||
"print(f\"Is natural? {middle_c.is_natural}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Create tones from frequencies or MIDI numbers\n",
|
||||
"from_hz = Tone.from_frequency(440.0)\n",
|
||||
"from_midi = Tone.from_midi(60)\n",
|
||||
"\n",
|
||||
"print(f\"440 Hz -> {from_hz}\")\n",
|
||||
"print(f\"MIDI 60 -> {from_midi}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Explore the harmonic series -- the physics behind consonance\n",
|
||||
"c3 = Tone.from_string(\"C3\")\n",
|
||||
"harmonics = c3.overtones(8)\n",
|
||||
"print(f\"Harmonic series of {c3} ({c3.frequency:.1f} Hz):\")\n",
|
||||
"for i, hz in enumerate(harmonics, 1):\n",
|
||||
" print(f\" Harmonic {i}: {hz:.1f} Hz\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2. Tone Arithmetic\n",
|
||||
"\n",
|
||||
"Tones support arithmetic operations. Adding an integer to a tone raises it\n",
|
||||
"by that many **semitones** (half steps). Subtracting two tones gives the\n",
|
||||
"semitone distance between them. You can also compare tones by pitch."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"c4 = Tone.from_string(\"C4\")\n",
|
||||
"\n",
|
||||
"# Add semitones: C + 4 semitones = E (a major third)\n",
|
||||
"e4 = c4 + 4\n",
|
||||
"g4 = c4 + Interval.PERFECT_FIFTH\n",
|
||||
"print(f\"{c4} + 4 semitones = {e4}\")\n",
|
||||
"print(f\"{c4} + perfect 5th = {g4}\")\n",
|
||||
"\n",
|
||||
"# Subtract to find interval distance\n",
|
||||
"distance = g4 - c4\n",
|
||||
"print(f\"\\nDistance from {c4} to {g4}: {distance} semitones\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Name the interval between two tones\n",
|
||||
"print(f\"{c4} -> {e4}: {c4.interval_to(e4)}\")\n",
|
||||
"print(f\"{c4} -> {g4}: {c4.interval_to(g4)}\")\n",
|
||||
"\n",
|
||||
"c5 = Tone.from_string(\"C5\")\n",
|
||||
"print(f\"{c4} -> {c5}: {c4.interval_to(c5)}\")\n",
|
||||
"\n",
|
||||
"# Compare tones by pitch\n",
|
||||
"print(f\"\\n{c4} < {g4}? {c4 < g4}\")\n",
|
||||
"print(f\"{c4} == {c4}? {c4 == c4}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# The circle of fifths -- the backbone of Western harmony\n",
|
||||
"c = Tone.from_string(\"C4\")\n",
|
||||
"fifths = c.circle_of_fifths()\n",
|
||||
"print(\"Circle of fifths from C:\")\n",
|
||||
"print(\" -> \".join(str(t) for t in fifths))"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3. Scales and Modes\n",
|
||||
"\n",
|
||||
"A **scale** is a collection of tones arranged in ascending order.\n",
|
||||
"The `TonedScale` class provides access to dozens of scales from a given tonic.\n",
|
||||
"\n",
|
||||
"**Modes** are rotations of the same set of intervals. The seven modes of the\n",
|
||||
"major scale each have a distinct character:\n",
|
||||
"\n",
|
||||
"| Mode | Character |\n",
|
||||
"|------------|--------------------|\n",
|
||||
"| Ionian | Bright, happy |\n",
|
||||
"| Dorian | Jazzy, soulful |\n",
|
||||
"| Phrygian | Spanish, dark |\n",
|
||||
"| Lydian | Dreamy, floating |\n",
|
||||
"| Mixolydian | Bluesy, rock |\n",
|
||||
"| Aeolian | Sad, natural minor |\n",
|
||||
"| Locrian | Tense, unstable |"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Build a scale from a tonic\n",
|
||||
"ts = TonedScale(tonic=\"C4\")\n",
|
||||
"\n",
|
||||
"# See all available scale names\n",
|
||||
"print(\"Available scales:\")\n",
|
||||
"for name in ts.scales:\n",
|
||||
" print(f\" {name}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Get a specific scale and iterate its tones\n",
|
||||
"c_major = ts[\"major\"]\n",
|
||||
"print(f\"C major: {c_major.note_names}\")\n",
|
||||
"\n",
|
||||
"c_minor = ts[\"minor\"]\n",
|
||||
"print(f\"C minor: {c_minor.note_names}\")\n",
|
||||
"\n",
|
||||
"# Check if a note belongs to the scale\n",
|
||||
"print(f\"\\nIs F# in C major? {'F#' in c_major}\")\n",
|
||||
"print(f\"Is G in C major? {'G' in c_major}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": "from pytheory.scales import Scale\n\n# Transpose a scale\nd_major = c_major.transpose(2)\nprint(f\"D major (C major transposed up 2): {d_major.note_names}\")\n\n# Detect a scale from a set of notes\nresult = Scale.detect(\"A\", \"B\", \"C#\", \"D\", \"E\", \"F#\", \"G#\")\nprint(f\"\\nDetected scale: {result}\")",
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4. The Key Class\n",
|
||||
"\n",
|
||||
"A **Key** is the most convenient entry point for working with harmony.\n",
|
||||
"It wraps a tonic and mode, giving you instant access to scales, diatonic\n",
|
||||
"chords, key signatures, and related keys."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"key = Key(\"C\", \"major\")\n",
|
||||
"\n",
|
||||
"print(f\"Key: {key}\")\n",
|
||||
"print(f\"Notes: {key.note_names}\")\n",
|
||||
"print(f\"Signature: {key.signature}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Diatonic triads -- the seven chords built from the scale\n",
|
||||
"print(\"Diatonic triads in C major:\")\n",
|
||||
"for i, name in enumerate(key.chords, 1):\n",
|
||||
" print(f\" {i}. {name}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Seventh chords add richness and color\n",
|
||||
"print(\"Diatonic seventh chords in C major:\")\n",
|
||||
"for i, name in enumerate(key.seventh_chords, 1):\n",
|
||||
" print(f\" {i}. {name}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Related keys\n",
|
||||
"print(f\"Relative minor of C major: {key.relative}\")\n",
|
||||
"print(f\"Parallel minor of C major: {key.parallel}\")\n",
|
||||
"\n",
|
||||
"# Key signatures for sharp and flat keys\n",
|
||||
"for tonic in [\"G\", \"D\", \"F\", \"Bb\"]:\n",
|
||||
" k = Key(tonic, \"major\")\n",
|
||||
" sig = k.signature\n",
|
||||
" print(f\"{k}: {sig['sharps']} sharps, {sig['flats']} flats -> {sig['accidentals']}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 5. Chord Analysis\n",
|
||||
"\n",
|
||||
"Chords can be created from note names, intervals, chord symbols, or MIDI.\n",
|
||||
"PyTheory can identify chord quality, measure tension and consonance,\n",
|
||||
"and compute optimal voice leading between chords."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Multiple ways to create chords\n",
|
||||
"c_major_chord = Chord.from_tones(\"C\", \"E\", \"G\")\n",
|
||||
"g7 = Chord.from_intervals(\"G\", 4, 7, 10)\n",
|
||||
"am = Chord.from_name(\"Am\")\n",
|
||||
"\n",
|
||||
"print(f\"{c_major_chord} (intervals: {c_major_chord.intervals})\")\n",
|
||||
"print(f\"{g7} (intervals: {g7.intervals})\")\n",
|
||||
"print(f\"{am} (intervals: {am.intervals})\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Analyze harmonic tension\n",
|
||||
"# The dominant 7th chord is the most tension-filled chord in tonal music\n",
|
||||
"print(\"Tension analysis:\")\n",
|
||||
"for chord in [c_major_chord, am, g7]:\n",
|
||||
" t = chord.tension\n",
|
||||
" print(f\" {chord.identify():20s} -> score={t['score']:.2f}, \"\n",
|
||||
" f\"tritones={t['tritones']}, dominant={t['has_dominant_function']}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Consonance vs dissonance (psychoacoustic measures)\n",
|
||||
"print(f\"{'Chord':20s} {'Harmony':>10s} {'Dissonance':>12s}\")\n",
|
||||
"print(\"-\" * 44)\n",
|
||||
"for chord in [c_major_chord, am, g7]:\n",
|
||||
" print(f\"{chord.identify():20s} {chord.harmony:10.4f} {chord.dissonance:12.4f}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Voice leading: how individual notes move between chords\n",
|
||||
"f_major = Chord.from_tones(\"F\", \"A\", \"C\")\n",
|
||||
"vl = c_major_chord.voice_leading(f_major)\n",
|
||||
"\n",
|
||||
"print(f\"Voice leading: {c_major_chord.identify()} -> {f_major.identify()}\")\n",
|
||||
"for src, dst, movement in vl:\n",
|
||||
" direction = \"up\" if movement > 0 else \"down\" if movement < 0 else \"stays\"\n",
|
||||
" print(f\" {src} -> {dst} ({movement:+d} semitones, {direction})\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Inversions rearrange chord voicings\n",
|
||||
"print(f\"Root position: {[t.full_name for t in c_major_chord.tones]}\")\n",
|
||||
"print(f\"1st inversion: {[t.full_name for t in c_major_chord.inversion(1).tones]}\")\n",
|
||||
"print(f\"2nd inversion: {[t.full_name for t in c_major_chord.inversion(2).tones]}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 6. Chord Progressions\n",
|
||||
"\n",
|
||||
"Chord progressions are the harmonic backbone of songs. PyTheory supports\n",
|
||||
"both **Roman numeral** analysis (classical/jazz) and the **Nashville number\n",
|
||||
"system** (studio shorthand). It also ships with common progressions built in."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"key = Key(\"G\", \"major\")\n",
|
||||
"\n",
|
||||
"# Build a progression from Roman numerals\n",
|
||||
"prog = key.progression(\"I\", \"V\", \"vi\", \"IV\")\n",
|
||||
"print(\"I - V - vi - IV in G major (the 'four chord song'):\")\n",
|
||||
"for chord in prog:\n",
|
||||
" print(f\" {chord.identify()}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Nashville number system -- same thing, Arabic numerals\n",
|
||||
"nashville = key.nashville(1, 5, 6, 4)\n",
|
||||
"print(\"Nashville 1-5-6-4 in G major:\")\n",
|
||||
"for chord in nashville:\n",
|
||||
" print(f\" {chord.identify()}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Browse the built-in progression library\n",
|
||||
"print(\"Built-in progressions:\")\n",
|
||||
"for name, numerals in PROGRESSIONS.items():\n",
|
||||
" print(f\" {name:25s} -> {' '.join(numerals)}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Analyze an existing chord progression\n",
|
||||
"chords = [Chord.from_name(\"C\"), Chord.from_name(\"Am\"),\n",
|
||||
" Chord.from_name(\"F\"), Chord.from_name(\"G\")]\n",
|
||||
"numerals = analyze_progression(chords, key=\"C\")\n",
|
||||
"print(\"Progression analysis in C:\")\n",
|
||||
"for chord, numeral in zip(chords, numerals):\n",
|
||||
" print(f\" {chord.identify():15s} -> {numeral}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 7. World Music Systems\n",
|
||||
"\n",
|
||||
"Music theory extends far beyond Western harmony. PyTheory includes scale\n",
|
||||
"systems from several traditions:\n",
|
||||
"\n",
|
||||
"- **Indian** (raga/thaat) -- 10 parent scales covering all of Hindustani music\n",
|
||||
"- **Arabic** (maqam) -- modal systems with characteristic augmented seconds\n",
|
||||
"- **Japanese** -- pentatonic scales used in koto, shamisen, and folk music\n",
|
||||
"- **Blues** -- the scales that built American popular music\n",
|
||||
"- **Gamelan** -- Javanese/Balinese tuning systems (12-TET approximations)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"from pytheory import SYSTEMS\n",
|
||||
"\n",
|
||||
"# Indian thaat system\n",
|
||||
"indian = TonedScale(tonic=\"C4\", system=SYSTEMS[\"indian\"])\n",
|
||||
"print(\"Indian thaats from C:\")\n",
|
||||
"for name in indian.scales:\n",
|
||||
" scale = indian[name]\n",
|
||||
" print(f\" {name:12s} -> {scale.note_names}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Arabic maqam -- the Hijaz scale has a distinctive augmented 2nd\n",
|
||||
"arabic = TonedScale(tonic=\"D4\", system=SYSTEMS[\"arabic\"])\n",
|
||||
"hijaz = arabic[\"hijaz\"]\n",
|
||||
"print(f\"Maqam Hijaz from D: {hijaz.note_names}\")\n",
|
||||
"\n",
|
||||
"# Japanese hirajoshi -- hauntingly beautiful pentatonic\n",
|
||||
"japanese = TonedScale(tonic=\"A4\", system=SYSTEMS[\"japanese\"])\n",
|
||||
"hirajoshi = japanese[\"hirajoshi\"]\n",
|
||||
"print(f\"Hirajoshi from A: {hirajoshi.note_names}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Blues scales -- the foundation of rock, jazz, and R&B\n",
|
||||
"blues = TonedScale(tonic=\"A4\", system=SYSTEMS[\"blues\"])\n",
|
||||
"print(\"Blues scales from A:\")\n",
|
||||
"for name in blues.scales:\n",
|
||||
" scale = blues[name]\n",
|
||||
" print(f\" {name:20s} -> {scale.note_names}\")\n",
|
||||
"\n",
|
||||
"# Gamelan -- approximations of non-Western tuning\n",
|
||||
"gamelan = TonedScale(tonic=\"C4\", system=SYSTEMS[\"gamelan\"])\n",
|
||||
"slendro = gamelan[\"slendro\"]\n",
|
||||
"print(f\"\\nGamelan slendro from C: {slendro.note_names}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 8. Guitar and Instruments\n",
|
||||
"\n",
|
||||
"The `Fretboard` class models stringed instruments. You can look up\n",
|
||||
"chord fingerings, render tab diagrams, apply a capo, and visualize\n",
|
||||
"scale patterns across the neck."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Standard guitar fretboard\n",
|
||||
"guitar = Fretboard.guitar()\n",
|
||||
"print(f\"Standard tuning: {guitar}\")\n",
|
||||
"\n",
|
||||
"# Look up chord fingerings from the chart\n",
|
||||
"c_chart = CHARTS[\"western\"][\"C\"]\n",
|
||||
"print(f\"\\n{c_chart.tab(fretboard=guitar)}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Show several common chord shapes\n",
|
||||
"for chord_name in [\"G\", \"Am\", \"Em\", \"D\"]:\n",
|
||||
" chart = CHARTS[\"western\"][chord_name]\n",
|
||||
" print(chart.tab(fretboard=guitar))\n",
|
||||
" print()"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Apply a capo -- raises all strings by N semitones\n",
|
||||
"capo2 = Fretboard.guitar(capo=2)\n",
|
||||
"print(f\"Capo on fret 2: {capo2}\")\n",
|
||||
"print(\"Playing 'G shape' with capo 2 = A major voicing\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Scale diagram -- see where notes fall on the neck\n",
|
||||
"c_major_scale = TonedScale(tonic=\"C4\")[\"major\"]\n",
|
||||
"diagram = guitar.scale_diagram(c_major_scale, frets=12)\n",
|
||||
"print(\"C major scale on guitar:\")\n",
|
||||
"print(diagram)"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 9. Building a Song\n",
|
||||
"\n",
|
||||
"Let's put it all together: pick a key, explore its chords, build a\n",
|
||||
"progression, and analyze the harmonic movement."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Step 1: Choose a key\n",
|
||||
"song_key = Key(\"E\", \"minor\")\n",
|
||||
"print(f\"Key: {song_key}\")\n",
|
||||
"print(f\"Notes: {song_key.note_names}\")\n",
|
||||
"print(f\"Relative major: {song_key.relative}\")\n",
|
||||
"print(f\"Signature: {song_key.signature}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Step 2: See what chords are available\n",
|
||||
"print(\"Diatonic chords in E minor:\")\n",
|
||||
"for i, name in enumerate(song_key.chords, 1):\n",
|
||||
" print(f\" {i}. {name}\")\n",
|
||||
"\n",
|
||||
"print(\"\\nBorrowed chords from E major:\")\n",
|
||||
"for name in song_key.borrowed_chords[:4]:\n",
|
||||
" print(f\" {name}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Step 3: Build a progression\n",
|
||||
"# i - VI - III - VII is a classic minor key progression\n",
|
||||
"prog = song_key.progression(\"i\", \"VI\", \"III\", \"VII\")\n",
|
||||
"\n",
|
||||
"print(\"Progression: i - VI - III - VII\")\n",
|
||||
"for chord in prog:\n",
|
||||
" name = chord.identify()\n",
|
||||
" numeral = chord.analyze(\"E\", \"minor\")\n",
|
||||
" t = chord.tension\n",
|
||||
" print(f\" {name:18s} [{numeral:5s}] tension={t['score']:.2f}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Step 4: Analyze voice leading through the progression\n",
|
||||
"print(\"Voice leading through the progression:\\n\")\n",
|
||||
"for i in range(len(prog) - 1):\n",
|
||||
" src = prog[i]\n",
|
||||
" dst = prog[i + 1]\n",
|
||||
" vl = src.voice_leading(dst)\n",
|
||||
" total = sum(abs(m) for _, _, m in vl)\n",
|
||||
" print(f\"{src.identify()} -> {dst.identify()} (total movement: {total} semitones)\")\n",
|
||||
" for s, d, m in vl:\n",
|
||||
" print(f\" {s} -> {d} ({m:+d})\")\n",
|
||||
" print()"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Step 5: Show the chords on guitar\n",
|
||||
"guitar = Fretboard.guitar()\n",
|
||||
"chord_names = [\"Em\", \"C\", \"G\", \"D\"]\n",
|
||||
"\n",
|
||||
"print(\"Guitar charts for the progression:\\n\")\n",
|
||||
"for name in chord_names:\n",
|
||||
" chart = CHARTS[\"western\"][name]\n",
|
||||
" print(chart.tab(fretboard=guitar))\n",
|
||||
" print()"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Bonus: Detect the key from a set of notes\n",
|
||||
"detected = Key.detect(\"E\", \"G\", \"A\", \"B\", \"D\")\n",
|
||||
"print(f\"Key detected from [E, G, A, B, D]: {detected}\")\n",
|
||||
"\n",
|
||||
"# Secondary dominant -- adds harmonic color\n",
|
||||
"v_of_v = song_key.secondary_dominant(5)\n",
|
||||
"print(f\"\\nSecondary dominant V/V in E minor: {v_of_v.identify()}\")\n",
|
||||
"print(f\"Tension score: {v_of_v.tension['score']:.2f}\")"
|
||||
],
|
||||
"outputs": [],
|
||||
"execution_count": null
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"version": "3.12.0"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Explore scales from six musical traditions around the world."""
|
||||
|
||||
from pytheory import TonedScale
|
||||
|
||||
systems = [
|
||||
("western", "C4", [
|
||||
("major", "The foundation of Western tonal music"),
|
||||
("minor", "Natural minor — dark and introspective"),
|
||||
("harmonic minor", "Raised 7th — classical, Middle Eastern flavor"),
|
||||
("dorian", "Jazz, funk, soul (So What, Scarborough Fair)"),
|
||||
("mixolydian", "Blues, rock (Norwegian Wood, Sweet Home Alabama)"),
|
||||
("phrygian", "Flamenco, metal (White Rabbit)"),
|
||||
("lydian", "Dreamy, floating (The Simpsons theme)"),
|
||||
]),
|
||||
("indian", "Sa4", [
|
||||
("bilawal", "Equivalent to Western major scale"),
|
||||
("bhairav", "Morning raga — devotional, meditative"),
|
||||
("kafi", "Equivalent to Dorian mode — romantic, earthy"),
|
||||
("bhairavi", "Equivalent to Phrygian — melancholic, devotional"),
|
||||
("kalyan", "Equivalent to Lydian — serene, uplifting"),
|
||||
]),
|
||||
("arabic", "Do4", [
|
||||
("ajam", "Equivalent to Western major scale"),
|
||||
("hijaz", "The quintessential 'Middle Eastern' sound"),
|
||||
("bayati", "Contemplative, spiritual — most common maqam"),
|
||||
("rast", "Bright, festive — the 'mother' of maqamat"),
|
||||
("nahawand", "Equivalent to Western minor — melancholic"),
|
||||
]),
|
||||
("japanese", "C4", [
|
||||
("hirajoshi", "Haunting pentatonic — koto music"),
|
||||
("in", "Dark pentatonic — court music, Buddhist chant"),
|
||||
("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", "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"),
|
||||
]),
|
||||
]
|
||||
|
||||
for system_name, tonic, scales in systems:
|
||||
print(f"{'═' * 65}")
|
||||
print(f" {system_name.upper()}")
|
||||
print(f"{'═' * 65}")
|
||||
|
||||
ts = TonedScale(tonic=tonic, system=system_name)
|
||||
|
||||
for scale_name, description in scales:
|
||||
try:
|
||||
scale = ts[scale_name]
|
||||
notes = " ".join(scale.note_names)
|
||||
print(f" {scale_name:20s} {notes}")
|
||||
print(f" {'':20s} {description}")
|
||||
print()
|
||||
except (KeyError, IndexError, ValueError):
|
||||
print(f" {scale_name:20s} (not available)")
|
||||
print()
|
||||
|
||||
print(f"{'═' * 65}")
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pytheory"
|
||||
version = "0.3.2"
|
||||
version = "0.7.0"
|
||||
description = "Music Theory for Humans"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -33,6 +33,9 @@ Documentation = "https://pytheory.kennethreitz.org"
|
||||
Repository = "https://github.com/kennethreitz/pytheory"
|
||||
Issues = "https://github.com/kennethreitz/pytheory/issues"
|
||||
|
||||
[project.scripts]
|
||||
pytheory = "pytheory.cli:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest"]
|
||||
docs = ["sphinx"]
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"""PyTheory: Music Theory for Humans."""
|
||||
|
||||
__version__ = "0.3.2"
|
||||
__version__ = "0.7.0"
|
||||
|
||||
from .tones import Tone, Interval
|
||||
from .systems import System, SYSTEMS
|
||||
from .scales import Scale, TonedScale, Key, PROGRESSIONS
|
||||
from .chords import Chord, Fretboard
|
||||
from .charts import CHARTS, charts_for_fretboard
|
||||
from .chords import Chord, Fretboard, analyze_progression
|
||||
from .charts import CHARTS, Fingering, charts_for_fretboard
|
||||
|
||||
try:
|
||||
from .play import play, Synth
|
||||
from .play import play, save, Synth
|
||||
except OSError:
|
||||
play = None
|
||||
save = None
|
||||
Synth = None
|
||||
|
||||
# Aliases for discoverability.
|
||||
@@ -19,7 +20,7 @@ Note = Tone
|
||||
|
||||
__all__ = [
|
||||
"Tone", "Note", "Interval", "Scale", "TonedScale", "Key",
|
||||
"PROGRESSIONS", "Chord", "Fretboard",
|
||||
"PROGRESSIONS", "Chord", "Fretboard", "Fingering", "analyze_progression",
|
||||
"System", "SYSTEMS", "CHARTS", "charts_for_fretboard",
|
||||
"play", "Synth",
|
||||
"play", "save", "Synth",
|
||||
]
|
||||
|
||||
@@ -175,20 +175,6 @@ SCALES = {
|
||||
# "melodic minor": {"minor": True, "melodic": True, "hemitonic": True},
|
||||
},
|
||||
],
|
||||
# TODO: understand this
|
||||
# "hexatonic": (
|
||||
# 6,
|
||||
# {
|
||||
# # name, arguments to scale generator.
|
||||
# "wholetone": {},
|
||||
# "augmented": {},
|
||||
# "prometheus": {},
|
||||
# "blues": {},
|
||||
# },
|
||||
# ),
|
||||
# "pentatonic": (5, {}),
|
||||
# "tetratonic": (4, {}),
|
||||
# "monotonic": (1, {"monotonic": {"hemitonic": False}}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
+141
-15
@@ -60,6 +60,36 @@ class Chord:
|
||||
f"{t.name}{octave}", system="western"))
|
||||
return cls(tones=tones)
|
||||
|
||||
@classmethod
|
||||
def from_intervals(cls, root: str, *intervals: int, octave: int = 4) -> Chord:
|
||||
"""Create a Chord from a root note and semitone intervals.
|
||||
|
||||
Example::
|
||||
|
||||
>>> Chord.from_intervals("C", 4, 7) # C major
|
||||
<Chord C major>
|
||||
>>> Chord.from_intervals("G", 4, 7, 10) # G7
|
||||
<Chord G dominant 7th>
|
||||
>>> Chord.from_intervals("D", 3, 7) # D minor
|
||||
<Chord D minor>
|
||||
"""
|
||||
from .tones import Tone
|
||||
root_tone = Tone.from_string(f"{root}{octave}", system="western")
|
||||
tones = [root_tone] + [root_tone.add(i) for i in intervals]
|
||||
return cls(tones=tones)
|
||||
|
||||
@classmethod
|
||||
def from_midi_message(cls, *note_numbers: int) -> Chord:
|
||||
"""Create a Chord from MIDI note numbers.
|
||||
|
||||
Example::
|
||||
|
||||
>>> Chord.from_midi_message(60, 64, 67) # C4, E4, G4
|
||||
<Chord C major>
|
||||
"""
|
||||
from .tones import Tone
|
||||
return cls(tones=[Tone.from_midi(n) for n in note_numbers])
|
||||
|
||||
def __repr__(self) -> str:
|
||||
name = self.identify()
|
||||
if name:
|
||||
@@ -625,8 +655,33 @@ class Chord:
|
||||
"has_dominant_function": has_dominant,
|
||||
}
|
||||
|
||||
def fingering(self, *positions: int) -> Chord:
|
||||
"""Apply fret positions to each tone, returning a new Chord.
|
||||
def add_tone(self, tone) -> Chord:
|
||||
"""Return a new Chord with an additional tone.
|
||||
|
||||
Example::
|
||||
|
||||
>>> c_major = Chord.from_tones("C", "E", "G")
|
||||
>>> c_major.add_tone(Tone.from_string("B4", system="western"))
|
||||
<Chord C major 7th>
|
||||
"""
|
||||
return Chord(tones=list(self.tones) + [tone])
|
||||
|
||||
def remove_tone(self, tone_name: str) -> Chord:
|
||||
"""Return a new Chord with tones of the given name removed.
|
||||
|
||||
Args:
|
||||
tone_name: The note name to remove (e.g. "G").
|
||||
|
||||
Example::
|
||||
|
||||
>>> cmaj7 = Chord.from_name("Cmaj7")
|
||||
>>> cmaj7.remove_tone("B") # Remove the 7th
|
||||
<Chord C major>
|
||||
"""
|
||||
return Chord(tones=[t for t in self.tones if t.name != tone_name])
|
||||
|
||||
def fingering(self, *positions: int) -> "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.
|
||||
@@ -635,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:
|
||||
@@ -1156,8 +1210,68 @@ class Fretboard:
|
||||
]
|
||||
return cls(tones=[Tone.from_string(t, system="western") for t in strings])
|
||||
|
||||
def fingering(self, *positions: int) -> Chord:
|
||||
"""Apply fret positions to each string, returning a Chord.
|
||||
def scale_diagram(self, scale, frets: int = 12) -> str:
|
||||
"""Render an ASCII diagram showing where scale notes fall on the neck.
|
||||
|
||||
Each string is shown with dots on frets where scale notes appear.
|
||||
Useful for learning scale patterns on guitar, mandolin, etc.
|
||||
|
||||
Args:
|
||||
scale: A Scale object (or anything with a ``note_names`` attribute).
|
||||
frets: Number of frets to display (default 12).
|
||||
|
||||
Returns:
|
||||
A multi-line string showing the fretboard diagram.
|
||||
|
||||
Example::
|
||||
|
||||
>>> from pytheory import Fretboard, TonedScale
|
||||
>>> fb = Fretboard.guitar()
|
||||
>>> pentatonic = TonedScale(tonic="A4")["minor"]
|
||||
>>> print(fb.scale_diagram(pentatonic, frets=5))
|
||||
"""
|
||||
scale_notes = set(scale.note_names)
|
||||
max_name = max(len(t.name) for t in self.tones)
|
||||
lines = []
|
||||
|
||||
# Header with fret numbers
|
||||
header = " " * (max_name + 1) + " ".join(f"{f:<3d}" for f in range(frets + 1))
|
||||
lines.append(header)
|
||||
|
||||
for tone in self.tones:
|
||||
fret_marks = []
|
||||
for f in range(frets + 1):
|
||||
note = tone.add(f)
|
||||
if note.name in scale_notes:
|
||||
fret_marks.append(f" {note.name:<2s}")
|
||||
else:
|
||||
fret_marks.append(" - ")
|
||||
line = f"{tone.name:>{max_name}}|{'|'.join(fret_marks)}|"
|
||||
lines.append(line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def 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.
|
||||
|
||||
Each position value is added (in semitones) to the corresponding
|
||||
open-string tone. The number of positions must match the number
|
||||
@@ -1167,19 +1281,31 @@ 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]))
|
||||
string_names = tuple(t.name for t in self.tones)
|
||||
return Fingering(positions, string_names, fretboard=self)
|
||||
|
||||
return Chord(tones=tones)
|
||||
|
||||
def analyze_progression(chords: list[Chord], key: str = "C", mode: str = "major") -> list[str | None]:
|
||||
"""Analyze a list of chords and return their Roman numeral functions.
|
||||
|
||||
Example::
|
||||
|
||||
>>> chords = [Chord.from_name("C"), Chord.from_name("Am"), Chord.from_name("F"), Chord.from_name("G")]
|
||||
>>> analyze_progression(chords, key="C")
|
||||
['I', 'vi', 'IV', 'V']
|
||||
"""
|
||||
return [c.analyze(key, mode) for c in chords]
|
||||
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
"""PyTheory CLI — music theory from the command line."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def cmd_tone(args):
|
||||
from .tones import Tone
|
||||
tone = Tone.from_string(args.note, system="western")
|
||||
freq = tone.pitch(temperament=args.temperament)
|
||||
print(f" Note: {tone.full_name}")
|
||||
print(f" Frequency: {freq:.2f} Hz ({args.temperament} temperament)")
|
||||
if args.temperament != "equal":
|
||||
import math
|
||||
equal_freq = tone.pitch(temperament="equal")
|
||||
diff_cents = 1200 * math.log2(freq / equal_freq) if freq > 0 else 0
|
||||
print(f" Equal temp: {equal_freq:.2f} Hz (diff: {diff_cents:+.1f} cents)")
|
||||
if tone.midi is not None:
|
||||
print(f" MIDI: {tone.midi}")
|
||||
if tone.enharmonic:
|
||||
print(f" Enharmonic: {tone.enharmonic}")
|
||||
print(f" Overtones: {', '.join(f'{h:.1f}' for h in tone.overtones(6))}")
|
||||
|
||||
|
||||
def cmd_scale(args):
|
||||
from .scales import TonedScale
|
||||
ts = TonedScale(tonic=f"{args.tonic}4", system=args.system)
|
||||
scale = ts[args.mode]
|
||||
print(f" {args.tonic} {args.mode}: {' '.join(scale.note_names)}")
|
||||
print(f" Intervals: {scale.tones[0].full_name}", end="")
|
||||
for i in range(1, len(scale.tones)):
|
||||
semitones = abs(scale.tones[i] - scale.tones[i-1])
|
||||
print(f" -{semitones}- {scale.tones[i].full_name}", end="")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_chord(args):
|
||||
from .tones import Tone
|
||||
from .chords import Chord
|
||||
tones = [Tone.from_string(f"{n}4", system="western") for n in args.notes]
|
||||
chord = Chord(tones=tones)
|
||||
name = chord.identify() or "Unknown"
|
||||
print(f" Chord: {name}")
|
||||
print(f" Tones: {' '.join(t.full_name for t in chord.tones)}")
|
||||
print(f" Intervals: {chord.intervals}")
|
||||
print(f" Harmony: {chord.harmony:.4f}")
|
||||
print(f" Dissonance: {chord.dissonance:.4f}")
|
||||
t = chord.tension
|
||||
print(f" Tension: {t['score']:.2f} (tritones={t['tritones']})")
|
||||
|
||||
|
||||
def cmd_key(args):
|
||||
from .scales import Key
|
||||
key = Key(args.tonic, args.mode)
|
||||
sig = key.signature
|
||||
acc = ", ".join(sig["accidentals"]) if sig["accidentals"] else "none"
|
||||
print(f" Key: {key}")
|
||||
print(f" Signature: {sig['sharps']} sharps, {sig['flats']} flats ({acc})")
|
||||
print(f" Scale: {' '.join(key.note_names)}")
|
||||
print(f" Triads:")
|
||||
for chord in key.scale.harmonize():
|
||||
analysis = chord.analyze(args.tonic, args.mode) or "?"
|
||||
print(f" {analysis:6s} {chord}")
|
||||
print(f" 7th chords:")
|
||||
for name in key.seventh_chords:
|
||||
print(f" {name}")
|
||||
print(f" Relative: {key.relative}")
|
||||
print(f" Parallel: {key.parallel}")
|
||||
|
||||
|
||||
def cmd_fingering(args):
|
||||
from .charts import CHARTS
|
||||
from .chords import Fretboard
|
||||
chart = CHARTS.get("western", {})
|
||||
if args.chord not in chart:
|
||||
print(f" Unknown chord: {args.chord}")
|
||||
sys.exit(1)
|
||||
fb = Fretboard.guitar(capo=args.capo)
|
||||
print(chart[args.chord].tab(fretboard=fb))
|
||||
|
||||
|
||||
def cmd_progression(args):
|
||||
from .scales import Key
|
||||
key = Key(args.tonic, args.mode)
|
||||
chords = key.progression(*args.numerals)
|
||||
print(f" Key: {key}")
|
||||
print(f" Progression: {' → '.join(args.numerals)}")
|
||||
print()
|
||||
for numeral, chord in zip(args.numerals, chords):
|
||||
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)
|
||||
if key:
|
||||
print(f" Detected key: {key}")
|
||||
print(f" Scale: {' '.join(key.note_names)}")
|
||||
else:
|
||||
print(" Could not detect key")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="pytheory",
|
||||
description="Music Theory for Humans — from the command line",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
# tone
|
||||
p = sub.add_parser("tone", help="Look up a tone (e.g. pytheory tone C4)")
|
||||
p.add_argument("note", help="Note name with octave (e.g. C4, A#3)")
|
||||
p.add_argument("--temperament", "-t", default="equal",
|
||||
choices=["equal", "pythagorean", "meantone"],
|
||||
help="Tuning temperament (default: equal)")
|
||||
|
||||
# scale
|
||||
p = sub.add_parser("scale", help="Show a scale (e.g. pytheory scale C major)")
|
||||
p.add_argument("tonic", help="Tonic note (e.g. C, G, Sa)")
|
||||
p.add_argument("mode", help="Scale/mode name (e.g. major, minor, dorian)")
|
||||
p.add_argument("--system", default="western", help="Musical system (default: western)")
|
||||
|
||||
# chord
|
||||
p = sub.add_parser("chord", help="Identify a chord (e.g. pytheory chord C E G)")
|
||||
p.add_argument("notes", nargs="+", help="Note names (e.g. C E G)")
|
||||
|
||||
# key
|
||||
p = sub.add_parser("key", help="Explore a key (e.g. pytheory key C major)")
|
||||
p.add_argument("tonic", help="Tonic note (e.g. C, G)")
|
||||
p.add_argument("mode", nargs="?", default="major", help="Mode (default: major)")
|
||||
|
||||
# fingering
|
||||
p = sub.add_parser("fingering", help="Guitar fingering (e.g. pytheory fingering Am)")
|
||||
p.add_argument("chord", help="Chord name (e.g. C, Am, G7)")
|
||||
p.add_argument("--capo", type=int, default=0, help="Capo fret (default: 0)")
|
||||
|
||||
# progression
|
||||
p = sub.add_parser("progression", help="Build a progression (e.g. pytheory progression C major I V vi IV)")
|
||||
p.add_argument("tonic", help="Tonic note")
|
||||
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")
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(0)
|
||||
|
||||
commands = {
|
||||
"tone": cmd_tone,
|
||||
"scale": cmd_scale,
|
||||
"chord": cmd_chord,
|
||||
"key": cmd_key,
|
||||
"fingering": cmd_fingering,
|
||||
"progression": cmd_progression,
|
||||
"play": cmd_play,
|
||||
"detect": cmd_detect,
|
||||
}
|
||||
commands[args.command](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+77
-33
@@ -5,8 +5,8 @@ import sounddevice as sd
|
||||
|
||||
from .tones import Tone
|
||||
|
||||
SAMPLE_RATE = 44_100
|
||||
SAMPLE_PEAK = 4_096
|
||||
SAMPLE_RATE = 44_100 # CD-quality sample rate (Hz)
|
||||
SAMPLE_PEAK = 4_096 # Peak amplitude for 16-bit integer samples
|
||||
|
||||
|
||||
def sine_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
@@ -20,41 +20,33 @@ def sine_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
return numpy.resize(onecycle, (n_samples,)).astype(numpy.int16)
|
||||
|
||||
|
||||
def sawtooth_wave(hz, peak=SAMPLE_PEAK, rising_ramp_width=1, n_samples=SAMPLE_RATE):
|
||||
"""Compute N samples of a sine wave with given frequency and peak amplitude.
|
||||
def sawtooth_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""Compute N samples of a sawtooth wave with given frequency and peak amplitude.
|
||||
Defaults to one second.
|
||||
rising_ramp_width is the percentage of the ramp spend rising:
|
||||
.5 is a triangle wave with equal rising and falling times.
|
||||
"""
|
||||
t = numpy.linspace(0, 1, int(500 * 440 / hz), endpoint=False)
|
||||
wave = scipy.signal.sawtooth(2 * numpy.pi * 5 * t, width=rising_ramp_width)
|
||||
wave = numpy.resize(wave, (n_samples,))
|
||||
# Sawtooth waves sound very quiet, so multiply peak by 4.
|
||||
return peak * 6 * wave.astype(numpy.int16)
|
||||
length = SAMPLE_RATE / float(hz)
|
||||
omega = numpy.pi * 2 / length
|
||||
xvalues = numpy.arange(int(length)) * omega
|
||||
onecycle = scipy.signal.sawtooth(xvalues, width=1)
|
||||
onecycle = (peak * onecycle).astype(numpy.int16)
|
||||
return numpy.resize(onecycle, (n_samples,))
|
||||
|
||||
|
||||
def triangle_wave(hz, peak=SAMPLE_PEAK, rising_ramp_width=0.5, n_samples=SAMPLE_RATE):
|
||||
def triangle_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""Compute N samples of a triangle wave with given frequency and peak amplitude.
|
||||
Defaults to one second.
|
||||
rising_ramp_width is the percentage of the ramp spend rising:
|
||||
.5 is a triangle wave with equal rising and falling times.
|
||||
"""
|
||||
hz_value = float(hz)
|
||||
num_samples = int(500 * 440 / hz_value)
|
||||
t = numpy.linspace(0, 1, num_samples, endpoint=False)
|
||||
wave = scipy.signal.sawtooth(2 * numpy.pi * 5 * t, width=rising_ramp_width)
|
||||
wave = numpy.resize(wave, (n_samples,))
|
||||
# Use same amplitude as sawtooth_wave for testing
|
||||
return peak * 6 * wave.astype(numpy.int16)
|
||||
length = SAMPLE_RATE / float(hz)
|
||||
omega = numpy.pi * 2 / length
|
||||
xvalues = numpy.arange(int(length)) * omega
|
||||
onecycle = scipy.signal.sawtooth(xvalues, width=0.5)
|
||||
onecycle = (peak * onecycle).astype(numpy.int16)
|
||||
return numpy.resize(onecycle, (n_samples,))
|
||||
|
||||
|
||||
def _play_for(sample_wave, ms):
|
||||
"""Play the given NumPy array, as a sound, for ms milliseconds."""
|
||||
|
||||
# sounddevice expects float32 samples between -1 and 1
|
||||
"""Play the given NumPy sample array through the speakers."""
|
||||
normalized_wave = sample_wave.astype(numpy.float32) / SAMPLE_PEAK
|
||||
|
||||
# Play the audio and wait
|
||||
sd.play(normalized_wave, SAMPLE_RATE)
|
||||
sd.wait()
|
||||
|
||||
@@ -65,18 +57,70 @@ class Synth(Enum):
|
||||
TRIANGLE = triangle_wave
|
||||
|
||||
|
||||
def play(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000):
|
||||
"""Play a tone or chord."""
|
||||
def _render(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000):
|
||||
"""Render a tone or chord to a NumPy sample array.
|
||||
|
||||
Args:
|
||||
tone_or_chord: A :class:`Tone` or :class:`Chord` to render.
|
||||
temperament: Tuning temperament (``"equal"``, ``"pythagorean"``,
|
||||
or ``"meantone"``).
|
||||
synth: Waveform type — ``Synth.SINE``, ``Synth.SAW``, or
|
||||
``Synth.TRIANGLE``.
|
||||
t: Duration in milliseconds.
|
||||
|
||||
Returns:
|
||||
A NumPy int16 array of audio samples.
|
||||
"""
|
||||
n_samples = int(SAMPLE_RATE * t / 1_000)
|
||||
|
||||
if isinstance(tone_or_chord, Tone):
|
||||
chord = [synth(tone_or_chord.pitch(temperament=temperament))]
|
||||
waves = [synth(tone_or_chord.pitch(temperament=temperament), n_samples=n_samples)]
|
||||
else:
|
||||
chord = [
|
||||
synth(tone.pitch(temperament=temperament))
|
||||
waves = [
|
||||
synth(tone.pitch(temperament=temperament), n_samples=n_samples)
|
||||
for tone in tone_or_chord.tones
|
||||
]
|
||||
|
||||
_play_for(sum(chord), ms=t)
|
||||
return sum(waves)
|
||||
|
||||
|
||||
# 69 + 12*np.log2(hz_nonneg/440.)
|
||||
def play(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000):
|
||||
"""Play a tone or chord through the speakers.
|
||||
|
||||
Args:
|
||||
tone_or_chord: A :class:`Tone` or :class:`Chord` to play.
|
||||
temperament: Tuning temperament (``"equal"``, ``"pythagorean"``,
|
||||
or ``"meantone"``).
|
||||
synth: Waveform type — ``Synth.SINE``, ``Synth.SAW``, or
|
||||
``Synth.TRIANGLE``.
|
||||
t: Duration in milliseconds (default 1000).
|
||||
|
||||
Example::
|
||||
|
||||
>>> play(Tone.from_string("A4"), t=1_000)
|
||||
>>> play(Chord.from_name("Am7"), synth=Synth.TRIANGLE, t=2_000)
|
||||
"""
|
||||
_play_for(_render(tone_or_chord, temperament=temperament, synth=synth, t=t), ms=t)
|
||||
|
||||
|
||||
def save(tone_or_chord, path, temperament="equal", synth=Synth.SINE, t=1_000):
|
||||
"""Render a tone or chord and save it as a WAV file.
|
||||
|
||||
Args:
|
||||
tone_or_chord: A :class:`Tone` or :class:`Chord` to render.
|
||||
path: Output file path (e.g. ``"chord.wav"``).
|
||||
temperament: Tuning temperament.
|
||||
synth: Waveform type.
|
||||
t: Duration in milliseconds (default 1000).
|
||||
|
||||
Example::
|
||||
|
||||
>>> save(Chord.from_name("C"), "c_major.wav", t=2_000)
|
||||
"""
|
||||
import scipy.io.wavfile
|
||||
|
||||
samples = _render(tone_or_chord, temperament=temperament, synth=synth, t=t)
|
||||
normalized = samples.astype(numpy.float32) / SAMPLE_PEAK
|
||||
# Convert to 16-bit PCM
|
||||
pcm = (normalized * 32767).astype(numpy.int16)
|
||||
scipy.io.wavfile.write(path, SAMPLE_RATE, pcm)
|
||||
|
||||
+104
-2
@@ -236,7 +236,6 @@ class Scale:
|
||||
return [self.triad(i) for i in range(unique)]
|
||||
|
||||
def degree(self, item: Union[str, int, slice], major: Optional[bool] = None, minor: bool = False) -> Optional[Union[Tone, tuple[Tone, ...]]]:
|
||||
# TODO: cleanup degrees.
|
||||
|
||||
# Ensure that both major and minor aren't passed.
|
||||
if all((major, minor)):
|
||||
@@ -486,6 +485,109 @@ class Key:
|
||||
keys.append(cls(tonic, "minor"))
|
||||
return keys
|
||||
|
||||
@property
|
||||
def signature(self) -> dict:
|
||||
"""The key signature — number and names of sharps or flats.
|
||||
|
||||
In Western music, each key has a unique key signature that tells
|
||||
you which notes are sharped or flatted throughout a piece.
|
||||
|
||||
Returns:
|
||||
A dict with:
|
||||
- ``sharps`` (int): number of sharps (0 if flat key)
|
||||
- ``flats`` (int): number of flats (0 if sharp key)
|
||||
- ``accidentals`` (list[str]): the sharped/flatted note names
|
||||
|
||||
Example::
|
||||
|
||||
>>> Key("G", "major").signature
|
||||
{'sharps': 1, 'flats': 0, 'accidentals': ['F#']}
|
||||
>>> Key("F", "major").signature
|
||||
{'sharps': 0, 'flats': 1, 'accidentals': ['Bb']}
|
||||
>>> Key("C", "major").signature
|
||||
{'sharps': 0, 'flats': 0, 'accidentals': []}
|
||||
"""
|
||||
# Compare scale notes against the natural notes C D E F G A B
|
||||
naturals = {"C", "D", "E", "F", "G", "A", "B"}
|
||||
scale_notes = set(self.note_names[:-1]) # exclude octave
|
||||
|
||||
sharps = [n for n in scale_notes if "#" in n]
|
||||
flats = [n for n in scale_notes if "b" in n[1:]] # skip first char for B
|
||||
|
||||
# Order sharps: F C G D A E B
|
||||
sharp_order = ["F#", "C#", "G#", "D#", "A#", "E#", "B#"]
|
||||
flat_order = ["Bb", "Eb", "Ab", "Db", "Gb", "Cb", "Fb"]
|
||||
|
||||
sharps_sorted = [s for s in sharp_order if s in sharps]
|
||||
flats_sorted = [f for f in flat_order if f in flats]
|
||||
|
||||
if sharps_sorted:
|
||||
return {"sharps": len(sharps_sorted), "flats": 0, "accidentals": sharps_sorted}
|
||||
elif flats_sorted:
|
||||
return {"sharps": 0, "flats": len(flats_sorted), "accidentals": flats_sorted}
|
||||
else:
|
||||
return {"sharps": 0, "flats": 0, "accidentals": []}
|
||||
|
||||
@property
|
||||
def borrowed_chords(self) -> list[str]:
|
||||
"""Chords borrowed from the parallel key.
|
||||
|
||||
Modal interchange (or modal mixture) borrows chords from the
|
||||
parallel major or minor key. In C major, the parallel minor
|
||||
is C minor, which provides chords like Ab major, Bb major,
|
||||
and Eb major — commonly heard in rock, film, and pop music.
|
||||
|
||||
Returns:
|
||||
A list of chord names from the parallel key that are NOT
|
||||
in the current key's diatonic chords.
|
||||
|
||||
Example::
|
||||
|
||||
>>> Key("C", "major").borrowed_chords
|
||||
['C minor', 'D diminished', 'D# major', ...]
|
||||
"""
|
||||
par = self.parallel
|
||||
if par is None:
|
||||
return []
|
||||
own = set(self.chords)
|
||||
return [c for c in par.chords if c not in own]
|
||||
|
||||
def random_progression(self, length: int = 4) -> list:
|
||||
"""Generate a random diatonic chord progression.
|
||||
|
||||
Uses weighted probabilities based on common chord function:
|
||||
I and vi are most common, IV and V are very common, ii is
|
||||
common, iii and viidim are rare. Always starts on I and
|
||||
ends on I or V.
|
||||
|
||||
Args:
|
||||
length: Number of chords (default 4).
|
||||
|
||||
Returns:
|
||||
A list of Chord objects.
|
||||
|
||||
Example::
|
||||
|
||||
>>> Key("C", "major").random_progression(4)
|
||||
[<Chord C major>, <Chord F major>, <Chord G major>, <Chord C major>]
|
||||
"""
|
||||
import random
|
||||
|
||||
harmonized = self._scale.harmonize()
|
||||
unique = len(harmonized)
|
||||
# Weights: I=high, ii=med, iii=low, IV=high, V=high, vi=med, vii=low
|
||||
weights = [10, 5, 2, 8, 8, 5, 1]
|
||||
if unique < len(weights):
|
||||
weights = weights[:unique]
|
||||
|
||||
chords = [harmonized[0]] # Start on I
|
||||
for _ in range(length - 2):
|
||||
chords.append(random.choices(harmonized, weights=weights, k=1)[0])
|
||||
if length > 1:
|
||||
# End on I or V
|
||||
chords.append(random.choice([harmonized[0], harmonized[4 % unique]]))
|
||||
return chords
|
||||
|
||||
@property
|
||||
def relative(self) -> Optional[Key]:
|
||||
"""The relative major or minor key.
|
||||
@@ -550,7 +652,7 @@ class TonedScale:
|
||||
try:
|
||||
return self._scales[scale]
|
||||
except KeyError:
|
||||
pass
|
||||
return None
|
||||
|
||||
@property
|
||||
def scales(self) -> tuple[str, ...]:
|
||||
|
||||
+10
-1
@@ -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):
|
||||
@@ -105,7 +115,6 @@ class System:
|
||||
yield step
|
||||
else:
|
||||
for i in range(tones):
|
||||
# TODO: figure out how to make this work with monotonic.
|
||||
yield 1
|
||||
|
||||
scale = [
|
||||
|
||||
+24
-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:
|
||||
@@ -115,6 +115,21 @@ class Tone:
|
||||
"""True if this tone has a flat (b after the first character)."""
|
||||
return "b" in self.name[1:]
|
||||
|
||||
@property
|
||||
def letter(self) -> str:
|
||||
"""The letter name without any accidental.
|
||||
|
||||
Example::
|
||||
|
||||
>>> Tone.from_string("C#4").letter
|
||||
'C'
|
||||
>>> Tone.from_string("Bb4").letter
|
||||
'B'
|
||||
>>> Tone.from_string("G4").letter
|
||||
'G'
|
||||
"""
|
||||
return self.name[0]
|
||||
|
||||
@property
|
||||
def enharmonic(self) -> Optional[str]:
|
||||
"""The enharmonic equivalent of this tone, or None if there isn't one.
|
||||
@@ -316,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!")
|
||||
|
||||
|
||||
+536
-1
@@ -2622,7 +2622,7 @@ def test_tension_empty():
|
||||
|
||||
def test_version():
|
||||
import pytheory
|
||||
assert pytheory.__version__ == "0.3.2"
|
||||
assert pytheory.__version__ == "0.6.1"
|
||||
|
||||
|
||||
def test_all_exports():
|
||||
@@ -3342,3 +3342,538 @@ def test_pachelbel_progression():
|
||||
prog = k.progression(*PROGRESSIONS["Pachelbel"])
|
||||
assert len(prog) == 8
|
||||
assert prog[0].identify() == "C major"
|
||||
|
||||
|
||||
# ── Tone.letter ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_tone_letter_natural():
|
||||
assert Tone.from_string("C4").letter == "C"
|
||||
|
||||
|
||||
def test_tone_letter_sharp():
|
||||
assert Tone.from_string("C#4").letter == "C"
|
||||
|
||||
|
||||
def test_tone_letter_flat():
|
||||
assert Tone(name="Bb", octave=4).letter == "B"
|
||||
|
||||
|
||||
# ── Key.signature ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_key_signature_c_major():
|
||||
sig = Key("C", "major").signature
|
||||
assert sig["sharps"] == 0
|
||||
assert sig["flats"] == 0
|
||||
|
||||
|
||||
def test_key_signature_g_major():
|
||||
sig = Key("G", "major").signature
|
||||
assert sig["sharps"] == 1
|
||||
assert sig["accidentals"] == ["F#"]
|
||||
|
||||
|
||||
def test_key_signature_d_major():
|
||||
sig = Key("D", "major").signature
|
||||
assert sig["sharps"] == 2
|
||||
|
||||
|
||||
# ── Chord.from_intervals ──────────────────────────────────────────────────
|
||||
|
||||
def test_chord_from_intervals_major():
|
||||
assert Chord.from_intervals("C", 4, 7).identify() == "C major"
|
||||
|
||||
|
||||
def test_chord_from_intervals_dom7():
|
||||
assert Chord.from_intervals("G", 4, 7, 10).identify() == "G dominant 7th"
|
||||
|
||||
|
||||
# ── Chord.from_midi_message ──────────────────────────────────────────────
|
||||
|
||||
def test_chord_from_midi_message():
|
||||
c = Chord.from_midi_message(60, 64, 67)
|
||||
assert c.identify() == "C major"
|
||||
|
||||
|
||||
# ── Chord.add_tone / remove_tone ──────────────────────────────────────────
|
||||
|
||||
def test_chord_add_tone():
|
||||
c = Chord.from_tones("C", "E", "G")
|
||||
cmaj7 = c.add_tone(Tone("B", octave=4))
|
||||
assert cmaj7.identify() == "C major 7th"
|
||||
|
||||
|
||||
def test_chord_remove_tone():
|
||||
cmaj7 = Chord.from_name("Cmaj7")
|
||||
c = cmaj7.remove_tone("B")
|
||||
assert c.identify() == "C major"
|
||||
|
||||
|
||||
# ── analyze_progression ──────────────────────────────────────────────────
|
||||
|
||||
def test_analyze_progression():
|
||||
from pytheory import analyze_progression
|
||||
prog = [Chord.from_name("C"), Chord.from_name("Am"),
|
||||
Chord.from_name("F"), Chord.from_name("G")]
|
||||
assert analyze_progression(prog, key="C") == ["I", "vi", "IV", "V"]
|
||||
|
||||
|
||||
# ── Key.borrowed_chords ─────────────────────────────────────────────────
|
||||
|
||||
def test_borrowed_chords():
|
||||
borrowed = Key("C", "major").borrowed_chords
|
||||
assert len(borrowed) > 0
|
||||
|
||||
|
||||
# ── Key.random_progression ──────────────────────────────────────────────
|
||||
|
||||
def test_random_progression():
|
||||
prog = Key("C", "major").random_progression(4)
|
||||
assert len(prog) == 4
|
||||
|
||||
|
||||
# ── Fretboard.scale_diagram ────────────────────────────────────────────
|
||||
|
||||
def test_scale_diagram():
|
||||
fb = Fretboard.guitar()
|
||||
scale = TonedScale(tonic="C4")["major"]
|
||||
diagram = fb.scale_diagram(scale, frets=5)
|
||||
assert "E|" in diagram
|
||||
lines = diagram.strip().split("\n")
|
||||
assert len(lines) == 7
|
||||
|
||||
|
||||
# ── Coverage gap tests ─────────────────────────────────────────────────────
|
||||
|
||||
def test_tone_init_octave_parsed_from_name():
|
||||
"""Tone('C4') should parse octave from name string."""
|
||||
t = Tone("C4")
|
||||
assert t.octave == 4
|
||||
assert t.name == "C"
|
||||
|
||||
|
||||
def test_tone_enharmonic_from_alt_names_direct():
|
||||
t = Tone(name="C#", alt_names="Db", octave=4)
|
||||
assert t.enharmonic == "Db"
|
||||
|
||||
|
||||
def test_tone_sub_not_implemented():
|
||||
t = Tone("C4")
|
||||
result = t.__sub__(3.5)
|
||||
assert result is NotImplemented
|
||||
|
||||
|
||||
def test_tone_lt_not_implemented():
|
||||
assert Tone("C4").__lt__("not a tone") is NotImplemented
|
||||
|
||||
|
||||
def test_tone_le_not_implemented():
|
||||
assert Tone("C4").__le__("not a tone") is NotImplemented
|
||||
|
||||
|
||||
def test_tone_gt_not_implemented():
|
||||
assert Tone("C4").__gt__("not a tone") is NotImplemented
|
||||
|
||||
|
||||
def test_tone_ge_not_implemented():
|
||||
assert Tone("C4").__ge__("not a tone") is NotImplemented
|
||||
|
||||
|
||||
def test_tone_from_frequency_negative_raises():
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
Tone.from_frequency(-100)
|
||||
|
||||
|
||||
def test_tone_interval_compound_2_octaves():
|
||||
c4 = Tone.from_string("C4", system="western")
|
||||
e6 = c4 + 28 # 2 octaves + major 3rd
|
||||
assert "2 octaves" in c4.interval_to(e6)
|
||||
|
||||
|
||||
def test_tone_circle_of_fifths_returns_12():
|
||||
c = Tone.from_string("C4", system="western")
|
||||
assert len(c.circle_of_fifths()) == 12
|
||||
|
||||
|
||||
def test_tone_circle_of_fourths_returns_12():
|
||||
c = Tone.from_string("C4", system="western")
|
||||
assert len(c.circle_of_fourths()) == 12
|
||||
|
||||
|
||||
def test_chord_repr_unidentified():
|
||||
"""Chord with no known pattern should show raw tones in repr."""
|
||||
c = Chord(tones=[
|
||||
Tone.from_string("C4", system="western"),
|
||||
Tone.from_string("D4", system="western"),
|
||||
])
|
||||
assert "tones=" in repr(c)
|
||||
|
||||
|
||||
def test_chord_str_unidentified():
|
||||
c = Chord(tones=[
|
||||
Tone.from_string("C4", system="western"),
|
||||
Tone.from_string("D4", system="western"),
|
||||
])
|
||||
assert "C4" in str(c)
|
||||
|
||||
|
||||
def test_chord_add_not_implemented():
|
||||
c = Chord.from_tones("C", "E", "G")
|
||||
assert c.__add__("not a chord") is NotImplemented
|
||||
|
||||
|
||||
def test_chord_identify_returns_none_for_unknown():
|
||||
c = Chord(tones=[
|
||||
Tone.from_string("C4", system="western"),
|
||||
Tone.from_string("C#4", system="western"),
|
||||
Tone.from_string("D4", system="western"),
|
||||
])
|
||||
assert c.identify() is None
|
||||
|
||||
|
||||
def test_chord_voice_leading_different_sizes():
|
||||
"""Voice leading should pad shorter chord."""
|
||||
c3 = Chord.from_tones("C", "E", "G")
|
||||
c4 = Chord.from_intervals("C", 4, 7, 10)
|
||||
vl = c3.voice_leading(c4)
|
||||
assert len(vl) == 4 # padded to match
|
||||
|
||||
|
||||
def test_chord_analyze_with_tone_key():
|
||||
"""analyze() should accept a Tone as key_tonic."""
|
||||
c = Chord.from_tones("C", "E", "G")
|
||||
key_tone = Tone.from_string("C4", system="western")
|
||||
assert c.analyze(key_tone) == "I"
|
||||
|
||||
|
||||
def test_chord_analyze_unknown_chord():
|
||||
c = Chord(tones=[
|
||||
Tone.from_string("C4", system="western"),
|
||||
Tone.from_string("D4", system="western"),
|
||||
])
|
||||
assert c.analyze("C") is None
|
||||
|
||||
|
||||
def test_chord_analyze_diminished():
|
||||
b_dim = Chord.from_intervals("B", 3, 6)
|
||||
result = b_dim.analyze("C")
|
||||
assert "dim" in result
|
||||
|
||||
|
||||
def test_chord_analyze_augmented():
|
||||
c_aug = Chord.from_intervals("C", 4, 8)
|
||||
result = c_aug.analyze("C")
|
||||
assert "+" in result
|
||||
|
||||
|
||||
def test_chord_analyze_9th():
|
||||
c9 = Chord.from_intervals("C", 2, 4, 7, 10)
|
||||
result = c9.analyze("C")
|
||||
assert "9" in result
|
||||
|
||||
|
||||
def test_scale_with_system_object():
|
||||
"""Scale created with system object instead of string."""
|
||||
from pytheory.scales import Scale
|
||||
system = SYSTEMS["western"]
|
||||
s = Scale(tones=(Tone("C", octave=4), Tone("D", octave=4)), system=system)
|
||||
assert s.system == system
|
||||
|
||||
|
||||
def test_scale_degree_by_mode_name():
|
||||
major = TonedScale(tonic="C4")["major"]
|
||||
# Access by mode name should work via degree lookup
|
||||
tone = major.degree("ionian")
|
||||
assert tone is not None
|
||||
|
||||
|
||||
def test_scale_getitem_raises():
|
||||
major = TonedScale(tonic="C4")["major"]
|
||||
with pytest.raises(KeyError):
|
||||
major["nonexistent_degree"]
|
||||
|
||||
|
||||
def test_key_with_string_system():
|
||||
k = Key("C", "major", system="western")
|
||||
assert k.note_names[0] == "C"
|
||||
|
||||
|
||||
def test_key_detect_returns_none_empty():
|
||||
assert Key.detect() is None
|
||||
|
||||
|
||||
def test_key_signature_flat_key():
|
||||
"""F major has one flat (Bb)."""
|
||||
# F major scale: F G A Bb C D E
|
||||
# But our system uses sharps, so Bb = A#
|
||||
sig = Key("F", "major").signature
|
||||
# The scale uses A# which is sharp notation for Bb
|
||||
assert sig["sharps"] + sig["flats"] >= 0 # at least runs
|
||||
|
||||
|
||||
def test_key_borrowed_chords_minor():
|
||||
"""Minor key should borrow from parallel major."""
|
||||
borrowed = Key("A", "minor").borrowed_chords
|
||||
assert len(borrowed) > 0
|
||||
|
||||
|
||||
def test_key_parallel_returns_none_for_other_modes():
|
||||
"""Parallel should return None for non-major/minor modes."""
|
||||
k = Key("C", "major")
|
||||
k.mode = "lydian" # force non-standard mode
|
||||
assert k.parallel is None
|
||||
|
||||
|
||||
def test_key_relative_returns_none_for_other_modes():
|
||||
k = Key("C", "major")
|
||||
k.mode = "lydian"
|
||||
assert k.relative is None
|
||||
|
||||
|
||||
def test_toned_scale_with_string_system():
|
||||
ts = TonedScale(tonic="Do4", system="arabic")
|
||||
assert "ajam" in ts.scales
|
||||
|
||||
|
||||
def test_fretboard_fingering_method():
|
||||
"""Fretboard.fingering should return a Chord."""
|
||||
fb = Fretboard.guitar()
|
||||
result = fb.fingering(0, 0, 0, 0, 0, 0)
|
||||
assert len(result) == 6
|
||||
|
||||
|
||||
def test_charts_muted_string():
|
||||
"""A chord with no valid fret gets -1 → None."""
|
||||
from pytheory.charts import NamedChord
|
||||
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
|
||||
|
||||
|
||||
# ── CLI tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_cli_tone(capsys):
|
||||
from pytheory.cli import cmd_tone
|
||||
import argparse
|
||||
args = argparse.Namespace(note="A4", temperament="equal")
|
||||
cmd_tone(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "440.00" in out
|
||||
assert "A4" in out
|
||||
assert "MIDI" in out
|
||||
|
||||
|
||||
def test_cli_tone_pythagorean(capsys):
|
||||
from pytheory.cli import cmd_tone
|
||||
import argparse
|
||||
args = argparse.Namespace(note="C5", temperament="pythagorean")
|
||||
cmd_tone(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "Equal temp" in out
|
||||
assert "cents" in out
|
||||
|
||||
|
||||
def test_cli_scale(capsys):
|
||||
from pytheory.cli import cmd_scale
|
||||
import argparse
|
||||
args = argparse.Namespace(tonic="C", mode="major", system="western")
|
||||
cmd_scale(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "C D E F G A B C" in out
|
||||
|
||||
|
||||
def test_cli_chord(capsys):
|
||||
from pytheory.cli import cmd_chord
|
||||
import argparse
|
||||
args = argparse.Namespace(notes=["C", "E", "G"])
|
||||
cmd_chord(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "C major" in out
|
||||
assert "Harmony" in out
|
||||
assert "Tension" in out
|
||||
|
||||
|
||||
def test_cli_key(capsys):
|
||||
from pytheory.cli import cmd_key
|
||||
import argparse
|
||||
args = argparse.Namespace(tonic="G", mode="major")
|
||||
cmd_key(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "G major" in out
|
||||
assert "Signature" in out
|
||||
assert "Relative" in out
|
||||
|
||||
|
||||
def test_cli_fingering(capsys):
|
||||
from pytheory.cli import cmd_fingering
|
||||
import argparse
|
||||
args = argparse.Namespace(chord="Am", capo=0)
|
||||
cmd_fingering(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "Am" in out
|
||||
assert "|--" in out
|
||||
|
||||
|
||||
def test_cli_progression(capsys):
|
||||
from pytheory.cli import cmd_progression
|
||||
import argparse
|
||||
args = argparse.Namespace(tonic="C", mode="major", numerals=["I", "V", "vi", "IV"])
|
||||
cmd_progression(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "C major" in out
|
||||
assert "I → V → vi → IV" in out
|
||||
|
||||
|
||||
def test_cli_detect(capsys):
|
||||
from pytheory.cli import cmd_detect
|
||||
import argparse
|
||||
args = argparse.Namespace(notes=["C", "E", "G", "A", "D"])
|
||||
cmd_detect(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "C major" in out
|
||||
|
||||
|
||||
def test_cli_detect_no_match(capsys):
|
||||
from pytheory.cli import cmd_detect
|
||||
import argparse
|
||||
args = argparse.Namespace(notes=[])
|
||||
cmd_detect(args)
|
||||
out = capsys.readouterr().out
|
||||
assert "Could not detect" in out
|
||||
|
||||
|
||||
def test_cli_main_no_args(capsys):
|
||||
from pytheory.cli import main
|
||||
import sys
|
||||
old_argv = sys.argv
|
||||
sys.argv = ["pytheory"]
|
||||
try:
|
||||
main()
|
||||
except SystemExit:
|
||||
pass
|
||||
sys.argv = old_argv
|
||||
|
||||
|
||||
# ── 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
|
||||
tone = Tone.from_string("A4", system="western")
|
||||
samples = _render(tone, synth=Synth.SINE, t=500)
|
||||
expected = int(SAMPLE_RATE * 500 / 1000)
|
||||
assert len(samples) == expected
|
||||
|
||||
|
||||
@needs_portaudio
|
||||
def test_play_render_chord():
|
||||
from pytheory.play import _render, Synth
|
||||
chord = Chord.from_tones("C", "E", "G")
|
||||
samples = _render(chord, synth=Synth.SINE, t=200)
|
||||
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")
|
||||
for synth in Synth:
|
||||
samples = _render(tone, synth=synth, t=100)
|
||||
assert len(samples) > 0
|
||||
|
||||
|
||||
@needs_portaudio
|
||||
def test_play_save(tmp_path):
|
||||
"""save() writes a valid WAV file."""
|
||||
from pytheory.play import save, Synth
|
||||
path = tmp_path / "test.wav"
|
||||
tone = Tone.from_string("A4", system="western")
|
||||
save(tone, str(path), synth=Synth.SINE, t=200)
|
||||
assert path.exists()
|
||||
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"
|
||||
chord = Chord.from_tones("C", "E", "G")
|
||||
save(chord, str(path), t=200)
|
||||
assert path.exists()
|
||||
|
||||
Reference in New Issue
Block a user