mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aad427fb8 | |||
| e9c630705e | |||
| e78ba203d9 | |||
| c307c1e41f | |||
| b1f6996cd7 | |||
| 51ca98779d | |||
| 37b41e1bbf | |||
| da40189845 |
+1
-1
@@ -10,7 +10,7 @@ sys.modules["sounddevice"] = MagicMock()
|
||||
project = "PyTheory"
|
||||
copyright = "2026, Kenneth Reitz"
|
||||
author = "Kenneth Reitz"
|
||||
release = "0.3.2"
|
||||
release = "0.4.1"
|
||||
|
||||
extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
|
||||
+39
-1
@@ -125,9 +125,47 @@ same note name:
|
||||
>>> c5.pitch(temperament="pythagorean")
|
||||
521.48 # Slightly different!
|
||||
|
||||
# Symbolic output (SymPy expression)
|
||||
Symbolic Pitch
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
Pass ``symbolic=True`` to get exact pitch ratios as
|
||||
`SymPy <https://en.wikipedia.org/wiki/SymPy>`_ expressions instead of
|
||||
floating-point approximations. This is useful for mathematical analysis,
|
||||
proving tuning relationships, or comparing temperaments with exact
|
||||
arithmetic.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
>>> a4 = Tone.from_string("A4", system="western")
|
||||
|
||||
# Equal temperament: irrational ratios (roots of 2)
|
||||
>>> a4.pitch(symbolic=True)
|
||||
440
|
||||
>>> Tone.from_string("C5", system="western").pitch(symbolic=True)
|
||||
440*2**(1/4)
|
||||
|
||||
# Pythagorean: pure rational ratios (powers of 3/2)
|
||||
>>> Tone.from_string("G4", system="western").pitch(
|
||||
... temperament="pythagorean", symbolic=True)
|
||||
660
|
||||
|
||||
# Compare the major third across temperaments
|
||||
>>> e4 = Tone.from_string("E4", system="western")
|
||||
>>> e4.pitch(temperament="equal", symbolic=True)
|
||||
440*2**(1/3)
|
||||
>>> e4.pitch(temperament="pythagorean", symbolic=True)
|
||||
12160/27
|
||||
>>> e4.pitch(temperament="meantone", symbolic=True)
|
||||
550
|
||||
|
||||
# Symbolic expressions can be evaluated to any precision
|
||||
>>> e4.pitch(symbolic=True).evalf(50)
|
||||
329.62755691286991583007431157433859631791591649985
|
||||
|
||||
The symbolic output reveals *why* temperaments differ: equal temperament
|
||||
uses irrational numbers (roots of 2), Pythagorean uses powers of 3/2
|
||||
(rational but accumulating error), and meantone tunes thirds to the
|
||||
pure 5/4 ratio (sacrificing fifths).
|
||||
|
||||
Intervals and Arithmetic
|
||||
-------------------------
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pytheory"
|
||||
version = "0.3.2"
|
||||
version = "0.5.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"]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""PyTheory: Music Theory for Humans."""
|
||||
|
||||
__version__ = "0.3.2"
|
||||
__version__ = "0.4.1"
|
||||
|
||||
from .tones import Tone, Interval
|
||||
from .systems import System, SYSTEMS
|
||||
from .scales import Scale, TonedScale, Key, PROGRESSIONS
|
||||
from .chords import Chord, Fretboard
|
||||
from .chords import Chord, Fretboard, analyze_progression
|
||||
from .charts import CHARTS, charts_for_fretboard
|
||||
|
||||
try:
|
||||
@@ -19,7 +19,7 @@ Note = Tone
|
||||
|
||||
__all__ = [
|
||||
"Tone", "Note", "Interval", "Scale", "TonedScale", "Key",
|
||||
"PROGRESSIONS", "Chord", "Fretboard",
|
||||
"PROGRESSIONS", "Chord", "Fretboard", "analyze_progression",
|
||||
"System", "SYSTEMS", "CHARTS", "charts_for_fretboard",
|
||||
"play", "Synth",
|
||||
]
|
||||
|
||||
@@ -60,6 +60,36 @@ class Chord:
|
||||
f"{t.name}{octave}", system="western"))
|
||||
return cls(tones=tones)
|
||||
|
||||
@classmethod
|
||||
def from_intervals(cls, root: str, *intervals: int, octave: int = 4) -> Chord:
|
||||
"""Create a Chord from a root note and semitone intervals.
|
||||
|
||||
Example::
|
||||
|
||||
>>> Chord.from_intervals("C", 4, 7) # C major
|
||||
<Chord C major>
|
||||
>>> Chord.from_intervals("G", 4, 7, 10) # G7
|
||||
<Chord G dominant 7th>
|
||||
>>> Chord.from_intervals("D", 3, 7) # D minor
|
||||
<Chord D minor>
|
||||
"""
|
||||
from .tones import Tone
|
||||
root_tone = Tone.from_string(f"{root}{octave}", system="western")
|
||||
tones = [root_tone] + [root_tone.add(i) for i in intervals]
|
||||
return cls(tones=tones)
|
||||
|
||||
@classmethod
|
||||
def from_midi_message(cls, *note_numbers: int) -> Chord:
|
||||
"""Create a Chord from MIDI note numbers.
|
||||
|
||||
Example::
|
||||
|
||||
>>> Chord.from_midi_message(60, 64, 67) # C4, E4, G4
|
||||
<Chord C major>
|
||||
"""
|
||||
from .tones import Tone
|
||||
return cls(tones=[Tone.from_midi(n) for n in note_numbers])
|
||||
|
||||
def __repr__(self) -> str:
|
||||
name = self.identify()
|
||||
if name:
|
||||
@@ -625,6 +655,31 @@ class Chord:
|
||||
"has_dominant_function": has_dominant,
|
||||
}
|
||||
|
||||
def add_tone(self, tone) -> Chord:
|
||||
"""Return a new Chord with an additional tone.
|
||||
|
||||
Example::
|
||||
|
||||
>>> c_major = Chord.from_tones("C", "E", "G")
|
||||
>>> c_major.add_tone(Tone.from_string("B4", system="western"))
|
||||
<Chord C major 7th>
|
||||
"""
|
||||
return Chord(tones=list(self.tones) + [tone])
|
||||
|
||||
def remove_tone(self, tone_name: str) -> Chord:
|
||||
"""Return a new Chord with tones of the given name removed.
|
||||
|
||||
Args:
|
||||
tone_name: The note name to remove (e.g. "G").
|
||||
|
||||
Example::
|
||||
|
||||
>>> cmaj7 = Chord.from_name("Cmaj7")
|
||||
>>> cmaj7.remove_tone("B") # Remove the 7th
|
||||
<Chord C major>
|
||||
"""
|
||||
return Chord(tones=[t for t in self.tones if t.name != tone_name])
|
||||
|
||||
def fingering(self, *positions: int) -> Chord:
|
||||
"""Apply fret positions to each tone, returning a new Chord.
|
||||
|
||||
@@ -1156,6 +1211,47 @@ class Fretboard:
|
||||
]
|
||||
return cls(tones=[Tone.from_string(t, system="western") for t in strings])
|
||||
|
||||
def scale_diagram(self, scale, frets: int = 12) -> str:
|
||||
"""Render an ASCII diagram showing where scale notes fall on the neck.
|
||||
|
||||
Each string is shown with dots on frets where scale notes appear.
|
||||
Useful for learning scale patterns on guitar, mandolin, etc.
|
||||
|
||||
Args:
|
||||
scale: A Scale object (or anything with a ``note_names`` attribute).
|
||||
frets: Number of frets to display (default 12).
|
||||
|
||||
Returns:
|
||||
A multi-line string showing the fretboard diagram.
|
||||
|
||||
Example::
|
||||
|
||||
>>> from pytheory import Fretboard, TonedScale
|
||||
>>> fb = Fretboard.guitar()
|
||||
>>> pentatonic = TonedScale(tonic="A4")["minor"]
|
||||
>>> print(fb.scale_diagram(pentatonic, frets=5))
|
||||
"""
|
||||
scale_notes = set(scale.note_names)
|
||||
max_name = max(len(t.name) for t in self.tones)
|
||||
lines = []
|
||||
|
||||
# Header with fret numbers
|
||||
header = " " * (max_name + 1) + " ".join(f"{f:<3d}" for f in range(frets + 1))
|
||||
lines.append(header)
|
||||
|
||||
for tone in self.tones:
|
||||
fret_marks = []
|
||||
for f in range(frets + 1):
|
||||
note = tone.add(f)
|
||||
if note.name in scale_notes:
|
||||
fret_marks.append(f" {note.name:<2s}")
|
||||
else:
|
||||
fret_marks.append(" - ")
|
||||
line = f"{tone.name:>{max_name}}|{'|'.join(fret_marks)}|"
|
||||
lines.append(line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def fingering(self, *positions: int) -> Chord:
|
||||
"""Apply fret positions to each string, returning a Chord.
|
||||
|
||||
@@ -1183,3 +1279,15 @@ class Fretboard:
|
||||
tones.append(tone.add(positions[i]))
|
||||
|
||||
return Chord(tones=tones)
|
||||
|
||||
|
||||
def analyze_progression(chords: list[Chord], key: str = "C", mode: str = "major") -> list[str | None]:
|
||||
"""Analyze a list of chords and return their Roman numeral functions.
|
||||
|
||||
Example::
|
||||
|
||||
>>> chords = [Chord.from_name("C"), Chord.from_name("Am"), Chord.from_name("F"), Chord.from_name("G")]
|
||||
>>> analyze_progression(chords, key="C")
|
||||
['I', 'vi', 'IV', 'V']
|
||||
"""
|
||||
return [c.analyze(key, mode) for c in chords]
|
||||
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
"""PyTheory CLI — music theory from the command line."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def cmd_tone(args):
|
||||
from .tones import Tone
|
||||
tone = Tone.from_string(args.note, system="western")
|
||||
freq = tone.pitch(temperament=args.temperament)
|
||||
print(f" Note: {tone.full_name}")
|
||||
print(f" Frequency: {freq:.2f} Hz ({args.temperament} temperament)")
|
||||
if args.temperament != "equal":
|
||||
import math
|
||||
equal_freq = tone.pitch(temperament="equal")
|
||||
diff_cents = 1200 * math.log2(freq / equal_freq) if freq > 0 else 0
|
||||
print(f" Equal temp: {equal_freq:.2f} Hz (diff: {diff_cents:+.1f} cents)")
|
||||
if tone.midi is not None:
|
||||
print(f" MIDI: {tone.midi}")
|
||||
if tone.enharmonic:
|
||||
print(f" Enharmonic: {tone.enharmonic}")
|
||||
print(f" Overtones: {', '.join(f'{h:.1f}' for h in tone.overtones(6))}")
|
||||
|
||||
|
||||
def cmd_scale(args):
|
||||
from .scales import TonedScale
|
||||
ts = TonedScale(tonic=f"{args.tonic}4", system=args.system)
|
||||
scale = ts[args.mode]
|
||||
print(f" {args.tonic} {args.mode}: {' '.join(scale.note_names)}")
|
||||
print(f" Intervals: {scale.tones[0].full_name}", end="")
|
||||
for i in range(1, len(scale.tones)):
|
||||
semitones = abs(scale.tones[i] - scale.tones[i-1])
|
||||
print(f" -{semitones}- {scale.tones[i].full_name}", end="")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_chord(args):
|
||||
from .tones import Tone
|
||||
from .chords import Chord
|
||||
tones = [Tone.from_string(f"{n}4", system="western") for n in args.notes]
|
||||
chord = Chord(tones=tones)
|
||||
name = chord.identify() or "Unknown"
|
||||
print(f" Chord: {name}")
|
||||
print(f" Tones: {' '.join(t.full_name for t in chord.tones)}")
|
||||
print(f" Intervals: {chord.intervals}")
|
||||
print(f" Harmony: {chord.harmony:.4f}")
|
||||
print(f" Dissonance: {chord.dissonance:.4f}")
|
||||
t = chord.tension
|
||||
print(f" Tension: {t['score']:.2f} (tritones={t['tritones']})")
|
||||
|
||||
|
||||
def cmd_key(args):
|
||||
from .scales import Key
|
||||
key = Key(args.tonic, args.mode)
|
||||
sig = key.signature
|
||||
acc = ", ".join(sig["accidentals"]) if sig["accidentals"] else "none"
|
||||
print(f" Key: {key}")
|
||||
print(f" Signature: {sig['sharps']} sharps, {sig['flats']} flats ({acc})")
|
||||
print(f" Scale: {' '.join(key.note_names)}")
|
||||
print(f" Triads:")
|
||||
for chord in key.scale.harmonize():
|
||||
analysis = chord.analyze(args.tonic, args.mode) or "?"
|
||||
print(f" {analysis:6s} {chord}")
|
||||
print(f" 7th chords:")
|
||||
for name in key.seventh_chords:
|
||||
print(f" {name}")
|
||||
print(f" Relative: {key.relative}")
|
||||
print(f" Parallel: {key.parallel}")
|
||||
|
||||
|
||||
def cmd_fingering(args):
|
||||
from .charts import CHARTS
|
||||
from .chords import Fretboard
|
||||
chart = CHARTS.get("western", {})
|
||||
if args.chord not in chart:
|
||||
print(f" Unknown chord: {args.chord}")
|
||||
sys.exit(1)
|
||||
fb = Fretboard.guitar(capo=args.capo)
|
||||
print(chart[args.chord].tab(fretboard=fb))
|
||||
|
||||
|
||||
def cmd_progression(args):
|
||||
from .scales import Key
|
||||
key = Key(args.tonic, args.mode)
|
||||
chords = key.progression(*args.numerals)
|
||||
print(f" Key: {key}")
|
||||
print(f" Progression: {' → '.join(args.numerals)}")
|
||||
print()
|
||||
for numeral, chord in zip(args.numerals, chords):
|
||||
print(f" {numeral:6s} {chord}")
|
||||
|
||||
|
||||
def cmd_play(args):
|
||||
from .tones import Tone
|
||||
from .chords import Chord
|
||||
from .play import play, Synth
|
||||
|
||||
synth_map = {"sine": Synth.SINE, "saw": Synth.SAW, "triangle": Synth.TRIANGLE}
|
||||
synth = synth_map[args.synth]
|
||||
duration = args.duration
|
||||
|
||||
# Try chord name first (e.g. "Am", "Cmaj7"), then fall back to individual notes.
|
||||
if len(args.notes) == 1:
|
||||
note = args.notes[0]
|
||||
# Try as chord name first (Am, G7, Cmaj7, etc.)
|
||||
try:
|
||||
target = Chord.from_name(note)
|
||||
name = target.identify() or note
|
||||
label = f"{name} ({' '.join(t.full_name for t in target.tones)})"
|
||||
except (ValueError, KeyError):
|
||||
# Fall back to single tone
|
||||
target = Tone.from_string(
|
||||
note if any(c.isdigit() for c in note) else f"{note}4",
|
||||
system="western")
|
||||
label = target.full_name
|
||||
else:
|
||||
tones = [Tone.from_string(n if any(c.isdigit() for c in n) else f"{n}4",
|
||||
system="western") for n in args.notes]
|
||||
target = Chord(tones=tones)
|
||||
name = target.identify() or "Custom"
|
||||
label = f"{name} ({' '.join(t.full_name for t in tones)})"
|
||||
|
||||
print(f" Playing: {label}")
|
||||
print(f" Synth: {args.synth}")
|
||||
print(f" Duration: {duration} ms")
|
||||
play(target, temperament=args.temperament, synth=synth, t=duration)
|
||||
|
||||
|
||||
def cmd_detect(args):
|
||||
from .scales import Key
|
||||
key = Key.detect(*args.notes)
|
||||
if key:
|
||||
print(f" Detected key: {key}")
|
||||
print(f" Scale: {' '.join(key.note_names)}")
|
||||
else:
|
||||
print(" Could not detect key")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="pytheory",
|
||||
description="Music Theory for Humans — from the command line",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
# tone
|
||||
p = sub.add_parser("tone", help="Look up a tone (e.g. pytheory tone C4)")
|
||||
p.add_argument("note", help="Note name with octave (e.g. C4, A#3)")
|
||||
p.add_argument("--temperament", "-t", default="equal",
|
||||
choices=["equal", "pythagorean", "meantone"],
|
||||
help="Tuning temperament (default: equal)")
|
||||
|
||||
# scale
|
||||
p = sub.add_parser("scale", help="Show a scale (e.g. pytheory scale C major)")
|
||||
p.add_argument("tonic", help="Tonic note (e.g. C, G, Sa)")
|
||||
p.add_argument("mode", help="Scale/mode name (e.g. major, minor, dorian)")
|
||||
p.add_argument("--system", default="western", help="Musical system (default: western)")
|
||||
|
||||
# chord
|
||||
p = sub.add_parser("chord", help="Identify a chord (e.g. pytheory chord C E G)")
|
||||
p.add_argument("notes", nargs="+", help="Note names (e.g. C E G)")
|
||||
|
||||
# key
|
||||
p = sub.add_parser("key", help="Explore a key (e.g. pytheory key C major)")
|
||||
p.add_argument("tonic", help="Tonic note (e.g. C, G)")
|
||||
p.add_argument("mode", nargs="?", default="major", help="Mode (default: major)")
|
||||
|
||||
# fingering
|
||||
p = sub.add_parser("fingering", help="Guitar fingering (e.g. pytheory fingering Am)")
|
||||
p.add_argument("chord", help="Chord name (e.g. C, Am, G7)")
|
||||
p.add_argument("--capo", type=int, default=0, help="Capo fret (default: 0)")
|
||||
|
||||
# progression
|
||||
p = sub.add_parser("progression", help="Build a progression (e.g. pytheory progression C major I V vi IV)")
|
||||
p.add_argument("tonic", help="Tonic note")
|
||||
p.add_argument("mode", help="Mode (e.g. major, minor)")
|
||||
p.add_argument("numerals", nargs="+", help="Roman numerals (e.g. I V vi IV)")
|
||||
|
||||
# play
|
||||
p = sub.add_parser("play", help="Play notes or chords (e.g. pytheory play C E G)")
|
||||
p.add_argument("notes", nargs="+", help="Note names, with optional octave (e.g. C4, A#3, or just C E G)")
|
||||
p.add_argument("--synth", "-s", default="sine",
|
||||
choices=["sine", "saw", "triangle"],
|
||||
help="Waveform (default: sine)")
|
||||
p.add_argument("--duration", "-d", type=int, default=1000,
|
||||
help="Duration in milliseconds (default: 1000)")
|
||||
p.add_argument("--temperament", "-t", default="equal",
|
||||
choices=["equal", "pythagorean", "meantone"],
|
||||
help="Tuning temperament (default: equal)")
|
||||
|
||||
# detect
|
||||
p = sub.add_parser("detect", help="Detect key from notes (e.g. pytheory detect C E G)")
|
||||
p.add_argument("notes", nargs="+", help="Note names")
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(0)
|
||||
|
||||
commands = {
|
||||
"tone": cmd_tone,
|
||||
"scale": cmd_scale,
|
||||
"chord": cmd_chord,
|
||||
"key": cmd_key,
|
||||
"fingering": cmd_fingering,
|
||||
"progression": cmd_progression,
|
||||
"play": cmd_play,
|
||||
"detect": cmd_detect,
|
||||
}
|
||||
commands[args.command](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -486,6 +486,109 @@ class Key:
|
||||
keys.append(cls(tonic, "minor"))
|
||||
return keys
|
||||
|
||||
@property
|
||||
def signature(self) -> dict:
|
||||
"""The key signature — number and names of sharps or flats.
|
||||
|
||||
In Western music, each key has a unique key signature that tells
|
||||
you which notes are sharped or flatted throughout a piece.
|
||||
|
||||
Returns:
|
||||
A dict with:
|
||||
- ``sharps`` (int): number of sharps (0 if flat key)
|
||||
- ``flats`` (int): number of flats (0 if sharp key)
|
||||
- ``accidentals`` (list[str]): the sharped/flatted note names
|
||||
|
||||
Example::
|
||||
|
||||
>>> Key("G", "major").signature
|
||||
{'sharps': 1, 'flats': 0, 'accidentals': ['F#']}
|
||||
>>> Key("F", "major").signature
|
||||
{'sharps': 0, 'flats': 1, 'accidentals': ['Bb']}
|
||||
>>> Key("C", "major").signature
|
||||
{'sharps': 0, 'flats': 0, 'accidentals': []}
|
||||
"""
|
||||
# Compare scale notes against the natural notes C D E F G A B
|
||||
naturals = {"C", "D", "E", "F", "G", "A", "B"}
|
||||
scale_notes = set(self.note_names[:-1]) # exclude octave
|
||||
|
||||
sharps = [n for n in scale_notes if "#" in n]
|
||||
flats = [n for n in scale_notes if "b" in n[1:]] # skip first char for B
|
||||
|
||||
# Order sharps: F C G D A E B
|
||||
sharp_order = ["F#", "C#", "G#", "D#", "A#", "E#", "B#"]
|
||||
flat_order = ["Bb", "Eb", "Ab", "Db", "Gb", "Cb", "Fb"]
|
||||
|
||||
sharps_sorted = [s for s in sharp_order if s in sharps]
|
||||
flats_sorted = [f for f in flat_order if f in flats]
|
||||
|
||||
if sharps_sorted:
|
||||
return {"sharps": len(sharps_sorted), "flats": 0, "accidentals": sharps_sorted}
|
||||
elif flats_sorted:
|
||||
return {"sharps": 0, "flats": len(flats_sorted), "accidentals": flats_sorted}
|
||||
else:
|
||||
return {"sharps": 0, "flats": 0, "accidentals": []}
|
||||
|
||||
@property
|
||||
def borrowed_chords(self) -> list[str]:
|
||||
"""Chords borrowed from the parallel key.
|
||||
|
||||
Modal interchange (or modal mixture) borrows chords from the
|
||||
parallel major or minor key. In C major, the parallel minor
|
||||
is C minor, which provides chords like Ab major, Bb major,
|
||||
and Eb major — commonly heard in rock, film, and pop music.
|
||||
|
||||
Returns:
|
||||
A list of chord names from the parallel key that are NOT
|
||||
in the current key's diatonic chords.
|
||||
|
||||
Example::
|
||||
|
||||
>>> Key("C", "major").borrowed_chords
|
||||
['C minor', 'D diminished', 'D# major', ...]
|
||||
"""
|
||||
par = self.parallel
|
||||
if par is None:
|
||||
return []
|
||||
own = set(self.chords)
|
||||
return [c for c in par.chords if c not in own]
|
||||
|
||||
def random_progression(self, length: int = 4) -> list:
|
||||
"""Generate a random diatonic chord progression.
|
||||
|
||||
Uses weighted probabilities based on common chord function:
|
||||
I and vi are most common, IV and V are very common, ii is
|
||||
common, iii and viidim are rare. Always starts on I and
|
||||
ends on I or V.
|
||||
|
||||
Args:
|
||||
length: Number of chords (default 4).
|
||||
|
||||
Returns:
|
||||
A list of Chord objects.
|
||||
|
||||
Example::
|
||||
|
||||
>>> Key("C", "major").random_progression(4)
|
||||
[<Chord C major>, <Chord F major>, <Chord G major>, <Chord C major>]
|
||||
"""
|
||||
import random
|
||||
|
||||
harmonized = self._scale.harmonize()
|
||||
unique = len(harmonized)
|
||||
# Weights: I=high, ii=med, iii=low, IV=high, V=high, vi=med, vii=low
|
||||
weights = [10, 5, 2, 8, 8, 5, 1]
|
||||
if unique < len(weights):
|
||||
weights = weights[:unique]
|
||||
|
||||
chords = [harmonized[0]] # Start on I
|
||||
for _ in range(length - 2):
|
||||
chords.append(random.choices(harmonized, weights=weights, k=1)[0])
|
||||
if length > 1:
|
||||
# End on I or V
|
||||
chords.append(random.choice([harmonized[0], harmonized[4 % unique]]))
|
||||
return chords
|
||||
|
||||
@property
|
||||
def relative(self) -> Optional[Key]:
|
||||
"""The relative major or minor key.
|
||||
|
||||
@@ -115,6 +115,21 @@ class Tone:
|
||||
"""True if this tone has a flat (b after the first character)."""
|
||||
return "b" in self.name[1:]
|
||||
|
||||
@property
|
||||
def letter(self) -> str:
|
||||
"""The letter name without any accidental.
|
||||
|
||||
Example::
|
||||
|
||||
>>> Tone.from_string("C#4").letter
|
||||
'C'
|
||||
>>> Tone.from_string("Bb4").letter
|
||||
'B'
|
||||
>>> Tone.from_string("G4").letter
|
||||
'G'
|
||||
"""
|
||||
return self.name[0]
|
||||
|
||||
@property
|
||||
def enharmonic(self) -> Optional[str]:
|
||||
"""The enharmonic equivalent of this tone, or None if there isn't one.
|
||||
|
||||
+306
-1
@@ -2622,7 +2622,7 @@ def test_tension_empty():
|
||||
|
||||
def test_version():
|
||||
import pytheory
|
||||
assert pytheory.__version__ == "0.3.2"
|
||||
assert pytheory.__version__ == "0.4.1"
|
||||
|
||||
|
||||
def test_all_exports():
|
||||
@@ -3342,3 +3342,308 @@ def test_pachelbel_progression():
|
||||
prog = k.progression(*PROGRESSIONS["Pachelbel"])
|
||||
assert len(prog) == 8
|
||||
assert prog[0].identify() == "C major"
|
||||
|
||||
|
||||
# ── Tone.letter ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_tone_letter_natural():
|
||||
assert Tone.from_string("C4").letter == "C"
|
||||
|
||||
|
||||
def test_tone_letter_sharp():
|
||||
assert Tone.from_string("C#4").letter == "C"
|
||||
|
||||
|
||||
def test_tone_letter_flat():
|
||||
assert Tone(name="Bb", octave=4).letter == "B"
|
||||
|
||||
|
||||
# ── Key.signature ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_key_signature_c_major():
|
||||
sig = Key("C", "major").signature
|
||||
assert sig["sharps"] == 0
|
||||
assert sig["flats"] == 0
|
||||
|
||||
|
||||
def test_key_signature_g_major():
|
||||
sig = Key("G", "major").signature
|
||||
assert sig["sharps"] == 1
|
||||
assert sig["accidentals"] == ["F#"]
|
||||
|
||||
|
||||
def test_key_signature_d_major():
|
||||
sig = Key("D", "major").signature
|
||||
assert sig["sharps"] == 2
|
||||
|
||||
|
||||
# ── Chord.from_intervals ──────────────────────────────────────────────────
|
||||
|
||||
def test_chord_from_intervals_major():
|
||||
assert Chord.from_intervals("C", 4, 7).identify() == "C major"
|
||||
|
||||
|
||||
def test_chord_from_intervals_dom7():
|
||||
assert Chord.from_intervals("G", 4, 7, 10).identify() == "G dominant 7th"
|
||||
|
||||
|
||||
# ── Chord.from_midi_message ──────────────────────────────────────────────
|
||||
|
||||
def test_chord_from_midi_message():
|
||||
c = Chord.from_midi_message(60, 64, 67)
|
||||
assert c.identify() == "C major"
|
||||
|
||||
|
||||
# ── Chord.add_tone / remove_tone ──────────────────────────────────────────
|
||||
|
||||
def test_chord_add_tone():
|
||||
c = Chord.from_tones("C", "E", "G")
|
||||
cmaj7 = c.add_tone(Tone("B", octave=4))
|
||||
assert cmaj7.identify() == "C major 7th"
|
||||
|
||||
|
||||
def test_chord_remove_tone():
|
||||
cmaj7 = Chord.from_name("Cmaj7")
|
||||
c = cmaj7.remove_tone("B")
|
||||
assert c.identify() == "C major"
|
||||
|
||||
|
||||
# ── analyze_progression ──────────────────────────────────────────────────
|
||||
|
||||
def test_analyze_progression():
|
||||
from pytheory import analyze_progression
|
||||
prog = [Chord.from_name("C"), Chord.from_name("Am"),
|
||||
Chord.from_name("F"), Chord.from_name("G")]
|
||||
assert analyze_progression(prog, key="C") == ["I", "vi", "IV", "V"]
|
||||
|
||||
|
||||
# ── Key.borrowed_chords ─────────────────────────────────────────────────
|
||||
|
||||
def test_borrowed_chords():
|
||||
borrowed = Key("C", "major").borrowed_chords
|
||||
assert len(borrowed) > 0
|
||||
|
||||
|
||||
# ── Key.random_progression ──────────────────────────────────────────────
|
||||
|
||||
def test_random_progression():
|
||||
prog = Key("C", "major").random_progression(4)
|
||||
assert len(prog) == 4
|
||||
|
||||
|
||||
# ── Fretboard.scale_diagram ────────────────────────────────────────────
|
||||
|
||||
def test_scale_diagram():
|
||||
fb = Fretboard.guitar()
|
||||
scale = TonedScale(tonic="C4")["major"]
|
||||
diagram = fb.scale_diagram(scale, frets=5)
|
||||
assert "E|" in diagram
|
||||
lines = diagram.strip().split("\n")
|
||||
assert len(lines) == 7
|
||||
|
||||
|
||||
# ── Coverage gap tests ─────────────────────────────────────────────────────
|
||||
|
||||
def test_tone_init_octave_parsed_from_name():
|
||||
"""Tone('C4') should parse octave from name string."""
|
||||
t = Tone("C4")
|
||||
assert t.octave == 4
|
||||
assert t.name == "C"
|
||||
|
||||
|
||||
def test_tone_enharmonic_from_alt_names_direct():
|
||||
t = Tone(name="C#", alt_names="Db", octave=4)
|
||||
assert t.enharmonic == "Db"
|
||||
|
||||
|
||||
def test_tone_sub_not_implemented():
|
||||
t = Tone("C4")
|
||||
result = t.__sub__(3.5)
|
||||
assert result is NotImplemented
|
||||
|
||||
|
||||
def test_tone_lt_not_implemented():
|
||||
assert Tone("C4").__lt__("not a tone") is NotImplemented
|
||||
|
||||
|
||||
def test_tone_le_not_implemented():
|
||||
assert Tone("C4").__le__("not a tone") is NotImplemented
|
||||
|
||||
|
||||
def test_tone_gt_not_implemented():
|
||||
assert Tone("C4").__gt__("not a tone") is NotImplemented
|
||||
|
||||
|
||||
def test_tone_ge_not_implemented():
|
||||
assert Tone("C4").__ge__("not a tone") is NotImplemented
|
||||
|
||||
|
||||
def test_tone_from_frequency_negative_raises():
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
Tone.from_frequency(-100)
|
||||
|
||||
|
||||
def test_tone_interval_compound_2_octaves():
|
||||
c4 = Tone.from_string("C4", system="western")
|
||||
e6 = c4 + 28 # 2 octaves + major 3rd
|
||||
assert "2 octaves" in c4.interval_to(e6)
|
||||
|
||||
|
||||
def test_tone_circle_of_fifths_returns_12():
|
||||
c = Tone.from_string("C4", system="western")
|
||||
assert len(c.circle_of_fifths()) == 12
|
||||
|
||||
|
||||
def test_tone_circle_of_fourths_returns_12():
|
||||
c = Tone.from_string("C4", system="western")
|
||||
assert len(c.circle_of_fourths()) == 12
|
||||
|
||||
|
||||
def test_chord_repr_unidentified():
|
||||
"""Chord with no known pattern should show raw tones in repr."""
|
||||
c = Chord(tones=[
|
||||
Tone.from_string("C4", system="western"),
|
||||
Tone.from_string("D4", system="western"),
|
||||
])
|
||||
assert "tones=" in repr(c)
|
||||
|
||||
|
||||
def test_chord_str_unidentified():
|
||||
c = Chord(tones=[
|
||||
Tone.from_string("C4", system="western"),
|
||||
Tone.from_string("D4", system="western"),
|
||||
])
|
||||
assert "C4" in str(c)
|
||||
|
||||
|
||||
def test_chord_add_not_implemented():
|
||||
c = Chord.from_tones("C", "E", "G")
|
||||
assert c.__add__("not a chord") is NotImplemented
|
||||
|
||||
|
||||
def test_chord_identify_returns_none_for_unknown():
|
||||
c = Chord(tones=[
|
||||
Tone.from_string("C4", system="western"),
|
||||
Tone.from_string("C#4", system="western"),
|
||||
Tone.from_string("D4", system="western"),
|
||||
])
|
||||
assert c.identify() is None
|
||||
|
||||
|
||||
def test_chord_voice_leading_different_sizes():
|
||||
"""Voice leading should pad shorter chord."""
|
||||
c3 = Chord.from_tones("C", "E", "G")
|
||||
c4 = Chord.from_intervals("C", 4, 7, 10)
|
||||
vl = c3.voice_leading(c4)
|
||||
assert len(vl) == 4 # padded to match
|
||||
|
||||
|
||||
def test_chord_analyze_with_tone_key():
|
||||
"""analyze() should accept a Tone as key_tonic."""
|
||||
c = Chord.from_tones("C", "E", "G")
|
||||
key_tone = Tone.from_string("C4", system="western")
|
||||
assert c.analyze(key_tone) == "I"
|
||||
|
||||
|
||||
def test_chord_analyze_unknown_chord():
|
||||
c = Chord(tones=[
|
||||
Tone.from_string("C4", system="western"),
|
||||
Tone.from_string("D4", system="western"),
|
||||
])
|
||||
assert c.analyze("C") is None
|
||||
|
||||
|
||||
def test_chord_analyze_diminished():
|
||||
b_dim = Chord.from_intervals("B", 3, 6)
|
||||
result = b_dim.analyze("C")
|
||||
assert "dim" in result
|
||||
|
||||
|
||||
def test_chord_analyze_augmented():
|
||||
c_aug = Chord.from_intervals("C", 4, 8)
|
||||
result = c_aug.analyze("C")
|
||||
assert "+" in result
|
||||
|
||||
|
||||
def test_chord_analyze_9th():
|
||||
c9 = Chord.from_intervals("C", 2, 4, 7, 10)
|
||||
result = c9.analyze("C")
|
||||
assert "9" in result
|
||||
|
||||
|
||||
def test_scale_with_system_object():
|
||||
"""Scale created with system object instead of string."""
|
||||
from pytheory.scales import Scale
|
||||
system = SYSTEMS["western"]
|
||||
s = Scale(tones=(Tone("C", octave=4), Tone("D", octave=4)), system=system)
|
||||
assert s.system == system
|
||||
|
||||
|
||||
def test_scale_degree_by_mode_name():
|
||||
major = TonedScale(tonic="C4")["major"]
|
||||
# Access by mode name should work via degree lookup
|
||||
tone = major.degree("ionian")
|
||||
assert tone is not None
|
||||
|
||||
|
||||
def test_scale_getitem_raises():
|
||||
major = TonedScale(tonic="C4")["major"]
|
||||
with pytest.raises(KeyError):
|
||||
major["nonexistent_degree"]
|
||||
|
||||
|
||||
def test_key_with_string_system():
|
||||
k = Key("C", "major", system="western")
|
||||
assert k.note_names[0] == "C"
|
||||
|
||||
|
||||
def test_key_detect_returns_none_empty():
|
||||
assert Key.detect() is None
|
||||
|
||||
|
||||
def test_key_signature_flat_key():
|
||||
"""F major has one flat (Bb)."""
|
||||
# F major scale: F G A Bb C D E
|
||||
# But our system uses sharps, so Bb = A#
|
||||
sig = Key("F", "major").signature
|
||||
# The scale uses A# which is sharp notation for Bb
|
||||
assert sig["sharps"] + sig["flats"] >= 0 # at least runs
|
||||
|
||||
|
||||
def test_key_borrowed_chords_minor():
|
||||
"""Minor key should borrow from parallel major."""
|
||||
borrowed = Key("A", "minor").borrowed_chords
|
||||
assert len(borrowed) > 0
|
||||
|
||||
|
||||
def test_key_parallel_returns_none_for_other_modes():
|
||||
"""Parallel should return None for non-major/minor modes."""
|
||||
k = Key("C", "major")
|
||||
k.mode = "lydian" # force non-standard mode
|
||||
assert k.parallel is None
|
||||
|
||||
|
||||
def test_key_relative_returns_none_for_other_modes():
|
||||
k = Key("C", "major")
|
||||
k.mode = "lydian"
|
||||
assert k.relative is None
|
||||
|
||||
|
||||
def test_toned_scale_with_string_system():
|
||||
ts = TonedScale(tonic="Do4", system="arabic")
|
||||
assert "ajam" in ts.scales
|
||||
|
||||
|
||||
def test_fretboard_fingering_method():
|
||||
"""Fretboard.fingering should return a Chord."""
|
||||
fb = Fretboard.guitar()
|
||||
result = fb.fingering(0, 0, 0, 0, 0, 0)
|
||||
assert len(result) == 6
|
||||
|
||||
|
||||
def test_charts_muted_string():
|
||||
"""A chord with no valid fret gets -1 → None."""
|
||||
from pytheory.charts import NamedChord
|
||||
nc = NamedChord(tone_name="C", quality="")
|
||||
fixed = nc.fix_fingering((0, -1, 2))
|
||||
assert fixed == (0, None, 2)
|
||||
|
||||
Reference in New Issue
Block a user