Compare commits

...

40 Commits

Author SHA1 Message Date
kennethreitz 96131da59c v0.8.1: Musically correct flat spellings in flat keys
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:24:06 -04:00
kennethreitz d2058668a6 Use musically correct flat spellings in flat keys
Flat keys now display flats (Bb, Eb, Ab) instead of sharps (A#, D#, G#).
Uses the "no duplicate letter names" rule: if building a scale with
sharps produces two notes with the same letter (e.g. C and C# in C minor),
the scale is rebuilt with flat spellings instead.

- Tone.add() and Tone.from_index() accept prefer_flats parameter
- TonedScale detects flat vs sharp per-scale automatically
- F major: Bb (not A#), Eb major: Ab Bb (not G# A#), etc.
- All tests and docs updated to match

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:22:39 -04:00
kennethreitz a5ffdc6104 Expand cookbook, fix scale_diagram alignment, add play_progression
- 11 new cookbook recipes: circle of fifths, voice leading, tension
  analysis, tritone substitution, key signatures/detection, relative
  and parallel keys, borrowed chords, secondary dominants, overtones,
  enharmonics, world scales, guitar scale visualization
- Fix scale_diagram header alignment for 2-digit fret numbers
- play_progression() for sequencing chord playback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:05:41 -04:00
kennethreitz 724a0df7b5 Extract pentatonic variable on homepage for readability
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:57:18 -04:00
kennethreitz 4750061b87 v0.8.0: Scale diagrams, cookbook, progression playback
- scale_diagram() showcased on homepage and quickstart
- New cookbook page: analyze a song, 12-bar blues, find chords in a key,
  compare scales, guitar chord chart, explore intervals
- play_progression() for sequencing chord playback with gaps
- Scale and Note aliases exported
- Version bump to 0.8.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:56:43 -04:00
kennethreitz d53d8b60dd API ergonomics, curated fingerings, bounded caching, slow test markers
- Fretboard["G"] shorthand via __getitem__
- Chord repr now shows <Chord C major> format
- Scale = TonedScale and Note = Tone aliases
- GUITAR_OVERRIDES dict with 15 curated standard chord shapes
  (F barre 133211, B barre x24442, etc.)
- Bounded caches (max 1024 entries) for fingerings and possible_fingerings
- @pytest.mark.slow on 4 chart-generation tests; fast suite runs in 2s
- Highlights section moved above CLI examples on docs homepage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:52:17 -04:00
kennethreitz de1db0aa8d Add fingering memoization, barre detection, and 4-fret span constraint
- Cache fingering results by chord name + tuning for instant repeat lookups
- chart() goes from ~53s to <1ms on second call
- Barre chord detection: when multiple strings share the lowest fret,
  count them as 1 finger instead of N
- Hard 4-fret span constraint rejects unplayable voicings
- Penalize shapes requiring more than 4 fingers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:42:45 -04:00
kennethreitz b22b3c063f Improve fingering algorithm, add convenience APIs, convert all docs to REPL style
- Fretboard.chord(), .tab(), .chart() convenience methods
- Fingering.tab() for rendering ASCII tablature
- Fingering algorithm now considers muting, fret span, root-in-bass,
  and contiguous bass-side muting for idiomatic voicings
- All docs converted from code-block:: python to pycon with >>> prompts
- All doc outputs verified against actual library output
- Tests for new methods; version test no longer checks exact string

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 08:36:31 -04:00
kennethreitz 7e1d9e76bd v0.7.0: Add Fretboard.chord() method for named chord lookups
New `fb.chord("G")` API lets you look up fingerings by chord name
instead of knowing fret positions upfront. Updates all docs to use
REPL-style examples with verified output.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:43:25 -04:00
kennethreitz ee41691728 Skip play module tests when PortAudio is not available
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:41:39 -04:00
kennethreitz 02df87af09 v0.6.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:39:48 -04:00
kennethreitz b3110c6e0e Fix TODOs, add WAV export, CLI tests, and play module tests
Play module:
- Add save() for WAV file export
- Add _render() to separate rendering from playback
- Expand play() docstring with args and examples
- Add comments to SAMPLE_RATE/SAMPLE_PEAK constants
- Remove orphaned comment at EOF

Code cleanup:
- Remove 3 TODO comments (_statics.py, systems.py, scales.py)
- Remove commented-out incomplete scale definitions
- Fix silent return in TonedScale.get() to explicit return None

Tests (15 new):
- CLI: tone, scale, chord, key, fingering, progression, detect commands
- Play: _render for tones and chords, all synth engines, WAV save

README:
- Add CLI section with usage examples
- Mention WAV export in audio and features sections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:39:12 -04:00
kennethreitz fd82dccbfd Fix sawtooth and triangle wave generation
Both were using incorrect frequency scaling (magic numbers instead of
deriving cycle length from sample rate / hz). Now they match the sine
wave approach: compute one cycle at the correct frequency, then resize.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:31:36 -04:00
kennethreitz 6f7f9008b0 Use sine wave for chord progression playback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:30:55 -04:00
kennethreitz acb92171a1 Speed up songs (BPM 120→180) and catch KeyboardInterrupt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:30:04 -04:00
kennethreitz c006f5b3da Rewrite song.py with 7 playable songs and chord progressions
Melodies: Twinkle Twinkle, Ode to Joy, Happy Birthday, Fur Elise
Progressions: Pop I-V-vi-IV, 12-bar blues in A, Jazz ii-V-I turnaround
Interactive menu for picking songs. Clean helper functions for
melody and chord progression playback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:28:31 -04:00
kennethreitz 9da3ac8b28 Add 12 example scripts showcasing pytheory features
- circle_of_fifths.py — visualize keys around the circle
- chord_identifier.py — identify chords from notes and fingerings
- key_explorer.py — explore keys, signatures, progressions, borrowed chords
- temperament_comparison.py — compare equal, Pythagorean, and meantone
- chord_tension.py — analyze tension, consonance, and voice leading
- world_scales.py — scales from 6 musical traditions
- fretboard_explorer.py — instruments, tunings, capo transposition
- midi_converter.py — MIDI ↔ note ↔ frequency reference
- progression_writer.py — famous progressions, Nashville numbers, random generation
- interval_trainer.py — interval names, songs, and consonance ranking
- overtone_series.py — harmonics and why chords sound good
- key_detection.py — detect keys from melodies and chord progressions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:27:18 -04:00
kennethreitz e94ef5dcfd Expand documentation with undocumented features and CLI guide
Tones: add from_frequency, from_midi, letter, midi, exists properties;
  interval naming with interval_to(); transpose(); MIDI section
Scales: add Key.signature, relative/parallel keys, borrowed chords,
  secondary dominants, random progressions, all_keys, scale transpose
Chords: add transpose, add_tone/remove_tone, root/quality properties;
  simplify identification examples with from_tones()
CLI: new guide covering all 8 commands (tone, scale, chord, key,
  fingering, progression, play, detect)

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:11:37 -04:00
kennethreitz 6aad427fb8 Fix 'pytheory play' chord name parsing for names containing digits
Chord names like Cmaj7 and G7 were incorrectly treated as tone names
because they contain digits. Now tries chord name lookup first. v0.5.1.

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 13:40:20 -04:00
kennethreitz c307c1e41f v0.4.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 13:34:04 -04:00
kennethreitz b1f6996cd7 Add --temperament flag to CLI tone command
pytheory tone C5 -t pythagorean
pytheory tone A4 -t meantone

Shows frequency in chosen temperament and difference in cents
from equal temperament. Supports equal, pythagorean, meantone.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 13:08:24 -04:00
kennethreitz 51ca98779d Add CLI tool and Jupyter notebook tutorial
CLI (pytheory command):
  pytheory tone C4          — frequency, MIDI, overtones
  pytheory scale C major    — notes and intervals
  pytheory chord C E G      — identify, harmony, tension
  pytheory key C major      — full key analysis with diatonic chords
  pytheory fingering Am     — ASCII guitar tab
  pytheory progression C major I V vi IV — build from Roman numerals
  pytheory detect C D E G   — detect the key

Jupyter notebook (examples/tutorial.ipynb):
  46-cell interactive tutorial covering tones, scales, modes, keys,
  chord analysis, progressions, world music systems, guitar fingerings,
  and building a song from scratch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 13:05:33 -04:00
kennethreitz 37b41e1bbf Improve test coverage from 93% to 97% (476 tests)
Added 33 targeted tests covering:
- Tone: NotImplemented returns on comparison operators, negative
  frequency error, compound intervals, circle methods, octave
  parsing, enharmonic edge cases
- Chord: unidentified chord repr/str, __add__ NotImplemented,
  voice leading with different sizes, analyze with Tone key,
  diminished/augmented/9th analysis
- Scale: system object constructor, mode name degree lookup,
  KeyError on bad degree
- Key: string system param, flat key signatures, borrowed chords
  for minor, parallel/relative None returns
- Fretboard: fingering method returns Chord
- Charts: fix_fingering muted string

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:53:06 -04:00
39 changed files with 4581 additions and 721 deletions
+37 -2
View File
@@ -62,6 +62,22 @@ $ pip install pytheory
['C major', 'G major', 'A minor', 'F major']
```
## Keys and Progressions
```pycon
>>> from pytheory import Key
>>> key = Key("G", "major")
>>> key.chords
['G major', 'A minor', 'B minor', 'C major', 'D major', 'E minor', 'F# diminished']
>>> [c.identify() for c in key.progression("I", "V", "vi", "IV")]
['G major', 'D major', 'E minor', 'C major']
>>> Key.detect("C", "E", "G", "A", "D")
<Key C major>
```
## Chord Analysis
```pycon
@@ -116,7 +132,10 @@ $ pip install pytheory
>>> Fretboard.keyboard(25, "C3") # 25-key MIDI controller
>>> CHARTS['western']['Am'].fingering(fretboard=Fretboard.guitar())
(0, 1, 2, 2, 0, 0)
Fingering(e=0, B=1, G=2, D=2, A=0, E=0)
>>> Fretboard.guitar().fingering(0, 1, 0, 2, 3, 0).identify()
'C major'
```
## Audio Playback
@@ -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
View File
@@ -10,7 +10,9 @@ sys.modules["sounddevice"] = MagicMock()
project = "PyTheory"
copyright = "2026, Kenneth Reitz"
author = "Kenneth Reitz"
release = "0.4.0"
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"]
+205 -115
View File
@@ -45,18 +45,20 @@ For seventh chords, there's also **third inversion** (7th in bass):
- G7 in third inversion: F G B D (notated G7/F)
.. code-block:: python
.. code-block:: pycon
from pytheory import Chord, Tone
>>> from pytheory import Chord, Tone
# All three are "C major" — identify() finds the root
root = Chord([Tone.from_string(n, system="western") for n in ["C4", "E4", "G4"]])
first = Chord([Tone.from_string(n, system="western") for n in ["E3", "G3", "C4"]])
second = Chord([Tone.from_string(n, system="western") for n in ["G3", "C4", "E4"]])
>>> root = Chord([Tone.from_string(n, system="western") for n in ["C4", "E4", "G4"]])
>>> first = Chord([Tone.from_string(n, system="western") for n in ["E3", "G3", "C4"]])
>>> second = Chord([Tone.from_string(n, system="western") for n in ["G3", "C4", "E4"]])
root.identify() # 'C major'
first.identify() # 'C major'
second.identify() # 'C major'
>>> root.identify()
'C major'
>>> first.identify()
'C major'
>>> second.identify()
'C major'
Extended Chords
---------------
@@ -72,33 +74,42 @@ A full 13th chord contains all 7 notes of the scale! In practice,
tones are usually omitted — the 5th is typically dropped first, then
the 11th (which clashes with the 3rd in dominant chords).
.. code-block:: python
.. code-block:: pycon
from pytheory import TonedScale
>>> from pytheory import TonedScale
scale = TonedScale(tonic="C4")["major"]
>>> scale = TonedScale(tonic="C4")["major"]
# Build a Cmaj9 from the scale: C E G B D
cmaj9 = scale.chord(0, 2, 4, 6, 8)
# Build a full C13 (in theory): C E G B D F A
c13 = scale.chord(0, 2, 4, 6, 8, 10, 12)
>>> cmaj9 = scale.chord(0, 2, 4, 6, 8)
>>> c13 = scale.chord(0, 2, 4, 6, 8, 10, 12)
Using the Chord Chart
---------------------
PyTheory includes 144 pre-built chords (12 roots x 12 qualities):
.. code-block:: python
.. code-block:: pycon
from pytheory import CHARTS
>>> from pytheory import Fretboard
chart = CHARTS["western"]
>>> fb = Fretboard.guitar()
>>> fb.chord("C")
Fingering(e=0, B=1, G=0, D=2, A=3, E=x)
>>> fb.chord("Am")
Fingering(e=0, B=1, G=2, D=2, A=0, E=x)
>>> fb.chord("G7")
Fingering(e=1, B=0, G=0, D=0, A=2, E=3)
c_major = chart["C"] # C major (root position)
a_minor = chart["Am"] # A minor
g_seven = chart["G7"] # G dominant 7th
d_dim = chart["Ddim"] # D diminished
You can also build chords directly with ``Chord.from_name()``:
.. code-block:: pycon
>>> from pytheory import Chord
>>> Chord.from_name("G7").identify()
'G dominant 7th'
>>> Chord.from_name("Ddim").identify()
'D diminished'
Available qualities:
@@ -119,32 +130,48 @@ Quality Intervals Example tones (from C)
``"maj9"`` 4, 7, 11, 14 C E G B D (major 9th)
============ ================ ================================
.. code-block:: python
.. code-block:: pycon
>>> from pytheory import CHARTS
>>> chart = CHARTS["western"]
>>> chart["C"].acceptable_tone_names
('C', 'E', 'G')
>>> chart["Cm7"].acceptable_tone_names
('C', 'D#', 'G', 'A#') # Eb and Bb shown as sharps
('C', 'D#', 'G', 'A#')
Building Chords Manually
-------------------------
Building Chords
---------------
.. code-block:: python
Several convenience constructors make chord creation concise:
from pytheory import Tone, Chord
.. code-block:: pycon
c_major = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
])
>>> from pytheory import Chord
for tone in c_major:
print(tone)
>>> Chord.from_tones("C", "E", "G").identify()
'C major'
>>> Chord.from_tones("A", "C", "E").identify()
'A minor'
len(c_major) # 3
"C" in c_major # True
>>> Chord.from_name("Am7").identify()
'A minor 7th'
>>> Chord.from_name("G7").identify()
'G dominant 7th'
>>> Chord.from_intervals("C", 4, 7).identify()
'C major'
>>> Chord.from_intervals("G", 4, 7, 10).identify()
'G dominant 7th'
>>> Chord.from_midi_message(60, 64, 67).identify()
'C major'
>>> len(Chord.from_name("C"))
3
>>> "C" in Chord.from_name("C")
True
Intervals
---------
@@ -152,13 +179,13 @@ Intervals
The ``intervals`` property returns semitone distances between adjacent
tones — these are musically meaningful and octave-invariant:
.. code-block:: python
.. code-block:: pycon
>>> c_major.intervals
[4, 3] # major 3rd (4) + minor 3rd (3) = major triad
>>> Chord.from_tones("C", "E", "G").intervals
[4, 3]
>>> Chord(tones=[C4, Eb4, G4]).intervals
[3, 4] # minor 3rd + major 3rd = minor triad
>>> Chord.from_tones("C", "Eb", "G").intervals
[3, 4]
Consonance and Dissonance
-------------------------
@@ -185,13 +212,16 @@ Minor 3rd 6:5 Every 6th wave aligns
Tritone 45:32 Waves rarely align
=========== ===== ====================
.. code-block:: python
.. code-block:: pycon
fifth = Chord([C4, G4])
tritone = Chord([C4, F_sharp_4])
>>> from pytheory import Chord, Tone
>>> C4 = Tone.from_string("C4", system="western")
>>> G4 = Tone.from_string("G4", system="western")
fifth.harmony > tritone.harmony # True
# The perfect fifth's 3:2 ratio scores higher
>>> fifth = Chord([C4, G4])
>>> tritone = Chord([C4, C4 + 6])
>>> fifth.harmony > tritone.harmony
True
Dissonance Score
~~~~~~~~~~~~~~~~
@@ -207,14 +237,13 @@ The roughness depends on the frequency difference relative to the
that register). Maximum roughness occurs when the difference equals
the critical bandwidth.
.. code-block:: python
.. code-block:: pycon
# Octave: frequencies far apart → low roughness
octave = Chord([C4, C5])
# Major 3rd: closer frequencies → higher roughness
third = Chord([C4, E4])
octave.dissonance < third.dissonance # True
>>> E4 = Tone.from_string("E4", system="western")
>>> octave = Chord([C4, C4 + 12])
>>> third = Chord([C4, E4])
>>> octave.dissonance < third.dissonance
True
Beat Frequencies
~~~~~~~~~~~~~~~~
@@ -227,16 +256,49 @@ you hear a pulsing at the **beat frequency**: ``|f1 - f2|`` Hz.
- **1530 Hz**: Perceived as buzzing/roughness
- **> 30 Hz**: No longer beating — becomes part of the timbre
.. code-block:: python
.. code-block:: pycon
chord = Chord(tones=[A4, E5, A5])
>>> A4 = Tone.from_string("A4", system="western")
>>> chord = Chord([A4, A4 + 7, A4 + 12])
# All pairwise beat frequencies, sorted ascending
chord.beat_frequencies
# [(A4, E5, 189.6), (E5, A5, 220.0), (A4, A5, 440.0)]
>>> chord.beat_frequencies
[...]
# The slowest (most perceptible) beat
chord.beat_pulse # 189.6 Hz
>>> round(chord.beat_pulse, 1)
219.3
Transposition
-------------
Shift an entire chord up or down by any number of semitones:
.. code-block:: pycon
>>> 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:: pycon
>>> from pytheory import Chord, Tone
>>> c_major = Chord.from_tones("C", "E", "G")
>>> b4 = Tone.from_string("B4", system="western")
>>> cmaj7 = c_major.add_tone(b4)
>>> cmaj7.identify()
'C major 7th'
>>> c_again = cmaj7.remove_tone("B")
>>> c_again.identify()
'C major'
Chord Identification
--------------------
@@ -245,25 +307,30 @@ Give PyTheory any set of tones and it will tell you what chord it is.
It tries every tone as a potential root and matches the interval pattern
against 17 known chord types (triads, 7ths, 9ths, sus, power chords).
.. code-block:: python
.. code-block:: pycon
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'
>>> 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'
>>> Chord.from_tones("Bb", "D", "F").identify()
'Bb major'
You can also access the root and quality separately:
.. code-block:: pycon
>>> chord = Chord.from_name("Am7")
>>> chord.root
<Tone A4>
>>> chord.quality
'minor 7th'
Harmonic Analysis
-----------------
@@ -273,22 +340,22 @@ key. This is how musicians describe chord progressions independent of
key — "I-IV-V" means the same thing in C major (C-F-G) as in G major
(G-C-D).
.. code-block:: python
.. code-block:: pycon
from pytheory import Chord, Tone
>>> from pytheory import Chord, Tone
C4 = Tone.from_string("C4", system="western")
D4 = Tone.from_string("D4", system="western")
E4 = Tone.from_string("E4", system="western")
F4 = Tone.from_string("F4", system="western")
G4 = Tone.from_string("G4", system="western")
A4 = Tone.from_string("A4", system="western")
B4 = Tone.from_string("B4", system="western")
>>> C4 = Tone.from_string("C4", system="western")
>>> E4 = Tone.from_string("E4", system="western")
>>> G4 = Tone.from_string("G4", system="western")
Chord([C4, E4, G4]).analyze("C") # 'I' (tonic)
Chord([D4, F4, A4]).analyze("C") # 'ii' (supertonic minor)
Chord([G4, B4, G4+5]).analyze("C") # 'V' (dominant)
Chord([G4, B4, G4+5, G4+10]).analyze("C") # 'V7' (dominant 7th)
>>> Chord([C4, E4, G4]).analyze("C")
'I'
>>> Chord.from_tones("D", "F", "A").analyze("C")
'ii'
>>> Chord([G4, G4+4, G4+7]).analyze("C")
'V'
>>> Chord([G4, G4+4, G4+7, G4+10]).analyze("C")
'V7'
Tension and Resolution
----------------------
@@ -304,18 +371,21 @@ quantifies this based on:
- **Dominant function**: the specific combination of a major 3rd and
minor 7th above the root — the hallmark of the V7 chord.
.. code-block:: python
.. code-block:: pycon
# A C major triad is fully resolved — no tension
c_major = Chord([C4, E4, G4])
c_major.tension['score'] # 0.0
c_major.tension['tritones'] # 0
>>> c_major = Chord([C4, E4, G4])
>>> c_major.tension['score']
0.0
>>> c_major.tension['tritones']
0
# G7 is loaded with tension — it wants to resolve to C
g7 = Chord([G4, B4, G4+5, G4+10])
g7.tension['score'] # 0.6
g7.tension['tritones'] # 1
g7.tension['has_dominant_function'] # True
>>> g7 = Chord([G4, G4+4, G4+7, G4+10])
>>> g7.tension['score']
0.6
>>> g7.tension['tritones']
1
>>> g7.tension['has_dominant_function']
True
Voice Leading
-------------
@@ -325,14 +395,36 @@ jumping all voices to new positions, good voice leading moves each note
the minimum distance to reach the next chord. Bach's chorales are the
gold standard — every voice moves by step whenever possible.
.. code-block:: python
.. code-block:: pycon
c_maj = Chord([C4, E4, G4])
f_maj = Chord([F4, A4, C4+12])
>>> c_maj = Chord.from_tones("C", "E", "G")
>>> f_maj = Chord.from_tones("F", "A", "C")
for src, dst, motion in c_maj.voice_leading(f_maj):
print(f"{src} -> {dst} ({motion:+d} semitones)")
# Each voice moves the minimum distance to reach the target chord
>>> for src, dst, motion in c_maj.voice_leading(f_maj):
... print(f"{src} -> {dst} ({motion:+d} semitones)")
G4 -> A4 (+2 semitones)
E4 -> F4 (+1 semitones)
C4 -> C4 (+0 semitones)
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:: pycon
>>> from pytheory import Chord
>>> g7 = Chord.from_name("G7")
>>> sub = g7.tritone_sub()
>>> sub.identify()
'C# dominant 7th'
The Overtone Series
-------------------
@@ -347,12 +439,10 @@ overtones of C already contain G. The two tones share acoustic energy,
reinforcing each other. A dissonant interval like C and C# shares
almost no overtones — the waves clash.
.. code-block:: python
.. code-block:: pycon
from pytheory import Tone
>>> from pytheory import Tone
a4 = Tone.from_string("A4", system="western")
a4.overtones(8)
# [440.0, 880.0, 1320.0, 1760.0, 2200.0, 2640.0, 3080.0, 3520.0]
# A4 A5 E6 A6 C#7 E7 ~G7 A7
# fund. oct. 5th+oct 2oct 3rd 5th ~7th 3oct
>>> a4 = Tone.from_string("A4", system="western")
>>> [round(f, 1) for f in a4.overtones(8)]
[440.0, 880.0, 1320.0, 1760.0, 2200.0, 2640.0, 3080.0, 3520.0]
+129
View File
@@ -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
+366
View File
@@ -0,0 +1,366 @@
Cookbook
=======
Real-world recipes for common musical tasks. Each recipe is self-contained
and ready to paste into a Python session.
Analyze a Song
--------------
Take the chord progression from "Let It Be" (C G Am F) and analyze it
in the key of C major:
.. code-block:: pycon
>>> from pytheory import Chord, Key
>>> C = Chord.from_name("C")
>>> G = Chord.from_name("G")
>>> Am = Chord.from_name("Am")
>>> F = Chord.from_name("F")
>>> [c.identify() for c in [C, G, Am, F]]
['C major', 'G major', 'A minor', 'F major']
>>> [c.analyze("C") for c in [C, G, Am, F]]
['I', 'V', 'vi', 'IV']
>>> key = Key("C", "major")
>>> [c.identify() for c in key.progression("I", "V", "vi", "IV")]
['C major', 'G major', 'A minor', 'F major']
Write a 12-Bar Blues
--------------------
The `12-bar blues <https://en.wikipedia.org/wiki/Twelve-bar_blues>`_ is
built from the I, IV, and V chords. Here it is in the key of A:
.. code-block:: pycon
>>> from pytheory import Key, Chord
>>> key = Key("A", "major")
>>> [c.identify() for c in key.progression("I", "IV", "V")]
['A major', 'D major', 'E major']
>>> bars = ["I","I","I","I", "IV","IV","I","I", "V","IV","I","V"]
>>> [c.identify() for c in key.progression(*bars)]
['A major', 'A major', 'A major', 'A major', 'D major', 'D major', 'A major', 'A major', 'E major', 'D major', 'A major', 'E major']
>>> Chord.from_name("A7").identify()
'A dominant 7th'
>>> Chord.from_name("D7").identify()
'D dominant 7th'
>>> Chord.from_name("E7").identify()
'E dominant 7th'
Find Chords in a Key
--------------------
The :class:`~pytheory.scales.Key` class builds diatonic chords for any
key and lets you pull progressions by Roman numeral or Nashville number:
.. code-block:: 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']
>>> [c.identify() for c in key.nashville(1, 5, 6, 4)]
['G major', 'D major', 'E minor', 'C major']
Compare Scales
--------------
Play the same tonic through different scales to hear how each mode
reshapes the palette. The western modes share the same notes but start
on different degrees; the blues scale adds the "blue note" (flat 5th):
.. code-block:: pycon
>>> from pytheory import TonedScale
>>> c = TonedScale(tonic="C4")
>>> c["major"].note_names
['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
>>> c["minor"].note_names
['C', 'D', 'Eb', 'F', 'G', 'Ab', 'Bb', 'C']
>>> c["dorian"].note_names
['C', 'D', 'Eb', 'F', 'G', 'A', 'Bb', 'C']
>>> c["mixolydian"].note_names
['C', 'D', 'E', 'F', 'G', 'A', 'Bb', 'C']
>>> c_blues = TonedScale(tonic="C4", system="blues")
>>> c_blues["blues"].note_names
['C', 'Eb', 'F', 'Gb', 'G', 'Bb', 'C']
Guitar Chord Chart
------------------
Generate fingerings for guitar and ukulele with
:class:`~pytheory.tones.Fretboard`:
.. 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=x)
>>> fb.chord("G")
Fingering(e=3, B=0, G=0, D=0, A=2, E=3)
>>> fb.chord("Am")
Fingering(e=0, B=1, G=2, D=2, A=0, E=x)
>>> fb.chord("D")
Fingering(e=2, B=3, G=2, D=0, A=x, E=x)
>>> uke = Fretboard.ukulele()
>>> uke.chord("C")
Fingering(A=3, E=0, C=0, G=0)
>>> uke.chord("G")
Fingering(A=2, E=3, C=2, G=0)
Explore an Interval
-------------------
Start from A4 (440 Hz) and walk through intervals, checking names and
frequency ratios:
.. code-block:: pycon
>>> from pytheory import Tone
>>> a4 = Tone.from_string("A4", system="western")
>>> a4.frequency
440.0
>>> minor_3rd = a4 + 3
>>> a4.interval_to(minor_3rd)
'minor 3rd'
>>> p5 = a4 + 7
>>> a4.interval_to(p5)
'perfect 5th'
>>> round(p5.frequency / a4.frequency, 4)
1.4983
>>> octave = a4 + 12
>>> a4.interval_to(octave)
'octave'
>>> round(octave.frequency / a4.frequency, 4)
2.0
Walk the Circle of Fifths
-------------------------
The `circle of fifths <https://en.wikipedia.org/wiki/Circle_of_fifths>`_
is the backbone of Western harmony — each step adds one sharp or flat:
.. code-block:: pycon
>>> from pytheory import Tone
>>> c = Tone.from_string("C4", system="western")
>>> [t.name for t in c.circle_of_fifths()]
['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'C#', 'G#', 'D#', 'A#', 'F']
>>> g = Tone.from_string("G4", system="western")
>>> [t.name for t in g.circle_of_fifths()]
['G', 'D', 'A', 'E', 'B', 'F#', 'C#', 'G#', 'D#', 'A#', 'F', 'C']
Voice Leading Between Chords
-----------------------------
Find the smoothest path from one chord to the next — each voice moves
the minimum distance:
.. code-block:: pycon
>>> from pytheory import Chord
>>> c_maj = Chord.from_tones("C", "E", "G")
>>> f_maj = Chord.from_tones("F", "A", "C")
>>> for src, dst, motion in c_maj.voice_leading(f_maj):
... print(f"{src} -> {dst} ({motion:+d} semitones)")
G4 -> A4 (+2 semitones)
E4 -> F4 (+1 semitones)
C4 -> C4 (+0 semitones)
Measure Harmonic Tension
------------------------
Quantify how much a chord "wants to resolve." Dominant 7ths have
the most tension — the tritone between the 3rd and 7th pulls toward
resolution:
.. code-block:: pycon
>>> from pytheory import Chord
>>> for name in ["C", "Am", "G7", "Cmaj7"]:
... ch = Chord.from_name(name)
... t = ch.tension
... print(f"{name:6s} tension={t['score']:.2f} tritones={t['tritones']} dominant={t['has_dominant_function']}")
C tension=0.00 tritones=0 dominant=False
Am tension=0.00 tritones=0 dominant=False
G7 tension=0.60 tritones=1 dominant=True
Cmaj7 tension=0.15 tritones=0 dominant=False
Tritone Substitution (Jazz)
---------------------------
Replace any dominant chord with the one a
`tritone <https://en.wikipedia.org/wiki/Tritone_substitution>`_ away —
they share the same tritone interval:
.. code-block:: pycon
>>> from pytheory import Chord
>>> g7 = Chord.from_name("G7")
>>> g7.tritone_sub().identify()
'C# dominant 7th'
>>> # ii-V-I with tritone sub:
>>> # Dm7 -> G7 -> Cmaj7 (standard)
>>> # Dm7 -> Db7 -> Cmaj7 (chromatic bass line!)
Key Signatures and Detection
-----------------------------
View the accidentals in any key, or detect the key from a set of notes:
.. code-block:: pycon
>>> from pytheory import Key
>>> Key("C", "major").signature
{'sharps': 0, 'flats': 0, 'accidentals': []}
>>> Key("G", "major").signature
{'sharps': 1, 'flats': 0, 'accidentals': ['F#']}
>>> Key("D", "major").signature
{'sharps': 2, 'flats': 0, 'accidentals': ['F#', 'C#']}
>>> Key.detect("C", "E", "G", "A", "D")
<Key C major>
Relative and Parallel Keys
--------------------------
Every major key has a **relative minor** (same notes, different root)
and a **parallel minor** (same root, different notes):
.. code-block:: pycon
>>> from pytheory import Key
>>> c = Key("C", "major")
>>> c.relative
'A minor'
>>> c.parallel
'C minor'
Borrowed Chords and Secondary Dominants
---------------------------------------
Add color by borrowing from the parallel key or building secondary
dominants that approach other scale degrees:
.. code-block:: pycon
>>> from pytheory import Key
>>> c = Key("C", "major")
>>> c.borrowed_chords[:4]
['C minor', 'D diminished', 'Eb major', 'F minor']
>>> c.secondary_dominant(5).identify()
'D dominant 7th'
>>> c.secondary_dominant(2).identify()
'A dominant 7th'
>>> c.secondary_dominant(6).identify()
'E dominant 7th'
The Overtone Series
-------------------
Every musical tone contains a stack of harmonics — the physics behind
why intervals sound consonant:
.. code-block:: pycon
>>> from pytheory import Tone
>>> a4 = Tone.from_string("A4", system="western")
>>> [round(f, 1) for f in a4.overtones(6)]
[440.0, 880.0, 1320.0, 1760.0, 2200.0, 2640.0]
>>> # Harmonic 2 = octave (2:1)
>>> # Harmonic 3 = perfect 5th + octave (3:1)
>>> # Harmonic 5 = major 3rd + two octaves (5:1)
Enharmonic Spellings
--------------------
Find the alternate name for any sharp or flat:
.. code-block:: pycon
>>> from pytheory import Tone
>>> for name in ["C#4", "D#4", "F#4", "G#4"]:
... t = Tone.from_string(name, system="western")
... print(f"{t.name} = {t.enharmonic}")
C# = Db
D# = Eb
F# = Gb
G# = Ab
World Scales
------------
Explore scales from Indian, Arabic, and Japanese traditions:
.. code-block:: pycon
>>> from pytheory import TonedScale
>>> indian = TonedScale(tonic="Sa", system="indian")
>>> indian["bhairav"].note_names
['Sa', 'komal Re', 'Ga', 'Ma', 'Pa', 'komal Dha', 'Ni', 'Sa']
>>> arabic = TonedScale(tonic="Do", system="arabic")
>>> arabic["hijaz"].note_names
['Do', 'Reb', 'Mi', 'Fa', 'Sol', 'Solb', 'Sib', 'Do']
>>> japanese = TonedScale(tonic="C4", system="japanese")
>>> japanese["hirajoshi"].note_names
['C', 'D', 'Eb', 'G', 'Ab', 'C']
Visualize a Scale on Guitar
----------------------------
See where the notes fall across the fretboard — E minor pentatonic,
the most-played scale in rock:
.. code-block:: pycon
>>> from pytheory import Fretboard, Scale
>>> fb = Fretboard.guitar()
>>> pent = Scale(tonic="E4", system="blues")["minor pentatonic"]
>>> print(fb.scale_diagram(pent, frets=12))
0 1 2 3 4 5 6 7 8 9 10 11 12
E| E | - | - | G | - | A | - | B | - | - | D | - | E |
B| B | - | - | D | - | E | - | - | G | - | A | - | B |
G| G | - | A | - | B | - | - | D | - | E | - | - | G |
D| D | - | E | - | - | G | - | A | - | B | - | - | D |
A| A | - | B | - | - | D | - | E | - | - | G | - | A |
E| E | - | - | G | - | A | - | B | - | - | D | - | E |
+139 -96
View File
@@ -31,29 +31,42 @@ Guitars
This tuning uses intervals of a perfect 4th (5 semitones) between most
strings, except between G and B which is a major 3rd (4 semitones).
.. code-block:: python
.. code-block:: pycon
from pytheory import Fretboard
>>> from pytheory import Fretboard
guitar = Fretboard.guitar() # Standard EADGBE
twelve = Fretboard.twelve_string() # 12-string (6 doubled courses)
bass = Fretboard.bass() # Standard 4-string EADG
bass5 = Fretboard.bass(five_string=True) # 5-string with low B
>>> guitar = Fretboard.guitar() # Standard EADGBE
>>> twelve = Fretboard.twelve_string() # 12-string (6 doubled courses)
>>> bass = Fretboard.bass() # Standard 4-string EADG
>>> bass5 = Fretboard.bass(five_string=True) # 5-string with low B
**Alternate tunings** — 8 built-in presets:
.. code-block:: python
.. code-block:: pycon
Fretboard.guitar("drop d") # DADGBE — heavy riffs, metal
Fretboard.guitar("open g") # DGDGBD — slide guitar, Keith Richards
Fretboard.guitar("open d") # DADF#AD — slide, folk
Fretboard.guitar("open e") # EBEG#BE — slide blues
Fretboard.guitar("open a") # EAC#EAE
Fretboard.guitar("dadgad") # DADGAD — Celtic, fingerstyle
Fretboard.guitar("half step down") # Eb standard — Hendrix, SRV
>>> Fretboard.guitar("drop d") # DADGBE — heavy riffs, metal
>>> Fretboard.guitar("open g") # DGDGBD — slide guitar, Keith Richards
>>> Fretboard.guitar("open d") # DADF#AD — slide, folk
>>> Fretboard.guitar("open e") # EBEG#BE — slide blues
>>> Fretboard.guitar("open a") # EAC#EAE
>>> Fretboard.guitar("dadgad") # DADGAD — Celtic, fingerstyle
>>> Fretboard.guitar("half step down") # Eb standard — Hendrix, SRV
# Custom tuning with any notes
Fretboard.guitar(("C4", "G3", "C3", "G2", "C2", "G1"))
>>> # 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:: pycon
>>> # 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
-------------------
@@ -63,12 +76,12 @@ mirrors the `violin family <https://en.wikipedia.org/wiki/Violin_family>`_
— all tuned in perfect fifths, with each member a fifth or octave
lower than the last:
.. code-block:: python
.. code-block:: pycon
Fretboard.mandolin() # E5 A4 D4 G3 — soprano (= violin)
Fretboard.mandola() # A4 D4 G3 C3 — alto (= viola)
Fretboard.octave_mandolin() # E4 A3 D3 G2 — tenor (octave below mandolin)
Fretboard.mandocello() # A3 D3 G2 C2 — bass (= cello)
>>> Fretboard.mandolin() # E5 A4 D4 G3 — soprano (= violin)
>>> Fretboard.mandola() # A4 D4 G3 C3 — alto (= viola)
>>> Fretboard.octave_mandolin() # E4 A3 D3 G2 — tenor (octave below mandolin)
>>> Fretboard.mandocello() # A3 D3 G2 C2 — bass (= cello)
The mandolin's doubled courses (pairs of strings) create a natural
chorus effect. The `octave mandolin <https://en.wikipedia.org/wiki/Octave_mandolin>`_
@@ -80,12 +93,12 @@ The Bowed String Family
The orchestral `string family <https://en.wikipedia.org/wiki/String_section>`_
is tuned in perfect fifths (except the double bass, which uses fourths):
.. code-block:: python
.. code-block:: pycon
Fretboard.violin() # E5 A4 D4 G3 — soprano
Fretboard.viola() # A4 D4 G3 C3 — alto (5th below violin)
Fretboard.cello() # A3 D3 G2 C2 — tenor/bass (octave below viola)
Fretboard.double_bass() # G2 D2 A1 E1 — bass (tuned in 4ths!)
>>> Fretboard.violin() # E5 A4 D4 G3 — soprano
>>> Fretboard.viola() # A4 D4 G3 C3 — alto (5th below violin)
>>> Fretboard.cello() # A3 D3 G2 C2 — tenor/bass (octave below viola)
>>> Fretboard.double_bass() # G2 D2 A1 E1 — bass (tuned in 4ths!)
Bowed strings have no frets — the player can produce any pitch along
the fingerboard, enabling continuous
@@ -95,19 +108,19 @@ inflections not possible on fretted instruments.
The `erhu <https://en.wikipedia.org/wiki/Erhu>`_ — a 2-stringed Chinese
bowed instrument with a hauntingly vocal quality:
.. code-block:: python
.. code-block:: pycon
Fretboard.erhu() # A4 D4 — tuned a 5th apart, no fingerboard
>>> Fretboard.erhu() # A4 D4 — tuned a 5th apart, no fingerboard
Plucked Strings
---------------
.. code-block:: python
.. code-block:: pycon
Fretboard.ukulele() # A4 E4 C4 G4 — re-entrant tuning
Fretboard.banjo() # Open G (bluegrass, 5th string is high drone)
Fretboard.banjo("open d") # Open D (clawhammer, old-time)
Fretboard.harp() # 47 strings, C1 to G7 (concert pedal harp)
>>> Fretboard.ukulele() # A4 E4 C4 G4 — re-entrant tuning
>>> Fretboard.banjo() # Open G (bluegrass, 5th string is high drone)
>>> Fretboard.banjo("open d") # Open D (clawhammer, old-time)
>>> Fretboard.harp() # 47 strings, C1 to G7 (concert pedal harp)
The `banjo <https://en.wikipedia.org/wiki/Banjo>`_'s short 5th string
is a high drone — a defining feature of the instrument's sound.
@@ -119,28 +132,28 @@ by up to two semitones across all octaves simultaneously.
World Instruments
-----------------
.. code-block:: python
.. code-block:: pycon
# Middle Eastern
Fretboard.oud() # C4 G3 D3 A2 G2 C2 — fretless, ancestor of the lute
Fretboard.sitar() # 7 main strings — Indian classical
>>> # Middle Eastern
>>> Fretboard.oud() # C4 G3 D3 A2 G2 C2 — fretless, ancestor of the lute
>>> Fretboard.sitar() # 7 main strings — Indian classical
# East Asian
Fretboard.shamisen() # C4 G3 C3 — 3-string Japanese, honchoshi tuning
Fretboard.pipa() # D4 A3 E3 A2 — 4-string Chinese lute
Fretboard.erhu() # A4 D4 — 2-string Chinese bowed
>>> # East Asian
>>> Fretboard.shamisen() # C4 G3 C3 — 3-string Japanese, honchoshi tuning
>>> Fretboard.pipa() # D4 A3 E3 A2 — 4-string Chinese lute
>>> Fretboard.erhu() # A4 D4 — 2-string Chinese bowed
# European
Fretboard.bouzouki() # D4 A3 D3 G2 — Irish (Celtic music)
Fretboard.bouzouki("greek") # D4 A3 F3 C3 — Greek
Fretboard.lute() # G4 D4 A3 F3 C3 G2 — Renaissance (6 courses)
Fretboard.balalaika() # A4 E4 E4 — Russian (2 unison strings)
>>> # European
>>> Fretboard.bouzouki() # D4 A3 D3 G2 — Irish (Celtic music)
>>> Fretboard.bouzouki("greek") # D4 A3 F3 C3 — Greek
>>> Fretboard.lute() # G4 D4 A3 F3 C3 G2 — Renaissance (6 courses)
>>> Fretboard.balalaika() # A4 E4 E4 — Russian (2 unison strings)
# Latin American
Fretboard.charango() # E5 A4 E5 C5 G4 — Andean (re-entrant tuning)
>>> # Latin American
>>> Fretboard.charango() # E5 A4 E5 C5 G4 — Andean (re-entrant tuning)
# Steel guitar
Fretboard.pedal_steel() # 10 strings, E9 Nashville — country music
>>> # Steel guitar
>>> Fretboard.pedal_steel() # 10 strings, E9 Nashville — country music
The `oud <https://en.wikipedia.org/wiki/Oud>`_ is fretless, allowing
the quarter-tone inflections essential to
@@ -151,12 +164,12 @@ sympathetic strings that resonate in harmony with the played notes.
Keyboards
---------
.. code-block:: python
.. code-block:: pycon
Fretboard.keyboard() # 88-key piano (A0 to C8)
Fretboard.keyboard(61, "C2") # 61-key synth controller
Fretboard.keyboard(49, "C2") # 49-key controller
Fretboard.keyboard(25, "C3") # 25-key mini MIDI controller
>>> Fretboard.keyboard() # 88-key piano (A0 to C8)
>>> Fretboard.keyboard(61, "C2") # 61-key synth controller
>>> Fretboard.keyboard(49, "C2") # 49-key controller
>>> Fretboard.keyboard(25, "C3") # 25-key mini MIDI controller
While keyboards don't have strings or frets, they map naturally to a
sequence of tones. A full 88-key piano spans over 7 octaves — the
@@ -172,78 +185,108 @@ 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
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=x)
# 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'
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)
G|--0-- (open — G)
D|--2-- (fret 2 — E)
A|--3-- (fret 3 — C)
E|--0-- (open — E)
E|--x-- (muted)
A value of ``None`` means the string is muted (not played).
A value of ``x`` (``None``) means the string is muted (not played).
ASCII Tablature
~~~~~~~~~~~~~~~
For a more visual representation, use ``tab()``:
.. code-block:: pycon
>>> print(fb.tab("C"))
C major
e|--0--
B|--1--
G|--0--
D|--2--
A|--3--
E|--x--
Generating Full Charts
----------------------
Generate fingerings for every chord at once:
.. code-block:: python
.. code-block:: pycon
from pytheory import Fretboard, charts_for_fretboard
>>> fb = Fretboard.guitar()
>>> chart = fb.chart()
fb = Fretboard.guitar()
chart = charts_for_fretboard(fretboard=fb)
>>> chart["C"]
Fingering(e=0, B=1, G=0, D=2, A=3, E=x)
for name, fingering in chart.items():
print(f"{name:6s} {fingering}")
# Works with any instrument
uke_chart = charts_for_fretboard(fretboard=Fretboard.ukulele())
mando_chart = charts_for_fretboard(fretboard=Fretboard.mandolin())
>>> # Works with any instrument
>>> uke_chart = Fretboard.ukulele().chart()
>>> mando_chart = Fretboard.mandolin().chart()
Custom Instruments
------------------
Any instrument can be modeled with custom string tunings:
.. code-block:: python
.. code-block:: pycon
from pytheory import Tone, Fretboard
>>> from pytheory import Tone, Fretboard
# Baritone ukulele (DGBE — top 4 guitar strings)
bari_uke = Fretboard(tones=[
Tone.from_string("E4"),
Tone.from_string("B3"),
Tone.from_string("G3"),
Tone.from_string("D3"),
])
>>> # Baritone ukulele (DGBE — top 4 guitar strings)
>>> bari_uke = Fretboard(tones=[
... Tone.from_string("E4"),
... Tone.from_string("B3"),
... Tone.from_string("G3"),
... Tone.from_string("D3"),
... ])
# Tres cubano (Cuban guitar, 3 doubled courses)
tres = Fretboard(tones=[
Tone.from_string("E4"),
Tone.from_string("B3"),
Tone.from_string("G3"),
])
>>> # Tres cubano (Cuban guitar, 3 doubled courses)
>>> tres = Fretboard(tones=[
... Tone.from_string("E4"),
... Tone.from_string("B3"),
... Tone.from_string("G3"),
... ])
+61 -22
View File
@@ -13,26 +13,25 @@ using basic `waveform <https://en.wikipedia.org/wiki/Waveform>`_ synthesis.
Playing a Tone
--------------
.. code-block:: python
.. code-block:: pycon
from pytheory import Tone, play
>>> from pytheory import Tone, play
a4 = Tone.from_string("A4", system="western")
play(a4, t=1_000) # Play A440 for 1 second
>>> a4 = Tone.from_string("A4", system="western")
>>> play(a4, t=1_000) # Play A440 for 1 second
Playing a Chord
---------------
.. code-block:: python
.. code-block:: pycon
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
--------------
@@ -53,28 +52,68 @@ integer multiples of the fundamental frequency.
1/n². Sounds softer and more mellow than sawtooth — somewhere between
sine and sawtooth. Often described as "woody" or "hollow."
.. code-block:: python
.. code-block:: pycon
from pytheory import play, Synth, Tone
>>> from pytheory import play, Synth, Tone
tone = Tone.from_string("C4", system="western")
>>> tone = Tone.from_string("C4", system="western")
play(tone, synth=Synth.SINE) # Pure, clean
play(tone, synth=Synth.SAW) # Bright, buzzy
play(tone, synth=Synth.TRIANGLE) # Mellow, hollow
>>> play(tone, synth=Synth.SINE) # Pure, clean
>>> play(tone, synth=Synth.SAW) # Bright, buzzy
>>> play(tone, synth=Synth.TRIANGLE) # Mellow, hollow
Temperaments
------------
Hear the difference between tuning systems:
.. code-block:: python
.. code-block:: pycon
play(tone, temperament="equal") # Modern standard (since ~1917)
play(tone, temperament="pythagorean") # Pure fifths, wolf intervals
play(tone, temperament="meantone") # Pure thirds, Renaissance sound
>>> play(tone, temperament="equal") # Modern standard (since ~1917)
>>> play(tone, temperament="pythagorean") # Pure fifths, wolf intervals
>>> play(tone, temperament="meantone") # Pure thirds, Renaissance sound
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.
Chord Progressions
-------------------
Play an entire chord progression in sequence with a single call:
.. code-block:: pycon
>>> from pytheory import Key, play_progression
>>> chords = Key("C", "major").progression("I", "V", "vi", "IV")
>>> play_progression(chords, t=800)
You can customize the waveform and the gap (silence) between chords:
.. code-block:: pycon
>>> from pytheory import Synth
>>> play_progression(chords, t=1000, synth=Synth.TRIANGLE, gap=200)
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:: pycon
>>> 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)
+175 -30
View File
@@ -8,42 +8,183 @@ 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
.. code-block:: python
Tones
-----
from pytheory import Tone, TonedScale, Fretboard, CHARTS
A :class:`~pytheory.tones.Tone` is a single musical note:
# Create a tone — A4 is the tuning standard (440 Hz)
a4 = Tone.from_string("A4", system="western")
print(a4.frequency) # 440.0
.. code-block:: pycon
# 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
>>> from pytheory import Tone
# Measure intervals between tones
print(g4 - c4) # 7 (semitones — a perfect fifth)
>>> a4 = Tone.from_string("A4", system="western")
>>> a4.frequency
440.0
# Build a C major scale
c_major = TonedScale(tonic="C4")["major"]
print(c_major.note_names)
# ['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
>>> c4 = Tone.from_string("C4", system="western")
>>> c4.midi
60
# 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)
>>> Tone.from_frequency(440)
<Tone A4>
>>> Tone.from_midi(60)
<Tone C4>
# Guitar chord fingerings
fb = Fretboard.guitar()
fingering = CHARTS["western"]["Am"].fingering(fretboard=fb)
print(fingering) # (0, 1, 2, 2, 0, 0)
>>> c4 + 4
<Tone E4>
>>> c4 + 7
<Tone G4>
>>> g4 = c4 + 7
>>> g4 - c4
7
>>> c4.interval_to(g4)
'perfect 5th'
>>> Tone.from_string("C#4", system="western").enharmonic
'Db'
Scales
------
Build scales in any key and mode:
.. code-block:: pycon
>>> from pytheory import TonedScale
>>> c = TonedScale(tonic="C4")
>>> c["major"].note_names
['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
>>> c["minor"].note_names
['C', 'D', 'Eb', 'F', 'G', 'Ab', 'Bb', 'C']
>>> c["dorian"].note_names
['C', 'D', 'Eb', 'F', 'G', 'A', 'Bb', 'C']
>>> 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:: pycon
>>> from pytheory import Key
>>> key = Key("G", "major")
>>> key.note_names
['G', 'A', 'B', 'C', 'D', 'E', 'F#', 'G']
>>> key.chords
['G major', 'A minor', 'B minor', 'C major', 'D major', 'E minor', 'F# diminished']
>>> chords = key.progression("I", "V", "vi", "IV")
>>> [c.identify() for c in chords]
['G major', 'D major', 'E minor', 'C major']
>>> Key.detect("C", "E", "G", "A", "D")
<Key C major>
Build chords directly:
.. code-block:: pycon
>>> 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>
>>> Chord.from_tones("Bb", "D", "F").identify()
'Bb major'
>>> 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=x)
>>> fb.chord("C")['A']
3
>>> fb.fingering(0, 0, 0, 2, 2, 0).identify()
'E minor'
>>> print(fb.tab("Am"))
A minor
e|--0--
B|--1--
G|--2--
D|--2--
A|--0--
E|--x--
>>> from pytheory import Scale
>>> pentatonic = Scale(tonic="A4", system="blues")["minor pentatonic"]
>>> print(fb.scale_diagram(pentatonic, frets=5))
0 1 2 3 4 5
E| E | - | - | G | - | A |
B| - | C | - | D | - | E |
G| G | - | A | - | - | C |
D| D | - | E | - | - | G |
A| A | - | - | C | - | D |
E| E | - | - | G | - | A |
Audio Playback
--------------
.. code-block:: pycon
>>> from pytheory import Tone, Chord, play, save, Synth
>>> play(Tone.from_string("A4"), t=1_000)
>>> play(Chord.from_name("Am7"), synth=Synth.TRIANGLE, t=2_000)
>>> 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 +195,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
+214 -88
View File
@@ -30,18 +30,15 @@ Building Scales
Use :class:`~pytheory.scales.TonedScale` to generate scales in any key:
.. code-block:: python
.. code-block:: pycon
from pytheory import TonedScale
c = TonedScale(tonic="C4")
major = c["major"]
minor = c["minor"]
harmonic_minor = c["harmonic minor"]
print(major.note_names)
# ['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
>>> from pytheory import TonedScale
>>> c = TonedScale(tonic="C4")
>>> major = c["major"]
>>> minor = c["minor"]
>>> harmonic_minor = c["harmonic minor"]
>>> major.note_names
['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
Major and Minor
---------------
@@ -55,13 +52,12 @@ notes but starts from the 6th degree:
- G major → E minor (both have one sharp: F#)
- F major → D minor (both have one flat: Bb)
.. code-block:: python
.. code-block:: pycon
c_major = TonedScale(tonic="C4")["major"]
a_minor = TonedScale(tonic="A4")["minor"]
# Same notes, different starting point
set(c_major.note_names) == set(a_minor.note_names) # True
>>> c_major = TonedScale(tonic="C4")["major"]
>>> a_minor = TonedScale(tonic="A4")["minor"]
>>> set(c_major.note_names) == set(a_minor.note_names)
True
The `harmonic minor <https://en.wikipedia.org/wiki/Harmonic_minor_scale>`_ raises the 7th degree of the natural minor,
creating an augmented 2nd interval (3 semitones) between the 6th and
@@ -79,44 +75,65 @@ The seven `modes <https://en.wikipedia.org/wiki/Mode_(music)>`_ of the major sca
pattern, each starting from a different degree. Each mode has a distinct
emotional character:
.. code-block:: python
.. code-block:: pycon
c = TonedScale(tonic="C4")
>>> c = TonedScale(tonic="C4")
**Ionian** (I) — the major scale itself. Bright, happy, resolved::
**Ionian** (I) — the major scale itself. Bright, happy, resolved:
c["ionian"] # C D E F G A B C
.. code-block:: pycon
>>> c["ionian"].note_names
['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
`Dorian <https://en.wikipedia.org/wiki/Dorian_mode>`_ (ii) — minor with a raised 6th. Jazzy, soulful (So What,
Scarborough Fair)::
Scarborough Fair):
c["dorian"] # C D Eb F G A Bb C
.. code-block:: pycon
>>> c["dorian"].note_names
['C', 'D', 'Eb', 'F', 'G', 'A', 'Bb', 'C']
`Phrygian <https://en.wikipedia.org/wiki/Phrygian_mode>`_ (iii) — minor with a flat 2nd. Spanish, flamenco, dark
(White Rabbit)::
(White Rabbit):
c["phrygian"] # C Db Eb F G Ab Bb C
.. code-block:: pycon
>>> c["phrygian"].note_names
['C', 'Db', 'Eb', 'F', 'G', 'Ab', 'Bb', 'C']
`Lydian <https://en.wikipedia.org/wiki/Lydian_mode>`_ (IV) — major with a raised 4th. Dreamy, floating, ethereal
(The Simpsons theme, Flying by ET)::
(The Simpsons theme, Flying by ET):
c["lydian"] # C D E F# G A B C
.. code-block:: pycon
>>> c["lydian"].note_names
['C', 'D', 'E', 'F#', 'G', 'A', 'B', 'C']
`Mixolydian <https://en.wikipedia.org/wiki/Mixolydian_mode>`_ (V) — major with a flat 7th. Bluesy, rock, dominant
(Norwegian Wood, Sweet Home Alabama)::
(Norwegian Wood, Sweet Home Alabama):
c["mixolydian"] # C D E F G A Bb C
.. code-block:: pycon
>>> c["mixolydian"].note_names
['C', 'D', 'E', 'F', 'G', 'A', 'Bb', 'C']
`Aeolian <https://en.wikipedia.org/wiki/Aeolian_mode>`_ (vi) — the natural minor scale. Sad, dark, introspective
(Stairway to Heaven, Losing My Religion)::
(Stairway to Heaven, Losing My Religion):
c["aeolian"] # C D Eb F G Ab Bb C
.. code-block:: pycon
>>> c["aeolian"].note_names
['C', 'D', 'Eb', 'F', 'G', 'Ab', 'Bb', 'C']
`Locrian <https://en.wikipedia.org/wiki/Locrian_mode>`_ (vii) — minor with flat 2nd and flat 5th. Unstable,
rarely used as a home key (used in metal and jazz over diminished
chords)::
chords):
c["locrian"] # C Db Eb F Gb Ab Bb C
.. code-block:: pycon
>>> c["locrian"].note_names
['C', 'Db', 'Eb', 'F', 'Gb', 'Ab', 'Bb', 'C']
Scale Degrees
-------------
@@ -137,32 +154,45 @@ Leading Tone VII One semitone below tonic — pulls upward
Access degrees by index, Roman numeral, or name:
.. code-block:: python
.. code-block:: pycon
major = TonedScale(tonic="C4")["major"]
major[0] # C4 (by index)
major["I"] # C4 (by Roman numeral)
major["tonic"] # C4 (by degree name)
major["V"] # G4 (dominant)
major["dominant"] # G4
major[0:3] # (C4, D4, E4) — slicing works too
>>> major = TonedScale(tonic="C4")["major"]
>>> major[0]
C4
>>> major["I"]
C4
>>> major["tonic"]
C4
>>> major["V"]
G4
>>> major["dominant"]
G4
>>> major[0:3]
(<Tone C4>, <Tone D4>, <Tone E4>)
Iteration
---------
Scales are iterable and support ``len()`` and ``in``:
.. code-block:: python
.. code-block:: pycon
for tone in major:
print(f"{tone.name}: {tone.frequency:.1f} Hz")
len(major) # 8 (7 notes + octave)
"C" in major # True
"C#" in major # False
>>> for tone in major:
... print(f"{tone.name}: {tone.frequency:.1f} Hz")
C: 261.6 Hz
D: 293.7 Hz
E: 329.6 Hz
F: 349.2 Hz
G: 392.0 Hz
A: 440.0 Hz
B: 493.9 Hz
C: 523.3 Hz
>>> len(major)
8
>>> "C" in major
True
>>> "C#" in major
False
Building Chords from Scales
----------------------------
@@ -185,21 +215,25 @@ Notice the pattern: **major** triads on I, IV, V; **minor** triads on
ii, iii, vi; **diminished** on vii°. This pattern holds for every major
key.
.. code-block:: python
.. code-block:: pycon
major = TonedScale(tonic="C4")["major"]
# Build diatonic triads
I = major.triad(0) # C E G (C major)
ii = major.triad(1) # D F A (D minor)
iii = major.triad(2) # E G B (E minor)
IV = major.triad(3) # F A C (F major)
V = major.triad(4) # G B D (G major)
vi = major.triad(5) # A C E (A minor)
# Build seventh chords
Imaj7 = major.chord(0, 2, 4, 6) # C E G B = Cmaj7
V7 = major.chord(4, 6, 8, 10) # G B D F = G7 (dominant 7th)
>>> major = TonedScale(tonic="C4")["major"]
>>> major.triad(0)
C major
>>> major.triad(1)
D minor
>>> major.triad(2)
E minor
>>> major.triad(3)
F major
>>> major.triad(4)
G major
>>> major.triad(5)
A minor
>>> major.chord(0, 2, 4, 6)
C major 7th
>>> major.chord(4, 6, 8, 10)
G dominant 7th
Common Progressions
~~~~~~~~~~~~~~~~~~~
@@ -215,6 +249,29 @@ Some of the most-used chord progressions in Western music:
My Heart Will Go On)
- **IIVviV** — axis of awesome (many, many pop songs)
The :class:`~pytheory.scales.Key` class makes working with progressions
easy:
.. code-block:: pycon
>>> from pytheory import Key
>>> key = Key("G", "major")
>>> chords = key.progression("I", "V", "vi", "IV")
>>> for c in chords:
... print(c.identify())
G major
D major
E minor
C major
>>> key.nashville(1, 5, 6, 4)
[<Chord G major>, <Chord D major>, <Chord E minor>, <Chord C major>]
>>> key.chords
['G major', 'A minor', 'B minor', 'C major', 'D major', 'E minor', 'F# diminished']
>>> key.seventh_chords
['G major 7th', 'A minor 7th', 'B minor 7th', 'C major 7th', 'D dominant 7th', 'E minor 7th', 'F# half-diminished 7th']
>>> Key.detect("C", "E", "G", "A", "D")
C major
The 12-Bar Blues
~~~~~~~~~~~~~~~~
@@ -233,35 +290,104 @@ structure. In the key of A::
| D | D | A | A |
| E | D | A | E |
.. code-block:: python
.. code-block:: pycon
from pytheory import TonedScale
>>> from pytheory import TonedScale
>>> a = TonedScale(tonic="A4")["major"]
>>> I = a.triad(0)
>>> IV = a.triad(3)
>>> V = a.triad(4)
>>> blues_12 = [I, I, I, I, IV, IV, I, I, V, IV, I, V]
a = TonedScale(tonic="A4")["major"]
I = a.triad(0) # A major
IV = a.triad(3) # D major
V = a.triad(4) # E major
Key Signatures
~~~~~~~~~~~~~~
# The 12-bar blues progression
blues_12 = [I, I, I, I, IV, IV, I, I, V, IV, I, V]
The ``signature`` property tells you how many sharps or flats a key has:
Parallel Major and Minor
~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: pycon
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).
>>> 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': []}
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.
Relative and Parallel Keys
~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
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):
c_major = TonedScale(tonic="C4")["major"]
c_minor = TonedScale(tonic="C4")["minor"]
.. code-block:: pycon
# 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']
>>> Key("C", "major").relative
A minor
>>> Key("A", "minor").relative
C major
>>> Key("C", "major").parallel
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:: pycon
>>> Key("C", "major").borrowed_chords
['C minor', 'D diminished', 'Eb major', 'F minor', 'G minor', 'Ab major', 'Bb 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:: pycon
>>> key = Key("C", "major")
>>> key.secondary_dominant(5)
D dominant 7th
>>> 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:: pycon
>>> key = Key("C", "major")
>>> chords = key.random_progression(4)
>>> [c.identify() for c in chords]
['C major', 'F major', 'A minor', 'G major']
All Keys
~~~~~~~~
Enumerate all 24 major and minor keys:
.. code-block:: pycon
>>> 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:: pycon
>>> c_major = TonedScale(tonic="C4")["major"]
>>> d_major = c_major.transpose(2)
>>> d_major.note_names
['D', 'E', 'F#', 'G', 'A', 'B', 'C#', 'D']
+72 -69
View File
@@ -10,16 +10,16 @@ Western
The standard 12-tone equal temperament system with major/minor scales
and all seven modes.
.. code-block:: python
.. code-block:: pycon
from pytheory import TonedScale
>>> from pytheory import TonedScale
c = TonedScale(tonic="C4")
c["major"].note_names
# ['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
>>> c = TonedScale(tonic="C4")
>>> c["major"].note_names
['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
c["dorian"].note_names
# ['C', 'D', 'D#', 'F', 'G', 'A', 'A#', 'C']
>>> c["dorian"].note_names
['C', 'D', 'Eb', 'F', 'G', 'A', 'Bb', 'C']
**Scales:** major, minor, harmonic minor, ionian, dorian, phrygian,
lydian, mixolydian, aeolian, locrian, chromatic
@@ -31,20 +31,20 @@ The Hindustani system uses **swaras** (Sa, Re, Ga, Ma, Pa, Dha, Ni) and
organizes scales into `thaats <https://en.wikipedia.org/wiki/Thaat>`_ — the 10 parent scales from which `ragas <https://en.wikipedia.org/wiki/Raga>`_
are derived.
.. code-block:: python
.. code-block:: pycon
from pytheory import TonedScale
>>> from pytheory import TonedScale
sa = TonedScale(tonic="Sa4", system="indian")
>>> sa = TonedScale(tonic="Sa4", system="indian")
sa["bilawal"].note_names # = major scale
# ['Sa', 'Re', 'Ga', 'Ma', 'Pa', 'Dha', 'Ni', 'Sa']
>>> sa["bilawal"].note_names # = major scale
['Sa', 'Re', 'Ga', 'Ma', 'Pa', 'Dha', 'Ni', 'Sa']
sa["bhairav"].note_names # unique to Indian music
# ['Sa', 'komal Re', 'Ga', 'Ma', 'Pa', 'komal Dha', 'Ni', 'Sa']
>>> sa["bhairav"].note_names # unique to Indian music
['Sa', 'komal Re', 'Ga', 'Ma', 'Pa', 'komal Dha', 'Ni', 'Sa']
sa["todi"].note_names
# ['Sa', 'komal Re', 'komal Ga', 'tivra Ma', 'Pa', 'komal Dha', 'Ni', 'Sa']
>>> sa["todi"].note_names
['Sa', 'komal Re', 'komal Ga', 'tivra Ma', 'Pa', 'komal Dha', 'Ni', 'Sa']
**Thaats:** bilawal, khamaj, kafi, asavari, bhairavi, kalyan, bhairav,
poorvi, marwa, todi
@@ -67,20 +67,20 @@ and organizes scales into **maqamat** (plural of `maqam <https://en.wikipedia.or
12-tone equal temperament. These scales are the closest 12-TET
approximations.
.. code-block:: python
.. code-block:: pycon
from pytheory import TonedScale
>>> from pytheory import TonedScale
do = TonedScale(tonic="Do4", system="arabic")
>>> do = TonedScale(tonic="Do4", system="arabic")
do["ajam"].note_names # = major scale
# ['Do', 'Re', 'Mi', 'Fa', 'Sol', 'La', 'Si', 'Do']
>>> do["ajam"].note_names # = major scale
['Do', 'Re', 'Mi', 'Fa', 'Sol', 'La', 'Si', 'Do']
do["hijaz"].note_names # characteristic augmented 2nd
# ['Do', 'Reb', 'Mi', 'Fa', 'Sol', 'Solb', 'Sib', 'Do']
>>> do["hijaz"].note_names # characteristic augmented 2nd
['Do', 'Reb', 'Mi', 'Fa', 'Sol', 'Solb', 'Sib', 'Do']
do["nikriz"].note_names
# ['Do', 'Re', 'Mib', 'Fa#', 'Sol', 'La', 'Sib', 'Do']
>>> do["nikriz"].note_names
['Do', 'Re', 'Mib', 'Fa#', 'Sol', 'La', 'Sib', 'Do']
**Maqamat:** ajam, nahawand, kurd, hijaz, nikriz, bayati, rast, saba,
sikah, jiharkah
@@ -91,23 +91,23 @@ Japanese
The Japanese system uses Western note names with traditional pentatonic
and heptatonic scales from Japanese music.
.. code-block:: python
.. code-block:: pycon
from pytheory import TonedScale
>>> from pytheory import TonedScale
c = TonedScale(tonic="C4", system="japanese")
>>> c = TonedScale(tonic="C4", system="japanese")
c["hirajoshi"].note_names # most iconic Japanese scale
# ['C', 'D', 'D#', 'G', 'G#', 'C']
>>> c["hirajoshi"].note_names # most iconic Japanese scale
['C', 'D', 'Eb', 'G', 'Ab', 'C']
c["in"].note_names # Miyako-bushi, used in koto music
# ['C', 'C#', 'F', 'G', 'G#', 'C']
>>> c["in"].note_names # Miyako-bushi, used in koto music
['C', 'Db', 'F', 'G', 'Ab', 'C']
c["yo"].note_names # folk music scale
# ['C', 'D', 'F', 'G', 'A#', 'C']
>>> c["yo"].note_names # folk music scale
['C', 'D', 'F', 'G', 'A#', 'C']
c["ritsu"].note_names # gagaku court music (= Dorian)
# ['C', 'D', 'D#', 'F', 'G', 'A', 'A#', 'C']
>>> c["ritsu"].note_names # gagaku court music (= Dorian)
['C', 'D', 'Eb', 'F', 'G', 'A', 'Bb', 'C']
**Pentatonic scales:** hirajoshi, in, yo, iwato, kumoi, insen
@@ -125,23 +125,23 @@ The `blues scale <https://en.wikipedia.org/wiki/Blues_scale>`_ adds the "`blue n
minor pentatonic — this chromatic passing tone is the defining sound
of the blues.
.. code-block:: python
.. code-block:: pycon
from pytheory import TonedScale
>>> from pytheory import TonedScale
c = TonedScale(tonic="C4", system="blues")
>>> c = TonedScale(tonic="C4", system="blues")
c["major pentatonic"].note_names # the "happy" pentatonic
# ['C', 'D', 'E', 'G', 'A', 'C']
>>> c["major pentatonic"].note_names # the "happy" pentatonic
['C', 'D', 'E', 'G', 'A', 'C']
c["minor pentatonic"].note_names # the "sad" pentatonic
# ['C', 'D#', 'F', 'G', 'A#', 'C']
>>> c["minor pentatonic"].note_names # the "sad" pentatonic
['C', 'D#', 'F', 'G', 'A#', 'C']
c["blues"].note_names # minor pentatonic + blue note
# ['C', 'D#', 'F', 'F#', 'G', 'A#', 'C']
>>> c["blues"].note_names # minor pentatonic + blue note
['C', 'Eb', 'F', 'Gb', 'G', 'Bb', 'C']
c["major blues"].note_names # major pentatonic + blue note
# ['C', 'D', 'D#', 'E', 'G', 'A', 'C']
>>> c["major blues"].note_names # major pentatonic + blue note
['C', 'D', 'Eb', 'E', 'G', 'A', 'C']
**Pentatonic:** major pentatonic, minor pentatonic
@@ -163,20 +163,20 @@ these are the closest 12-TET approximations.
an ethereal, floating quality. `Pelog <https://en.wikipedia.org/wiki/Pelog>`_ is a 7-tone scale with unequal
intervals, typically performed using 5-note subsets called *pathet*.
.. code-block:: python
.. code-block:: pycon
from pytheory import TonedScale
>>> from pytheory import TonedScale
ji = TonedScale(tonic="ji4", system="gamelan")
>>> ji = TonedScale(tonic="ji4", system="gamelan")
ji["slendro"].note_names # the 5-tone equidistant scale
# ['ji', 'ro', 'pat', 'mo', 'pi', 'ji']
>>> ji["slendro"].note_names # the 5-tone equidistant scale
['ji', 'ro', 'pat', 'mo', 'pi', 'ji']
ji["pelog"].note_names # full 7-tone pelog
# ['ji', 'ro-', 'lu', 'pat', 'mo', 'nem-', 'barang', 'ji']
>>> ji["pelog"].note_names # full 7-tone pelog
['ji', 'ro-', 'lu', 'pat', 'mo', 'nem-', 'barang', 'ji']
ji["pelog nem"].note_names # pathet nem subset
# ['ji', 'ro-', 'lu', 'pat', 'mo', 'ji']
>>> ji["pelog nem"].note_names # pathet nem subset
['ji', 'ro-', 'lu', 'pat', 'mo', 'ji']
**Pentatonic:** slendro, pelog nem, pelog barang, pelog lima
@@ -195,20 +195,23 @@ Cross-System Comparison
Since all systems use 12-tone equal temperament, equivalent scales
produce the same pitches:
.. code-block:: python
.. code-block:: pycon
from pytheory import TonedScale, Tone
>>> from pytheory import TonedScale, Tone
# These are all the same scale with different names
western = TonedScale(tonic="C4")["major"]
indian = TonedScale(tonic="Sa4", system="indian")["bilawal"]
arabic = TonedScale(tonic="Do4", system="arabic")["ajam"]
>>> # These are all the same scale with different names
>>> western = TonedScale(tonic="C4")["major"]
>>> indian = TonedScale(tonic="Sa4", system="indian")["bilawal"]
>>> arabic = TonedScale(tonic="Do4", system="arabic")["ajam"]
# Same pitches
c4 = Tone.from_string("C4", system="western")
sa4 = Tone.from_string("Sa4", system="indian")
do4 = Tone.from_string("Do4", system="arabic")
>>> # Same pitches
>>> c4 = Tone.from_string("C4", system="western")
>>> sa4 = Tone.from_string("Sa4", system="indian")
>>> do4 = Tone.from_string("Do4", system="arabic")
c4.frequency # 261.63
sa4.frequency # 261.63
do4.frequency # 261.63
>>> c4.frequency
261.6255653005986
>>> sa4.frequency
261.6255653005986
>>> do4.frequency
261.6255653005986
+53 -54
View File
@@ -50,14 +50,13 @@ cycle almost closes. The tiny gap where it doesn't close perfectly is
the `Pythagorean comma <https://en.wikipedia.org/wiki/Pythagorean_comma>`_
— the reason we need `temperament <https://en.wikipedia.org/wiki/Musical_temperament>`_.
.. code-block:: python
.. code-block:: pycon
from pytheory import Tone
>>> from pytheory import Tone
# Walk the circle of fifths — all 12 notes
c = Tone.from_string("C4", system="western")
[t.name for t in c.circle_of_fifths()]
# ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'C#', 'G#', 'D#', 'A#', 'F']
>>> c = Tone.from_string("C4", system="western")
>>> [t.name for t in c.circle_of_fifths()]
['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'C#', 'G#', 'D#', 'A#', 'F']
Other cultures divide the octave differently: Indonesian
`gamelan <https://en.wikipedia.org/wiki/Gamelan>`_ uses 5 or 7 unequal
@@ -183,17 +182,18 @@ is exactly this pattern. Every "Louie Louie" and every
`Bach chorale <https://en.wikipedia.org/wiki/Bach_chorale>`_ follows
this basic tonal gravity.
.. code-block:: python
.. code-block:: pycon
from pytheory import TonedScale
>>> from pytheory import TonedScale
scale = TonedScale(tonic="C4")["major"]
>>> scale = TonedScale(tonic="C4")["major"]
# The I-IV-V-I progression
I = scale.triad(0) # C major — home
IV = scale.triad(3) # F major — departure
V = scale.triad(4) # G major — tension
# I again # C major — resolution
>>> scale.triad(0).identify()
'C major'
>>> scale.triad(3).identify()
'F major'
>>> scale.triad(4).identify()
'G major'
The Dominant Seventh
~~~~~~~~~~~~~~~~~~~~
@@ -210,20 +210,24 @@ This combination creates the strongest possible pull toward
`resolution <https://en.wikipedia.org/wiki/Resolution_(music)>`_.
When you hear V7→I, you feel arrival.
.. code-block:: python
.. code-block:: pycon
from pytheory import Chord, Tone
>>> from pytheory import Chord, Tone
C4 = Tone.from_string("C4", system="western")
G4 = Tone.from_string("G4", system="western")
>>> C4 = Tone.from_string("C4", system="western")
>>> G4 = Tone.from_string("G4", system="western")
g7 = Chord([G4, G4+4, G4+7, G4+10]) # G B D F
g7.identify() # 'G dominant 7th'
g7.tension['has_dominant_function'] # True
g7.tension['tritones'] # 1
>>> g7 = Chord([G4, G4+4, G4+7, G4+10])
>>> g7.identify()
'G dominant 7th'
>>> g7.tension['has_dominant_function']
True
>>> g7.tension['tritones']
1
c_major = Chord([C4, C4+4, C4+7]) # C E G
c_major.tension['score'] # 0.0 — fully resolved
>>> c_major = Chord([C4, C4+4, C4+7])
>>> c_major.tension['score']
0.0
Rhythm and Meter
----------------
@@ -277,43 +281,38 @@ foundation of blues and jazz. Indonesian gamelan embraces
`beating <https://en.wikipedia.org/wiki/Beat_(acoustics)>`_ between
paired instruments as a core aesthetic.
.. code-block:: python
.. code-block:: pycon
from pytheory import Chord, Tone
>>> from pytheory import Chord, Tone
C4 = Tone.from_string("C4", system="western")
E4 = Tone.from_string("E4", system="western")
G4 = Tone.from_string("G4", system="western")
>>> C4 = Tone.from_string("C4", system="western")
>>> E4 = Tone.from_string("E4", system="western")
>>> G4 = Tone.from_string("G4", system="western")
# The overtone series — the fifth is "built into" every tone
C4.overtones(6)
# [261.63, 523.25, 784.88, 1046.50, 1308.13, 1569.75]
# 3rd harmonic (784.88) ≈ G5 (783.99) — a perfect fifth
>>> [round(f, 2) for f in C4.overtones(6)]
[261.63, 523.25, 784.88, 1046.5, 1308.13, 1569.75]
# Consonance: simple frequency ratios score high
fifth = Chord([C4, G4]) # 3:2 ratio
tritone = Chord([C4, C4 + 6]) # 45:32 ratio
fifth.harmony > tritone.harmony # True
>>> fifth = Chord([C4, G4])
>>> tritone = Chord([C4, C4 + 6])
>>> fifth.harmony > tritone.harmony
True
# Dissonance: Plomp-Levelt roughness model
# An octave has low roughness (frequencies far apart)
# A major 3rd has more roughness (closer frequencies)
octave = Chord([C4, C4 + 12])
third = Chord([C4, E4])
octave.dissonance < third.dissonance # True
>>> octave = Chord([C4, C4 + 12])
>>> third = Chord([C4, E4])
>>> octave.dissonance < third.dissonance
True
# Tension: tritones and dominant function
c_major = Chord([C4, E4, G4])
c_major.tension['score'] # 0.0 — fully resolved
>>> c_major = Chord([C4, E4, G4])
>>> c_major.tension['score']
0.0
g7 = Chord([G4, G4+4, G4+7, G4+10]) # G dominant 7th
g7.tension['score'] # 0.6 — wants to resolve
g7.tension['tritones'] # 1 (B-F)
g7.tension['has_dominant_function'] # True
# Beat frequencies — the pulsing between close pitches
g7.beat_frequencies
# [(tone_a, tone_b, hz), ...] sorted by frequency
>>> g7 = Chord([G4, G4+4, G4+7, G4+10])
>>> g7.tension['score']
0.6
>>> g7.tension['tritones']
1
>>> g7.tension['has_dominant_function']
True
Further Reading
---------------
+146 -42
View File
@@ -40,34 +40,46 @@ Key reference points:
Creating Tones
--------------
.. code-block:: python
.. code-block:: pycon
from pytheory import Tone
>>> from pytheory import Tone
# From a string (most common)
c4 = Tone.from_string("C4")
cs4 = Tone.from_string("C#4")
>>> c4 = Tone.from_string("C4")
>>> cs4 = Tone.from_string("C#4")
>>> db4 = Tone.from_string("Db4")
# Direct construction
d = Tone(name="D", octave=3)
>>> d = Tone(name="D", octave=3)
# With a specific system
a4 = Tone.from_string("A4", system="western")
>>> a4 = Tone.from_string("A4", system="western")
>>> Tone.from_frequency(440)
<Tone A4>
>>> Tone.from_frequency(261.63)
<Tone C4>
>>> Tone.from_midi(60)
<Tone C4>
>>> Tone.from_midi(69)
<Tone A4>
Properties
----------
.. code-block:: python
.. code-block:: pycon
>>> 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
'C'
>>> c4.midi
60
>>> c4.exists
True
Pitch and Frequency
-------------------
@@ -77,17 +89,17 @@ cycles per second). The relationship between pitch and frequency is
**logarithmic**: each octave doubles the frequency, and each semitone
multiplies by the 12th root of 2 (~1.05946).
.. code-block:: python
.. code-block:: pycon
>>> a4 = Tone.from_string("A4", system="western")
>>> a4.frequency
440.0
>>> Tone.from_string("A3", system="western").frequency
220.0 # One octave down = half the frequency
220.0
>>> Tone.from_string("C4", system="western").frequency
261.63 # Middle C
261.6255653005986
Temperament
~~~~~~~~~~~
@@ -112,22 +124,56 @@ same note name:
in closely related keys but "wolf intervals" make distant keys
unusable.
.. code-block:: python
.. code-block:: pycon
>>> a4.pitch(temperament="equal")
440.0
>>> a4.pitch(temperament="pythagorean")
440.0 # A4 is always 440 (it's the reference)
440.0
>>> c5 = Tone.from_string("C5", system="western")
>>> c5.pitch(temperament="equal")
523.25
523.2511306011972
>>> c5.pitch(temperament="pythagorean")
521.48 # Slightly different!
521.4814814814815
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:: pycon
>>> a4 = Tone.from_string("A4", system="western")
# Symbolic output (SymPy expression)
>>> a4.pitch(symbolic=True)
440
>>> Tone.from_string("C5", system="western").pitch(symbolic=True)
440*2**(1/4)
>>> Tone.from_string("G4", system="western").pitch(
... temperament="pythagorean", symbolic=True)
391.111111111111
>>> e4 = Tone.from_string("E4", system="western")
>>> e4.pitch(temperament="equal", symbolic=True)
220.0*2**(7/12)
>>> e4.pitch(temperament="pythagorean", symbolic=True)
330.000000000000
>>> e4.pitch(temperament="meantone", symbolic=True)
220.0*5**(1/4)
>>> e4.pitch(symbolic=True).evalf(50)
329.62755691286992973584176104655507518647334182098
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
-------------------------
@@ -156,34 +202,84 @@ Common intervals::
Tones support ``+`` and ``-`` operators for semitone math:
.. code-block:: python
.. code-block:: pycon
>>> c4 = Tone.from_string("C4", system="western")
>>> c4 + 4 # Major third up
>>> c4 + 4
<Tone E4>
>>> c4 + 7 # Perfect fifth up
>>> c4 + 7
<Tone G4>
>>> c4 + 12 # Octave up
>>> c4 + 12
<Tone C5>
Subtracting two tones gives the semitone distance:
.. code-block:: python
.. code-block:: pycon
>>> g4 = Tone.from_string("G4", system="western")
>>> g4 - c4 # Perfect fifth = 7 semitones
>>> g4 - c4
7
>>> c5 = Tone.from_string("C5", system="western")
>>> c5 - c4 # Octave = 12 semitones
>>> c5 - c4
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:: pycon
>>> c4.interval_to(g4)
'perfect 5th'
>>> c4.interval_to(c4 + 4)
'major 3rd'
>>> c4.interval_to(c5)
'octave'
>>> c4.interval_to(c4 + 19)
'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:: pycon
>>> c4.transpose(7)
<Tone G4>
>>> c4.transpose(-2)
<Tone A#3>
MIDI
~~~~
Every tone maps to a `MIDI note number <https://en.wikipedia.org/wiki/MIDI>`_
(0127), the standard for communicating with synthesizers, DAWs, and
digital instruments:
.. code-block:: pycon
>>> c4.midi
60
>>> Tone.from_string("A4", system="western").midi
69
>>> Tone.from_midi(60).midi
60
Comparison and Sorting
----------------------
Tones can be compared and sorted by pitch frequency:
.. code-block:: python
.. code-block:: pycon
>>> c4 < g4
True
@@ -192,9 +288,9 @@ Tones can be compared and sorted by pitch frequency:
Equality checks note name and octave:
.. code-block:: python
.. code-block:: pycon
>>> c4 == "C" # Compare with string (name only)
>>> c4 == "C"
True
>>> c4 == Tone(name="C", octave=4)
True
@@ -206,7 +302,7 @@ Every tone you hear is actually a composite of many frequencies. When
a string vibrates, it doesn't just vibrate as a whole — it also vibrates
in halves, thirds, quarters, and so on, producing the `harmonic series <https://en.wikipedia.org/wiki/Harmonic_series_(music)>`_:
.. code-block:: python
.. code-block:: pycon
>>> a4 = Tone.from_string("A4", system="western")
>>> a4.overtones(8)
@@ -248,12 +344,18 @@ 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
.. code-block:: pycon
>>> 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'
>>> Tone.from_string("C4", system="western").enharmonic is None
True
The Circle of Fifths
--------------------
@@ -263,13 +365,15 @@ theory. Starting from any note and ascending by perfect fifths (7
semitones), you pass through all 12 chromatic tones before returning
to the starting note:
.. code-block:: python
.. code-block:: pycon
>>> 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")
>>> [t.name for t in c4.circle_of_fifths()]
['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'C#', 'G#', 'D#', 'A#', 'F']
>>> [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.
+70 -15
View File
@@ -1,26 +1,79 @@
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, Scale, 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)
>>> pentatonic = Scale(tonic="A4", system="blues")["minor pentatonic"]
>>> print(fb.scale_diagram(pentatonic, frets=5))
0 1 2 3 4 5
E| E | - | - | G | - | A |
B| - | C | - | D | - | E |
G| G | - | A | - | - | C |
D| D | - | E | - | - | G |
A| A | - | - | C | - | D |
E| E | - | - | G | - | A |
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
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
.. toctree::
:maxdepth: 2
@@ -34,6 +87,8 @@ Work with tones, scales, chords, and fretboards using a clean, Pythonic API.
guide/fretboard
guide/systems
guide/playback
guide/cli
guide/cookbook
.. toctree::
:maxdepth: 2
+46
View File
@@ -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}")
+52
View File
@@ -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)")
+34
View File
@@ -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]}")
+74
View File
@@ -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)")
+93
View File
@@ -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}")
+64
View File
@@ -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}"
)
+58
View File
@@ -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)
+35
View File
@@ -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}")
+68
View File
@@ -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)")
+81
View File
@@ -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 iiVI (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
View File
@@ -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 IVviIV 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 iiVI 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!")
+49
View File
@@ -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()
+677
View File
@@ -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
}
+68
View File
@@ -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}")
+7 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.4.0"
version = "0.8.1"
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"]
@@ -41,5 +44,8 @@ docs = ["sphinx"]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[tool.pytest.ini_options]
markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"]
[tool.setuptools]
packages = ["pytheory"]
+9 -6
View File
@@ -1,25 +1,28 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.4.0"
__version__ = "0.8.1"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
from .scales import Scale, TonedScale, Key, PROGRESSIONS
from .scales import TonedScale, Key, PROGRESSIONS
from .chords import Chord, Fretboard, analyze_progression
from .charts import CHARTS, charts_for_fretboard
from .charts import CHARTS, Fingering, charts_for_fretboard
try:
from .play import play, Synth
from .play import play, save, play_progression, Synth
except OSError:
play = None
save = None
play_progression = None
Synth = None
# Aliases for discoverability.
Note = Tone
Scale = TonedScale
__all__ = [
"Tone", "Note", "Interval", "Scale", "TonedScale", "Key",
"PROGRESSIONS", "Chord", "Fretboard", "analyze_progression",
"PROGRESSIONS", "Chord", "Fretboard", "Fingering", "analyze_progression",
"System", "SYSTEMS", "CHARTS", "charts_for_fretboard",
"play", "Synth",
"play", "save", "play_progression", "Synth",
]
-14
View File
@@ -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}}),
}
}
+315 -23
View File
@@ -1,4 +1,6 @@
import functools
import itertools
from typing import Optional
from .systems import SYSTEMS
from .tones import Tone
@@ -6,6 +8,166 @@ from .tones import Tone
QUALITIES = ("", "maj", "m", "5", "7", "9", "dim", "m6", "m7", "m9", "maj7", "maj9")
MAX_FRET = 7
# Standard guitar tuning (high to low): E4 B3 G3 D3 A2 E2
STANDARD_GUITAR_TUNING = ("E4", "B3", "G3", "D3", "A2", "E2")
# Curated override fingerings for common guitar chords in standard tuning.
# Key: chord name, Value: tuple of fret positions (-1 = muted string).
# Order is high-to-low (matching Fretboard.guitar() string order).
GUITAR_OVERRIDES = {
"C": (0, 1, 0, 2, 3, -1),
"D": (2, 3, 2, 0, -1, -1),
"Dm": (1, 3, 2, 0, -1, -1),
"D7": (2, 1, 2, 0, -1, -1),
"E": (0, 0, 1, 2, 2, 0),
"Em": (0, 0, 0, 2, 2, 0),
"F": (1, 1, 2, 3, 3, 1),
"G": (3, 0, 0, 0, 2, 3),
"G7": (1, 0, 0, 0, 2, 3),
"A": (0, 2, 2, 2, 0, -1),
"Am": (0, 1, 2, 2, 0, -1),
"Am7": (0, 1, 0, 2, 0, -1),
"B": (2, 4, 4, 4, 2, -1),
"Bm": (2, 3, 4, 4, 2, -1),
"B7": (2, 0, 2, 1, 2, -1),
}
# Memoization cache for fingering lookups.
# Key: (chord_name, fretboard_tuning_tuple)
# Value: Fingering object (single) or tuple of Fingerings (multiple)
# Bounded to _CACHE_MAX_SIZE entries; cleared entirely when full.
_CACHE_MAX_SIZE = 1024
_fingering_cache: dict[tuple, "Fingering"] = {}
_fingering_multi_cache: dict[tuple, tuple] = {}
_possible_cache: dict[tuple, tuple] = {}
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()
def tab(self) -> str:
"""Render this fingering as ASCII guitar tablature.
Requires that the Fingering was created with a fretboard reference.
Example::
>>> fb = Fretboard.guitar()
>>> print(fb.chord("C").tab())
C
e|--0--
B|--1--
G|--0--
D|--2--
A|--3--
E|--0--
"""
if self._fretboard is None:
raise ValueError("Cannot render tab without a fretboard reference.")
name = self.identify() or "?"
lines = [name]
max_name = max(len(n) for n in self.string_names)
for sname, fret in zip(self.string_names, self.positions):
fret_str = "x" if fret is None else str(fret)
lines.append(f"{sname:>{max_name}}|--{fret_str}--")
return "\n".join(lines)
CHARTS = {}
CHARTS["western"] = []
@@ -90,6 +252,11 @@ class NamedChord:
return tuple([tone.name for tone in self.acceptable_tones])
def _possible_fingerings(self, *, fretboard):
# Check the _possible_cache first
key = self._cache_key(fretboard)
if key in _possible_cache:
return _possible_cache[key]
def find_fingerings(tone):
fingerings = []
for j in range(MAX_FRET):
@@ -102,13 +269,21 @@ class NamedChord:
fingering = []
for i, tone in enumerate(fretboard.tones):
fingering.append(find_fingerings(tone))
frets = find_fingerings(tone)
# Always allow muting as an option
if frets:
fingering.append((*frets, -1))
else:
fingering.append((-1,))
for i, finger in enumerate(fingering):
if finger == ():
fingering[i] = (-1,)
result = tuple(fingering)
return tuple(fingering)
# Bounded cache: clear entirely if over limit
if len(_possible_cache) >= _CACHE_MAX_SIZE:
_possible_cache.clear()
_possible_cache[key] = result
return result
@staticmethod
def fix_fingering(fingering):
@@ -121,38 +296,155 @@ class NamedChord:
def fingerings(self, *, fretboard):
return tuple(itertools.product(*self._possible_fingerings(fretboard=fretboard)))
def _cache_key(self, fretboard):
"""Return a hashable key for memoization."""
return (self.name, tuple(t.full_name for t in fretboard.tones))
def fingering(self, *, fretboard, multiple=False):
# Check cache first
key = self._cache_key(fretboard)
if multiple:
if key in _fingering_multi_cache:
return _fingering_multi_cache[key]
else:
if key in _fingering_cache:
return _fingering_cache[key]
# Check for curated guitar chord overrides in standard tuning
tuning = tuple(t.full_name for t in fretboard.tones)
if tuning == STANDARD_GUITAR_TUNING and self.name in GUITAR_OVERRIDES:
string_names = tuple(t.name for t in fretboard.tones)
override = GUITAR_OVERRIDES[self.name]
if not multiple:
result = Fingering(self.fix_fingering(override), string_names, fretboard=fretboard)
if len(_fingering_cache) >= _CACHE_MAX_SIZE:
_fingering_cache.clear()
_fingering_cache[key] = result
return result
else:
result = (Fingering(self.fix_fingering(override), string_names, fretboard=fretboard),)
if len(_fingering_multi_cache) >= _CACHE_MAX_SIZE:
_fingering_multi_cache.clear()
_fingering_multi_cache[key] = result
return result
MAX_SPAN = 4 # max fret span for a human hand
def fingering_score(fingering):
def number_of_fingers(fingering):
zeros = 0
for finger in fingering:
if finger == 0:
zeros += 1
return len(fingering) - zeros
score = 0.0
fretted = [f for f in fingering if f not in (0, -1)]
muted = sum(1 for f in fingering if f == -1)
sounding = len(fingering) - muted
def ascending(fingering):
fingering = [f for f in fingering if f != 0]
# Must have at least 2 sounding strings
if sounding < 2:
return -100.0
return sorted(fingering) == fingering
# Hard constraint: fret span must be playable
if fretted:
span = max(fretted) - min(fretted)
if span > MAX_SPAN:
return -100.0
else:
span = 0
ascending = int(ascending(fingering))
finger_count = number_of_fingers(fingering)
return ascending + (1 / finger_count)
# Check that all chord tones are present in the voicing
sounding_names = set()
for i, f in enumerate(fingering):
if f != -1:
sounding_names.add(fretboard.tones[i].add(f).name)
required = set(t.name for t in self.acceptable_tones)
missing = required - sounding_names
score -= len(missing) * 5.0
# Reward open strings
open_strings = sum(1 for f in fingering if f == 0)
score += open_strings * 2.0
# Penalize muted strings, but only mildly
score -= muted * 0.3
# Penalize fret span
score -= span * 2.0
# Penalize high fret positions (prefer open position)
if fretted:
score -= (sum(fretted) / len(fretted)) * 0.8
# Barre chord detection: if multiple strings share the same
# fret and it's the lowest fret in the shape, one finger can
# cover them all — so they cost only 1 finger, not N.
# Also check that barre strings are contiguous (no gaps).
if fretted:
min_fret = min(fretted)
barre_indices = [i for i, f in enumerate(fingering) if f == min_fret and f > 0]
barre_count = len(barre_indices)
if barre_count >= 2:
unique_higher = len(set(f for f in fretted if f > min_fret))
fingers_needed = unique_higher + 1 # 1 for barre
# Mild reward for barre efficiency (saves fingers)
score += (barre_count - 1) * 0.5
else:
fingers_needed = len(fretted)
else:
fingers_needed = 0
# Penalize fingers needed (max 4 on a guitar)
score -= fingers_needed * 0.3
if fingers_needed > 4:
score -= (fingers_needed - 4) * 5.0
# Reward root in bass — the lowest sounding string
for i in range(len(fingering) - 1, -1, -1):
f = fingering[i]
if f == -1:
continue
bass_tone = fretboard.tones[i].add(f)
if bass_tone.name == self.tone.name:
score += 4.0
else:
score -= 1.5
break
# Prefer muting from the bass side (contiguous muting)
# e.g. xx0232 is good, x0x232 is awkward
mute_from_bass = 0
for i in range(len(fingering) - 1, -1, -1):
if fingering[i] == -1:
mute_from_bass += 1
else:
break
interior_mutes = muted - mute_from_bass
score -= interior_mutes * 3.0
return score
def gen():
fingerings = self.fingerings(fretboard=fretboard)
score_map = tuple(map(fingering_score, fingerings))
max_score = max(score_map)
scored = [(fingering_score(f), f) for f in fingerings]
max_score = max(s for s, _ in scored)
for possible_fingering in fingerings:
if fingering_score(possible_fingering) == max_score:
for s, possible_fingering in scored:
if s == 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])
result = Fingering(self.fix_fingering(best_fingerings[0]), string_names, fretboard=fretboard)
# Bounded cache: clear entirely if over limit
if len(_fingering_cache) >= _CACHE_MAX_SIZE:
_fingering_cache.clear()
_fingering_cache[key] = result
return result
else:
return tuple([self.fix_fingering(f) for f in best_fingerings])
result = tuple([Fingering(self.fix_fingering(f), string_names, fretboard=fretboard) for f in best_fingerings])
# Bounded cache: clear entirely if over limit
if len(_fingering_multi_cache) >= _CACHE_MAX_SIZE:
_fingering_multi_cache.clear()
_fingering_multi_cache[key] = result
return result
def tab(self, *, fretboard):
"""Render this chord as ASCII guitar tablature.
+100 -18
View File
@@ -680,8 +680,8 @@ class Chord:
"""
return Chord(tones=[t for t in self.tones if t.name != tone_name])
def fingering(self, *positions: int) -> Chord:
"""Apply fret positions to each tone, returning a new Chord.
def fingering(self, *positions: int) -> "Fingering":
"""Apply fret positions to each tone, returning a Fingering.
Each position value is added (in semitones) to the corresponding
tone. The number of positions must match the number of tones.
@@ -690,22 +690,21 @@ class Chord:
*positions: One integer per tone indicating the fret offset.
Returns:
A new :class:`Chord` with each tone shifted by its position.
A :class:`Fingering` labeled with tone names.
Raises:
ValueError: If the number of positions doesn't match the
number of tones.
"""
from .charts import Fingering
if not len(positions) == len(self.tones):
raise ValueError(
"The number of positions must match the number of tones (strings)."
)
tones = []
for i, tone in enumerate(self.tones):
tones.append(tone.add(positions[i]))
return Chord(tones=tones)
string_names = tuple(t.name for t in self.tones)
return Fingering(positions, string_names)
class Fretboard:
@@ -1235,8 +1234,15 @@ class Fretboard:
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))
# Each cell is " X |" where X is a note name or dash.
# Cell content width is 3 chars (space + 2-char note/dash).
# Full cell with separator: 4 chars.
# Header must align fret numbers to the center of each cell.
header_parts = []
for f in range(frets + 1):
header_parts.append(f"{f:>2} ")
# Offset header to align with cell content (after "X|" prefix)
header = " " * (max_name + 2) + " ".join(header_parts)
lines.append(header)
for tone in self.tones:
@@ -1252,8 +1258,84 @@ class Fretboard:
return "\n".join(lines)
def fingering(self, *positions: int) -> Chord:
"""Apply fret positions to each string, returning a Chord.
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 __getitem__(self, name: str) -> "Fingering":
"""Shorthand for :meth:`chord` — ``fb["G"]`` equals ``fb.chord("G")``.
Args:
name: Chord name like ``"G"``, ``"Am7"``, ``"Bb"``.
Returns:
A :class:`Fingering` for that chord on this fretboard.
Example::
>>> fb = Fretboard.guitar()
>>> fb["G"]
Fingering(e=3, B=0, G=0, D=0, A=2, E=3)
"""
return self.chord(name)
def tab(self, name: str, *, system: str = "western") -> str:
"""Look up a chord by name and return its ASCII tablature.
Args:
name: Chord name like ``"G"``, ``"Am7"``, ``"Bb"``.
system: Tonal system to use (default ``"western"``).
Returns:
A multi-line string showing the chord as tablature.
Example::
>>> fb = Fretboard.guitar()
>>> print(fb.tab("Am"))
A minor
e|--0--
B|--1--
G|--2--
D|--2--
A|--0--
E|--0--
"""
return self.chord(name, system=system).tab()
def chart(self, *, system: str = "western") -> dict:
"""Generate fingerings for every chord in the given system.
Returns:
A dict mapping chord names to :class:`Fingering` objects.
Example::
>>> fb = Fretboard.guitar()
>>> chart = fb.chart()
>>> chart["Am7"]
Fingering(e=0, B=1, G=0, D=2, A=0, E=0)
"""
from .charts import charts_for_fretboard, CHARTS
return charts_for_fretboard(chart=CHARTS[system], 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
@@ -1263,22 +1345,22 @@ class Fretboard:
*positions: One integer per string indicating the fret number.
Returns:
A :class:`Chord` with each tone shifted by its fret position.
A :class:`Fingering` labeled with string names. Call
``.to_chord(fretboard)`` or use the resulting chord directly.
Raises:
ValueError: If the number of positions doesn't match the
number of strings.
"""
from .charts import Fingering
if not len(positions) == len(self.tones):
raise ValueError(
"The number of positions must match the number of tones (strings)."
)
tones = []
for i, tone in enumerate(self.tones):
tones.append(tone.add(positions[i]))
return Chord(tones=tones)
string_names = tuple(t.name for t in self.tones)
return Fingering(positions, string_names, fretboard=self)
def analyze_progression(chords: list[Chord], key: str = "C", mode: str = "major") -> list[str | None]:
+215
View File
@@ -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()
+100 -33
View File
@@ -1,12 +1,14 @@
from enum import Enum
import time
import numpy
import scipy.signal
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 +22,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 +59,91 @@ 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)
def play_progression(chords, *, t=1000, synth=Synth.SINE, gap=100):
"""Play a list of chords in sequence.
Args:
chords: List of Chord objects to play in order.
t: Duration of each chord in milliseconds.
synth: Waveform type (Synth.SINE, etc). Defaults to sine.
gap: Silence between chords in milliseconds.
Example::
>>> from pytheory import Key, play_progression
>>> chords = Key("C", "major").progression("I", "V", "vi", "IV")
>>> play_progression(chords, t=800)
"""
for i, chord in enumerate(chords):
play(chord, synth=synth, t=t)
if gap > 0 and i < len(chords) - 1:
time.sleep(gap / 1000.0)
+28 -5
View File
@@ -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)):
@@ -653,34 +652,58 @@ class TonedScale:
try:
return self._scales[scale]
except KeyError:
pass
return None
@property
def scales(self) -> tuple[str, ...]:
"""Tuple of all available scale names in this system."""
return tuple(self._scales.keys())
@staticmethod
def _should_prefer_flats(tones: list) -> bool:
"""Determine if a scale should use flat spellings.
Uses the "no duplicate letters" rule: build the scale with sharps
first, and if any letter name appears twice (excluding the octave
repeat at the end), try flats instead. This correctly handles all
keys on the circle of fifths.
"""
# Exclude the last tone (octave repeat of the tonic)
unique_tones = tones[:-1] if len(tones) > 1 else tones
letters = [t.name[0] for t in unique_tones]
return len(letters) != len(set(letters))
@property
def _scales(self) -> dict[str, Scale]:
"""Lazily computed (and cached) mapping of scale names to Scale objects."""
if self._cached_scales is not None:
return self._cached_scales
# Also check if tonic itself is a flat (always prefer flats then)
tonic_is_flat = "b" in self.tonic.name and self.tonic.name != "B"
scales = {}
for scale_type in self.system.scales:
for scale in self.system.scales[scale_type]:
working_scale = []
reference_scale = self.system.scales[scale_type][scale]["intervals"]
# First pass: build with sharps (default)
working_scale = [self.tonic]
current_tone = self.tonic
working_scale.append(current_tone)
for interval in reference_scale:
current_tone = current_tone.add(interval)
working_scale.append(current_tone)
# Check if we need flats (duplicate letter names)
if tonic_is_flat or self._should_prefer_flats(working_scale):
working_scale = [self.tonic]
current_tone = self.tonic
for interval in reference_scale:
current_tone = current_tone.add(interval, prefer_flats=True)
working_scale.append(current_tone)
scales[scale] = Scale(tones=tuple(working_scale))
self._cached_scales = scales
+10 -1
View File
@@ -24,6 +24,16 @@ class System:
from . import Tone
return tuple([Tone.from_tuple(tone) for tone in self.tone_names])
def resolve_name(self, name: str) -> str | None:
"""Resolve a note name (including flats) to the canonical name.
Returns the primary name if found, or None if not recognized.
"""
for names in self.tone_names:
if name in names:
return names[0]
return None
@property
def scales(self):
@@ -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 = [
+21 -7
View File
@@ -71,7 +71,7 @@ class Tone:
@property
def exists(self) -> bool:
"""True if this tone's name is found in the associated system."""
return self.name in self.system.tones
return self.system.resolve_name(self.name) is not None
@property
def system(self) -> object:
@@ -313,29 +313,41 @@ class Tone:
return klass.from_index(index, octave=octave, system=system)
@classmethod
def from_index(klass, i: int, *, octave: int, system: object) -> Tone:
def from_index(klass, i: int, *, octave: int, system: object, prefer_flats: bool = False) -> Tone:
"""Create a Tone from its index within a tuning system.
Args:
i: The index of the tone in the system's tone list.
octave: The octave number.
system: The ``ToneSystem`` instance.
prefer_flats: If True and the tone has a flat spelling,
use it instead of the default sharp spelling.
Returns:
A new ``Tone`` instance.
"""
tone = system.tones[i].name
tone_names = system.tone_names[i]
if prefer_flats and len(tone_names) > 1:
tone = tone_names[1] # flat spelling (e.g. "Bb")
else:
tone = tone_names[0] # sharp spelling (e.g. "A#")
return klass(name=tone, octave=octave, system=system)
@property
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!")
@@ -369,17 +381,19 @@ class Tone:
return (new_index, new_octave)
def add(self, interval: int) -> Tone:
def add(self, interval: int, *, prefer_flats: bool = False) -> Tone:
"""Return a new Tone that is *interval* semitones above this one.
Args:
interval: Number of semitones to add (positive = up).
prefer_flats: If True, use flat spellings (Bb, Eb) instead
of sharp spellings (A#, D#) for accidentals.
Returns:
A new ``Tone`` instance.
"""
index, octave = self._math(interval)
return self.from_index(index, octave=octave, system=self.system)
return self.from_index(index, octave=octave, system=self.system, prefer_flats=prefer_flats)
def subtract(self, interval: int) -> Tone:
"""Return a new Tone that is *interval* semitones below this one.
+498 -15
View File
@@ -238,8 +238,8 @@ def test_c_minor_scale():
c = TonedScale(tonic="C4")
minor = c["minor"]
names = [t.name for t in minor.tones]
# C D Eb F G Ab Bb C (using sharps: D#, G#, A#)
assert names == ["C", "D", "D#", "F", "G", "G#", "A#", "C"]
# C D Eb F G Ab Bb C (using flats for flat keys)
assert names == ["C", "D", "Eb", "F", "G", "Ab", "Bb", "C"]
def test_c_harmonic_minor_scale():
@@ -247,7 +247,7 @@ def test_c_harmonic_minor_scale():
hminor = c["harmonic minor"]
names = [t.name for t in hminor.tones]
# C D Eb F G Ab B C (raised 7th)
assert names == ["C", "D", "D#", "F", "G", "G#", "B", "C"]
assert names == ["C", "D", "Eb", "F", "G", "Ab", "B", "C"]
def test_g_major_scale():
@@ -308,7 +308,7 @@ def test_c_dorian():
dorian = c["dorian"]
names = [t.name for t in dorian.tones]
# Dorian: W H W W W H W → C D Eb F G A Bb C
assert names == ["C", "D", "D#", "F", "G", "A", "A#", "C"]
assert names == ["C", "D", "Eb", "F", "G", "A", "Bb", "C"]
def test_c_phrygian():
@@ -316,7 +316,7 @@ def test_c_phrygian():
phrygian = c["phrygian"]
names = [t.name for t in phrygian.tones]
# Phrygian: H W W W H W W → C Db Eb F G Ab Bb C
assert names == ["C", "C#", "D#", "F", "G", "G#", "A#", "C"]
assert names == ["C", "Db", "Eb", "F", "G", "Ab", "Bb", "C"]
def test_c_lydian():
@@ -332,7 +332,7 @@ def test_c_mixolydian():
mixolydian = c["mixolydian"]
names = [t.name for t in mixolydian.tones]
# Mixolydian: W W H W W H W → C D E F G A Bb C
assert names == ["C", "D", "E", "F", "G", "A", "A#", "C"]
assert names == ["C", "D", "E", "F", "G", "A", "Bb", "C"]
def test_c_locrian():
@@ -340,7 +340,7 @@ def test_c_locrian():
locrian = c["locrian"]
names = [t.name for t in locrian.tones]
# Locrian: H W W H W W W → C Db Eb F Gb Ab Bb C
assert names == ["C", "C#", "D#", "F", "F#", "G#", "A#", "C"]
assert names == ["C", "Db", "Eb", "F", "Gb", "Ab", "Bb", "C"]
# ── Chords ───────────────────────────────────────────────────────────────────
@@ -525,6 +525,7 @@ def test_chord_fingering_em(guitar_fretboard):
assert zeros >= 3
@pytest.mark.slow
def test_chord_fingering_all_western_chords(guitar_fretboard):
"""Every chord in the western chart should produce a valid fingering."""
for name, chord in CHARTS["western"].items():
@@ -975,7 +976,7 @@ def test_f_major_scale():
f = TonedScale(tonic="F4")
major = f["major"]
names = [t.name for t in major.tones]
assert names == ["F", "G", "A", "A#", "C", "D", "E", "F"]
assert names == ["F", "G", "A", "Bb", "C", "D", "E", "F"]
def test_a_minor_scale():
@@ -1330,6 +1331,7 @@ def test_charts_all_qualities_present():
assert len(matching) > 0, f"No chords with quality '{quality}'"
@pytest.mark.slow
def test_charts_for_fretboard(guitar_fretboard):
result = charts_for_fretboard(fretboard=guitar_fretboard)
assert len(result) == len(CHARTS["western"])
@@ -1337,6 +1339,7 @@ def test_charts_for_fretboard(guitar_fretboard):
assert len(fingering) == 6, f"{name} has wrong fingering length"
@pytest.mark.slow
def test_charts_fingering_values_in_range(guitar_fretboard):
"""All fret values should be 0-6 or None (muted)."""
for name, chord in CHARTS["western"].items():
@@ -2296,7 +2299,7 @@ def test_japanese_hirajoshi():
c = TonedScale(tonic="C4", system=SYSTEMS["japanese"])
h = c["hirajoshi"]
names = [t.name for t in h]
assert names == ["C", "D", "D#", "G", "G#", "C"]
assert names == ["C", "D", "Eb", "G", "Ab", "C"]
def test_japanese_in_scale():
@@ -2304,7 +2307,7 @@ def test_japanese_in_scale():
c = TonedScale(tonic="C4", system=SYSTEMS["japanese"])
s = c["in"]
names = [t.name for t in s]
assert names == ["C", "C#", "F", "G", "G#", "C"]
assert names == ["C", "Db", "F", "G", "Ab", "C"]
def test_japanese_yo_scale():
@@ -2320,7 +2323,7 @@ def test_japanese_iwato():
c = TonedScale(tonic="C4", system=SYSTEMS["japanese"])
s = c["iwato"]
names = [t.name for t in s]
assert names == ["C", "C#", "F", "F#", "A#", "C"]
assert names == ["C", "Db", "F", "Gb", "Bb", "C"]
def test_japanese_kumoi():
@@ -2328,7 +2331,7 @@ def test_japanese_kumoi():
c = TonedScale(tonic="C4", system=SYSTEMS["japanese"])
s = c["kumoi"]
names = [t.name for t in s]
assert names == ["C", "D", "D#", "G", "A", "C"]
assert names == ["C", "D", "Eb", "G", "A", "C"]
def test_japanese_ritsu():
@@ -2336,7 +2339,7 @@ def test_japanese_ritsu():
c = TonedScale(tonic="C4", system=SYSTEMS["japanese"])
s = c["ritsu"]
names = [t.name for t in s]
assert names == ["C", "D", "D#", "F", "G", "A", "A#", "C"]
assert names == ["C", "D", "Eb", "F", "G", "A", "Bb", "C"]
def test_japanese_all_scales_available():
@@ -2382,7 +2385,7 @@ def test_blues_scale():
c = TonedScale(tonic="C4", system=SYSTEMS["blues"])
s = c["blues"]
names = s.note_names
assert names == ["C", "D#", "F", "F#", "G", "A#", "C"]
assert names == ["C", "Eb", "F", "Gb", "G", "Bb", "C"]
assert len(names) == 7 # 6 notes + octave
@@ -2622,7 +2625,7 @@ def test_tension_empty():
def test_version():
import pytheory
assert pytheory.__version__ == "0.4.0"
assert pytheory.__version__
def test_all_exports():
@@ -3440,3 +3443,483 @@ def test_scale_diagram():
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)
def test_fretboard_chord_method():
"""Fretboard.chord() looks up a chord by name."""
fb = Fretboard.guitar()
f = fb.chord("G")
assert f.identify() == "G major"
assert len(f) == 6
def test_fretboard_chord_system_kwarg():
"""Fretboard.chord() accepts a system keyword argument."""
fb = Fretboard.guitar()
f = fb.chord("Am", system="western")
assert f.identify() == "A minor"
def test_fretboard_tab_method():
"""Fretboard.tab() returns ASCII tablature."""
fb = Fretboard.guitar()
tab = fb.tab("C")
assert "C major" in tab
assert "e|" in tab
assert "E|" in tab
@pytest.mark.slow
def test_fretboard_chart_method():
"""Fretboard.chart() generates all fingerings."""
fb = Fretboard.guitar()
chart = fb.chart()
assert "C" in chart
assert "Am7" in chart
assert chart["C"].identify() == "C major"
def test_fingering_tab_method():
"""Fingering.tab() renders ASCII tablature."""
fb = Fretboard.guitar()
f = fb.chord("Em")
tab = f.tab()
assert "E minor" in tab
assert "e|" in tab
# ── 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()
Generated
+1 -1
View File
@@ -612,7 +612,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.4.0"
version = "0.4.1"
source = { editable = "." }
dependencies = [
{ name = "numeral" },