Compare commits

..

12 Commits

Author SHA1 Message Date
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
kennethreitz da40189845 v0.4.0: key signatures, scale diagrams, chord building, progression analysis
New features:
- Key.signature — sharps/flats count and accidental names
- Key.borrowed_chords — modal interchange from parallel key
- Key.random_progression(n) — weighted random diatonic progressions
- Chord.from_intervals("C", 4, 7) — build from root + semitones
- Chord.from_midi_message(60, 64, 67) — build from MIDI note numbers
- Chord.add_tone(tone) / remove_tone("B") — modify chords immutably
- Tone.letter — "C" from "C#" (letter without accidental)
- Fretboard.scale_diagram(scale) — ASCII neck diagram
- analyze_progression([chords], key="C") → ["I", "vi", "IV", "V"]

443 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:48:04 -04:00
kennethreitz 54b82440c4 v0.3.2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:56:15 -04:00
kennethreitz ef003dbd1d Add type hints, docstrings, and property caching throughout
Type hints: all methods and properties across Tone, Scale, TonedScale,
Key, Chord, and Fretboard now have full type annotations using
from __future__ import annotations.

Docstrings: added to all methods that were missing them —
constructors, dunder methods, properties, classmethods.

Property caching:
- TonedScale._scales: computed once and cached (immutable after init)
- Chord.identify(): cached result, cleared on transpose/inversion
- Tone.frequency: cached after first computation

428 tests passing, no behavior changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:53:06 -04:00
kennethreitz 890c3cfbe2 v0.3.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:47:08 -04:00
kennethreitz 599a00f066 Add capo, chord merging, tritone sub, secondary dominants, more progressions
Fretboard:
- Fretboard.guitar(capo=2) — capo as constructor parameter
- fretboard.capo(fret) — apply capo to any instrument

Chord:
- chord1 + chord2 — merge/layer two chords
- chord.tritone_sub() — jazz tritone substitution (transpose by 6)

Key:
- key.secondary_dominant(5) → V/V (e.g. D7 in C major)
- Key.all_keys() → all 24 major and minor keys

Progressions (14 total, up from 8):
- Pachelbel (Canon in D)
- Andalusian cadence (flamenco)
- Rhythm changes A section
- Jazz turnaround (iii-vi-ii-V)
- Dorian vamp, Mixolydian vamp

Also: py.typed marker for type checkers. 428 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:44:41 -04:00
12 changed files with 2019 additions and 150 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ sys.modules["sounddevice"] = MagicMock()
project = "PyTheory"
copyright = "2026, Kenneth Reitz"
author = "Kenneth Reitz"
release = "0.3.0"
release = "0.4.1"
extensions = [
"sphinx.ext.autodoc",
+39 -1
View File
@@ -125,9 +125,47 @@ same note name:
>>> c5.pitch(temperament="pythagorean")
521.48 # Slightly different!
# Symbolic output (SymPy expression)
Symbolic Pitch
~~~~~~~~~~~~~~
Pass ``symbolic=True`` to get exact pitch ratios as
`SymPy <https://en.wikipedia.org/wiki/SymPy>`_ expressions instead of
floating-point approximations. This is useful for mathematical analysis,
proving tuning relationships, or comparing temperaments with exact
arithmetic.
.. code-block:: python
>>> a4 = Tone.from_string("A4", system="western")
# Equal temperament: irrational ratios (roots of 2)
>>> a4.pitch(symbolic=True)
440
>>> Tone.from_string("C5", system="western").pitch(symbolic=True)
440*2**(1/4)
# Pythagorean: pure rational ratios (powers of 3/2)
>>> Tone.from_string("G4", system="western").pitch(
... temperament="pythagorean", symbolic=True)
660
# Compare the major third across temperaments
>>> e4 = Tone.from_string("E4", system="western")
>>> e4.pitch(temperament="equal", symbolic=True)
440*2**(1/3)
>>> e4.pitch(temperament="pythagorean", symbolic=True)
12160/27
>>> e4.pitch(temperament="meantone", symbolic=True)
550
# Symbolic expressions can be evaluated to any precision
>>> e4.pitch(symbolic=True).evalf(50)
329.62755691286991583007431157433859631791591649985
The symbolic output reveals *why* temperaments differ: equal temperament
uses irrational numbers (roots of 2), Pythagorean uses powers of 3/2
(rational but accumulating error), and meantone tunes thirds to the
pure 5/4 ratio (sacrificing fifths).
Intervals and Arithmetic
-------------------------
+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
}
+4 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.3.0"
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"]
+3 -3
View File
@@ -1,11 +1,11 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.3.0"
__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",
]
+284 -57
View File
@@ -1,9 +1,20 @@
from __future__ import annotations
from typing import Iterator, Optional, Union
class Chord:
def __init__(self, tones):
def __init__(self, tones: list[Tone]) -> None:
"""Initialize a Chord from a list of Tone objects.
Args:
tones: A list of :class:`Tone` instances that make up the chord.
"""
self.tones = tones
self._identify_cache: Optional[str] = None
@classmethod
def from_tones(cls, *note_names, octave=4):
def from_tones(cls, *note_names: str, octave: int = 4) -> Chord:
"""Create a Chord from note name strings.
Example::
@@ -20,7 +31,7 @@ class Chord:
])
@classmethod
def from_name(cls, name, octave=4):
def from_name(cls, name: str, octave: int = 4) -> Chord:
"""Create a Chord from a chord name like ``"Cmaj7"`` or ``"Am"``.
Uses the built-in chord chart to find the correct tones,
@@ -49,31 +60,89 @@ class Chord:
f"{t.name}{octave}", system="western"))
return cls(tones=tones)
def __repr__(self):
@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:
return f"<Chord {name}>"
l = tuple([tone.full_name for tone in self.tones])
return f"<Chord tones={l!r}>"
def __str__(self):
def __str__(self) -> str:
name = self.identify()
if name:
return name
return " ".join(t.full_name for t in self.tones)
def __iter__(self):
def __iter__(self) -> Iterator[Tone]:
"""Iterate over the tones in this chord."""
return iter(self.tones)
def __len__(self):
def __len__(self) -> int:
"""Return the number of tones in this chord."""
return len(self.tones)
def __contains__(self, item):
def __contains__(self, item: Union[str, Tone]) -> bool:
"""Check if a tone (by name or Tone object) is in this chord."""
if isinstance(item, str):
return any(item == t.name for t in self.tones)
return item in self.tones
def inversion(self, n=1):
def __add__(self, other: Chord) -> Chord:
"""Merge two chords into one (layer their tones).
Example::
>>> c_major = Chord.from_tones("C", "E", "G")
>>> g_bass = Chord.from_tones("G", octave=2)
>>> slash = c_major + g_bass # C/G
"""
if isinstance(other, Chord):
return Chord(tones=list(self.tones) + list(other.tones))
return NotImplemented
def tritone_sub(self) -> Chord:
"""Return the tritone substitution of this chord.
In jazz harmony, any dominant chord can be replaced by the
dominant chord a tritone (6 semitones) away. G7 → Db7,
C7 → F#7. This works because the two chords share the same
tritone interval (the 3rd and 7th swap roles).
Returns a new Chord transposed by 6 semitones.
"""
return self.transpose(6)
def inversion(self, n: int = 1) -> Chord:
"""Return the nth inversion of this chord.
An inversion moves the lowest tone(s) up by one octave:
@@ -96,9 +165,11 @@ class Chord:
break
tone = tones.pop(0)
tones.append(tone.add(12))
return Chord(tones=tones)
result = Chord(tones=tones)
result._identify_cache = None
return result
def transpose(self, semitones):
def transpose(self, semitones: int) -> Chord:
"""Return a new Chord transposed by the given number of semitones.
Every tone in the chord is shifted up (positive) or down
@@ -111,10 +182,12 @@ class Chord:
>>> c_major.transpose(7).identify()
'G major'
"""
return Chord(tones=[t.add(semitones) for t in self.tones])
result = Chord(tones=[t.add(semitones) for t in self.tones])
result._identify_cache = None
return result
@property
def root(self):
def root(self) -> Optional[Tone]:
"""The root of this chord (if identifiable).
Returns the Tone that serves as the root based on chord
@@ -130,7 +203,7 @@ class Chord:
return None
@property
def quality(self):
def quality(self) -> Optional[str]:
"""The quality of this chord (e.g. 'major', 'minor 7th').
Returns the quality string from chord identification, or
@@ -143,7 +216,7 @@ class Chord:
return parts[1] if len(parts) > 1 else None
@property
def intervals(self):
def intervals(self) -> list[int]:
"""Semitone distances between adjacent tones in the chord.
Returns a list of integers, where each value is the absolute
@@ -176,7 +249,7 @@ class Chord:
for i in range(1, len(self.tones))]
@property
def harmony(self):
def harmony(self) -> float:
"""Consonance score based on frequency ratio simplicity.
Computed by examining the frequency ratio between every pair of
@@ -218,7 +291,7 @@ class Chord:
return score
@property
def dissonance(self):
def dissonance(self) -> float:
"""Sensory dissonance score using the Plomp-Levelt roughness model.
When two tones are close in frequency, their waveforms interfere
@@ -271,7 +344,7 @@ class Chord:
return roughness
@property
def beat_frequencies(self):
def beat_frequencies(self) -> list[tuple[Tone, Tone, float]]:
"""Beat frequencies (Hz) between all pairs of tones in the chord.
When two tones with frequencies f1 and f2 are played together,
@@ -312,7 +385,7 @@ class Chord:
return sorted(beats, key=lambda b: b[2])
@property
def beat_pulse(self):
def beat_pulse(self) -> float:
"""The slowest (most perceptible) beat frequency in the chord, in Hz.
This is the beat frequency between the two tones closest in
@@ -354,7 +427,7 @@ class Chord:
"minor 9th": {0, 2, 3, 7, 10},
}
def identify(self):
def identify(self) -> Optional[str]:
"""Identify this chord by name (root + quality).
Tries each tone as a potential root and checks if the remaining
@@ -375,6 +448,9 @@ class Chord:
>>> Chord([A4, C5, E5]).identify()
'A minor'
"""
if self._identify_cache is not None:
return self._identify_cache
if len(self.tones) < 2:
return None
@@ -388,10 +464,11 @@ class Chord:
for name, pattern in self._CHORD_PATTERNS.items():
if pitch_classes == pattern:
return f"{root.name} {name}"
self._identify_cache = f"{root.name} {name}"
return self._identify_cache
return None
def voice_leading(self, other):
def voice_leading(self, other: Chord) -> list[tuple[Tone, Tone, int]]:
"""Find the smoothest voice leading to another chord.
Voice leading is the art of moving individual voices (tones)
@@ -446,7 +523,7 @@ class Chord:
result.append((src[i], dst[j], movement))
return sorted(result, key=lambda v: v[0].pitch(), reverse=True)
def analyze(self, key_tonic, mode="major"):
def analyze(self, key_tonic: Union[str, Tone], mode: str = "major") -> Optional[str]:
"""Roman numeral analysis of this chord relative to a key.
In tonal music, every chord has a **function** determined by
@@ -516,7 +593,7 @@ class Chord:
return numeral_str + suffix
@property
def tension(self):
def tension(self) -> dict:
"""Harmonic tension score and resolution suggestions.
Tension in tonal music arises from specific intervallic
@@ -578,7 +655,47 @@ class Chord:
"has_dominant_function": has_dominant,
}
def fingering(self, *positions):
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.
Each position value is added (in semitones) to the corresponding
tone. The number of positions must match the number of tones.
Args:
*positions: One integer per tone indicating the fret offset.
Returns:
A new :class:`Chord` with each tone shifted by its position.
Raises:
ValueError: If the number of positions doesn't match the
number of tones.
"""
if not len(positions) == len(self.tones):
raise ValueError(
"The number of positions must match the number of tones (strings)."
@@ -592,17 +709,53 @@ class Chord:
class Fretboard:
def __init__(self, *, tones):
def __init__(self, *, tones: list[Tone]) -> None:
"""Initialize a Fretboard from a list of open-string Tone objects.
Args:
tones: A list of :class:`Tone` instances representing the
open strings (high to low).
"""
self.tones = tones
def __repr__(self):
def __repr__(self) -> str:
l = tuple([tone.full_name for tone in self.tones])
return f"<Fretboard tones={l!r}>"
def __iter__(self):
def capo(self, fret: int) -> Fretboard:
"""Return a new Fretboard with a capo at the given fret.
A `capo <https://en.wikipedia.org/wiki/Capo>`_ clamps across
all strings at a fret, raising every string's pitch by that
many semitones. This lets you play open chord shapes in
higher keys.
Common uses:
- Capo 2 + G shapes = A major voicings
- Capo 4 + C shapes = E major voicings
- Capo 7 + D shapes = A major voicings (bright, high register)
Example::
>>> fb = Fretboard.guitar(capo=2)
>>> # Open strings are now F#4 C#4 A3 E3 B2 F#2
>>> # Playing a "G shape" sounds as A major
Args:
fret: The fret number to place the capo (1-12).
Returns:
A new Fretboard with all strings raised by ``fret`` semitones.
"""
return Fretboard(tones=[t.add(fret) for t in self.tones])
def __iter__(self) -> Iterator[Tone]:
"""Iterate over the open-string tones of this fretboard."""
return iter(self.tones)
def __len__(self):
def __len__(self) -> int:
"""Return the number of strings on this fretboard."""
return len(self.tones)
INSTRUMENTS = [
@@ -627,21 +780,26 @@ class Fretboard:
}
@classmethod
def guitar(cls, tuning="standard"):
"""Guitar with the given tuning.
def guitar(cls, tuning: Union[str, tuple[str, ...]] = "standard", capo: int = 0) -> Fretboard:
"""Guitar with the given tuning and optional capo.
Args:
tuning: Tuning name or tuple of tone strings (high to low).
Built-in tunings: standard, drop d, open g, open d,
open e, open a, dadgad, half step down.
capo: Fret number for the capo (0 = no capo). Raises all
strings by this many semitones.
"""
from .tones import Tone
if isinstance(tuning, str):
tuning = cls.TUNINGS[tuning]
return cls(tones=[Tone.from_string(t, system="western") for t in tuning])
fb = cls(tones=[Tone.from_string(t, system="western") for t in tuning])
if capo:
fb = fb.capo(capo)
return fb
@classmethod
def bass(cls, five_string=False):
def bass(cls, five_string: bool = False) -> Fretboard:
"""Standard bass guitar tuning.
Args:
@@ -654,7 +812,7 @@ class Fretboard:
return cls(tones=[Tone.from_string(t, system="western") for t in strings])
@classmethod
def ukulele(cls):
def ukulele(cls) -> Fretboard:
"""Standard ukulele tuning (A4 E4 C4 G4).
Re-entrant tuning: the G4 string is higher than C4.
@@ -668,7 +826,7 @@ class Fretboard:
])
@classmethod
def mandolin(cls):
def mandolin(cls) -> Fretboard:
"""Standard mandolin tuning (E5 A4 D4 G3).
Tuned in fifths, same as a violin but one octave relationship.
@@ -683,7 +841,7 @@ class Fretboard:
])
@classmethod
def mandola(cls):
def mandola(cls) -> Fretboard:
"""Standard mandola tuning (A4 D4 G3 C3).
The mandola (or tenor mandola) is to the mandolin what the
@@ -699,7 +857,7 @@ class Fretboard:
])
@classmethod
def octave_mandolin(cls):
def octave_mandolin(cls) -> Fretboard:
"""Octave mandolin tuning (E4 A3 D3 G2).
Also called the octave mandola in European terminology.
@@ -716,7 +874,7 @@ class Fretboard:
])
@classmethod
def mandocello(cls):
def mandocello(cls) -> Fretboard:
"""Mandocello tuning (A3 D3 G2 C2).
The bass of the mandolin family. Tuned like a cello — an
@@ -732,7 +890,7 @@ class Fretboard:
])
@classmethod
def violin(cls):
def violin(cls) -> Fretboard:
"""Standard violin tuning (E5 A4 D4 G3).
Tuned in perfect fifths. The violin has no frets — intonation
@@ -748,7 +906,7 @@ class Fretboard:
])
@classmethod
def viola(cls):
def viola(cls) -> Fretboard:
"""Standard viola tuning (A4 D4 G3 C3).
A perfect fifth below the violin. The viola's darker, warmer
@@ -763,7 +921,7 @@ class Fretboard:
])
@classmethod
def cello(cls):
def cello(cls) -> Fretboard:
"""Standard cello tuning (A3 D3 G2 C2).
An octave below the viola. Tuned in fifths. The cello spans
@@ -778,7 +936,7 @@ class Fretboard:
])
@classmethod
def banjo(cls, tuning="open g"):
def banjo(cls, tuning: Union[str, tuple[str, ...]] = "open g") -> Fretboard:
"""Banjo with the given tuning.
Args:
@@ -800,7 +958,7 @@ class Fretboard:
return cls(tones=[Tone.from_string(t, system="western") for t in tuning])
@classmethod
def double_bass(cls):
def double_bass(cls) -> Fretboard:
"""Standard double bass (upright bass) tuning (G2 D2 A1 E1).
The largest and lowest-pitched bowed string instrument in the
@@ -819,7 +977,7 @@ class Fretboard:
])
@classmethod
def harp(cls):
def harp(cls) -> Fretboard:
"""Concert harp strings — 47 strings spanning C1 to G7.
The pedal harp has 7 strings per octave (one per note name),
@@ -847,7 +1005,7 @@ class Fretboard:
return cls(tones=[Tone.from_string(s, system="western") for s in strings])
@classmethod
def pedal_steel(cls):
def pedal_steel(cls) -> Fretboard:
"""Pedal steel guitar — E9 Nashville tuning (10 strings).
The standard tuning for country music. The pedal steel has
@@ -861,7 +1019,7 @@ class Fretboard:
return cls(tones=[Tone.from_string(s, system="western") for s in strings])
@classmethod
def bouzouki(cls, variant="irish"):
def bouzouki(cls, variant: Union[str, tuple[str, ...]] = "irish") -> Fretboard:
"""Bouzouki tuning.
Args:
@@ -881,7 +1039,7 @@ class Fretboard:
return cls(tones=[Tone.from_string(t, system="western") for t in variant])
@classmethod
def oud(cls):
def oud(cls) -> Fretboard:
"""Standard Arabic oud tuning (C4 G3 D3 A2 G2 C2).
The oud is the ancestor of the European lute and the defining
@@ -895,7 +1053,7 @@ class Fretboard:
return cls(tones=[Tone.from_string(t, system="western") for t in strings])
@classmethod
def sitar(cls):
def sitar(cls) -> Fretboard:
"""Sitar main playing strings (approximation).
The sitar typically has 6-7 main strings and 11-13 sympathetic
@@ -912,7 +1070,7 @@ class Fretboard:
return cls(tones=[Tone.from_string(t, system="western") for t in strings])
@classmethod
def shamisen(cls):
def shamisen(cls) -> Fretboard:
"""Standard shamisen tuning — honchoshi (C4 G3 C3).
The shamisen is a 3-stringed Japanese instrument played with
@@ -930,7 +1088,7 @@ class Fretboard:
])
@classmethod
def erhu(cls):
def erhu(cls) -> Fretboard:
"""Standard erhu tuning (A4 D4).
The erhu is a 2-stringed Chinese bowed instrument with a
@@ -945,7 +1103,7 @@ class Fretboard:
])
@classmethod
def charango(cls):
def charango(cls) -> Fretboard:
"""Standard charango tuning (E5 A4 E5 C5 G4).
A small Andean stringed instrument, traditionally made from
@@ -963,7 +1121,7 @@ class Fretboard:
])
@classmethod
def pipa(cls):
def pipa(cls) -> Fretboard:
"""Standard pipa tuning (D4 A3 E3 A2).
The pipa is a 4-stringed Chinese lute with a pear-shaped
@@ -979,7 +1137,7 @@ class Fretboard:
])
@classmethod
def balalaika(cls):
def balalaika(cls) -> Fretboard:
"""Standard balalaika prima tuning (A4 E4 E4).
The Russian balalaika has a distinctive triangular body and
@@ -994,7 +1152,7 @@ class Fretboard:
])
@classmethod
def keyboard(cls, keys=88, start="A0"):
def keyboard(cls, keys: int = 88, start: str = "A0") -> Fretboard:
"""Piano or keyboard with the given number of keys.
Args:
@@ -1020,7 +1178,7 @@ class Fretboard:
return cls(tones=tones)
@classmethod
def lute(cls):
def lute(cls) -> Fretboard:
"""Renaissance lute in G tuning (6 courses).
The European lute was the dominant instrument of the
@@ -1033,7 +1191,7 @@ class Fretboard:
return cls(tones=[Tone.from_string(t, system="western") for t in strings])
@classmethod
def twelve_string(cls):
def twelve_string(cls) -> Fretboard:
"""12-string guitar in standard tuning.
The lower 4 courses are doubled at the octave; the upper 2
@@ -1053,7 +1211,64 @@ class Fretboard:
]
return cls(tones=[Tone.from_string(t, system="western") for t in strings])
def fingering(self, *positions):
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.
Each position value is added (in semitones) to the corresponding
open-string tone. The number of positions must match the number
of strings.
Args:
*positions: One integer per string indicating the fret number.
Returns:
A :class:`Chord` with each tone shifted by its fret position.
Raises:
ValueError: If the number of positions doesn't match the
number of strings.
"""
if not len(positions) == len(self.tones):
raise ValueError(
"The number of positions must match the number of tones (strings)."
@@ -1064,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
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()
View File
+253 -41
View File
@@ -1,11 +1,25 @@
from __future__ import annotations
from typing import Optional, Union
import numeral
from .systems import SYSTEMS
from .systems import SYSTEMS, System
from .tones import Tone
class Scale:
def __init__(self, *, tones, degrees=None, system='western'):
def __init__(self, *, tones: tuple[Tone, ...], degrees: Optional[tuple[str, ...]] = None, system: Union[str, System] = 'western') -> None:
"""Initialize a Scale from a sequence of Tones.
Args:
tones: The tones that make up the scale.
degrees: Optional names for each scale degree (must match length of *tones*).
system: A tone system name or :class:`System` instance.
Raises:
ValueError: If *degrees* is provided but its length differs from *tones*.
"""
self.tones = tones
self.degrees = degrees
@@ -21,14 +35,18 @@ class Scale:
raise ValueError("The number of tones and degrees must be equal!")
@property
def system(self):
def system(self) -> Optional[System]:
"""Return the tone system for this scale.
Resolves a system name to a :class:`System` object on first access.
"""
if self._system:
return self._system
if self.system_name:
return SYSTEMS[self.system_name]
def __repr__(self):
def __repr__(self) -> str:
r = []
for (i, tone) in enumerate(self.tones):
degree = numeral.int2roman(i + 1, only_ascii=True)
@@ -38,22 +56,25 @@ class Scale:
return f"<Scale {r}>"
def __iter__(self):
"""Iterate over the tones in this scale."""
return iter(self.tones)
def __len__(self):
def __len__(self) -> int:
"""Return the number of tones in this scale (including the octave)."""
return len(self.tones)
def __contains__(self, item):
def __contains__(self, item: Union[str, Tone]) -> bool:
"""Check whether a tone or note name belongs to this scale."""
if isinstance(item, str):
return any(item == t.name for t in self.tones)
return item in self.tones
@property
def note_names(self):
def note_names(self) -> list[str]:
"""List of note names in this scale."""
return [t.name for t in self.tones]
def chord(self, *degrees):
def chord(self, *degrees: int) -> Chord:
"""Build a Chord from scale degrees (0-indexed).
Wraps around if degrees exceed the scale length, transposing
@@ -75,7 +96,7 @@ class Scale:
result.append(tone)
return Chord(tones=result)
def transpose(self, semitones):
def transpose(self, semitones: int) -> Scale:
"""Return a new Scale transposed by the given number of semitones.
Every tone is shifted by the same interval, preserving the
@@ -92,21 +113,21 @@ class Scale:
new_tones = tuple(t.add(semitones) for t in self.tones)
return Scale(tones=new_tones)
def triad(self, root=0):
def triad(self, root: int = 0) -> Chord:
"""Build a triad starting from the given scale degree (0-indexed).
Returns a chord with the root, 3rd, and 5th above it.
"""
return self.chord(root, root + 2, root + 4)
def seventh(self, root=0):
def seventh(self, root: int = 0) -> Chord:
"""Build a seventh chord from the given scale degree (0-indexed).
Returns a chord with the root, 3rd, 5th, and 7th.
"""
return self.chord(root, root + 2, root + 4, root + 6)
def progression(self, *numerals):
def progression(self, *numerals: str) -> list[Chord]:
"""Build a chord progression from Roman numeral strings.
Accepts Roman numerals like ``"I"``, ``"IV"``, ``"V"``,
@@ -130,7 +151,7 @@ class Scale:
chords.append(self.triad(degree))
return chords
def nashville(self, *numbers):
def nashville(self, *numbers: Union[int, str]) -> list[Chord]:
"""Build a chord progression using Nashville number system.
The `Nashville number system <https://en.wikipedia.org/wiki/Nashville_Number_System>`_
@@ -159,7 +180,7 @@ class Scale:
return chords
@staticmethod
def detect(*note_names):
def detect(*note_names: str) -> Optional[tuple[str, str, int]]:
"""Detect the most likely scale from a set of note names.
Tries all scales in the Western system and returns the best
@@ -200,7 +221,7 @@ class Scale:
return (best[1], best[2], best[3])
return None
def harmonize(self):
def harmonize(self) -> list[Chord]:
"""Build diatonic triads on every scale degree.
Returns a list of Chords — one triad for each degree of the
@@ -214,7 +235,7 @@ class Scale:
unique = len(self.tones) - 1
return [self.triad(i) for i in range(unique)]
def degree(self, item, major=None, minor=False):
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.
@@ -247,7 +268,12 @@ class Scale:
if isinstance(item, int) or isinstance(item, slice):
return self.tones[item]
def __getitem__(self, item):
def __getitem__(self, item: Union[str, int, slice]) -> Union[Tone, tuple[Tone, ...]]:
"""Retrieve a tone by scale degree (integer, Roman numeral, or degree name).
Raises:
KeyError: If the given degree is not found in this scale.
"""
result = self.degree(item)
if result is None:
raise KeyError(item)
@@ -255,14 +281,26 @@ class Scale:
PROGRESSIONS = {
# Rock / Pop / Folk
"I-IV-V-I": ("I", "IV", "V", "I"),
"I-V-vi-IV": ("I", "V", "vi", "IV"),
"ii-V-I": ("ii", "V7", "I"),
"I-vi-IV-V": ("I", "vi", "IV", "V"),
"12-bar blues": ("I", "I", "I", "I", "IV", "IV", "I", "I", "V", "IV", "I", "V"),
"i-bVI-bIII-bVII": ("i", "VI", "III", "VII"),
"vi-IV-I-V": ("vi", "IV", "I", "V"),
"I-IV-vi-V": ("I", "IV", "vi", "V"),
"vi-IV-I-V": ("vi", "IV", "I", "V"),
# Blues
"12-bar blues": ("I", "I", "I", "I", "IV", "IV", "I", "I", "V", "IV", "I", "V"),
# Jazz
"ii-V-I": ("ii", "V7", "I"),
"I-vi-ii-V": ("I", "vi", "ii", "V"), # rhythm changes A section
"iii-vi-ii-V": ("iii", "vi", "ii", "V"), # jazz turnaround
# Classical / Film
"i-bVI-bIII-bVII": ("i", "VI", "III", "VII"),
"Pachelbel": ("I", "V", "vi", "iii", "IV", "I", "IV", "V"),
# Flamenco / Spanish
"Andalusian": ("i", "VII", "VI", "V"),
# Modal
"Dorian vamp": ("i", "IV"),
"Mixolydian vamp": ("I", "VII"),
}
"""Common chord progressions as Roman numeral tuples.
@@ -289,7 +327,7 @@ class Key:
[<Chord (C,E,G)>, <Chord (G,B,D)>, ...]
"""
def __init__(self, tonic, mode="major", system=None):
def __init__(self, tonic: str, mode: str = "major", system: Optional[Union[str, System]] = None) -> None:
if system is None:
system = SYSTEMS["western"]
elif isinstance(system, str):
@@ -301,7 +339,7 @@ class Key:
self._scale = self._toned_scale[mode]
@classmethod
def detect(cls, *note_names):
def detect(cls, *note_names: str) -> Optional[Key]:
"""Detect the most likely key from a set of note names.
Tries every possible major and minor key and returns the one
@@ -343,42 +381,42 @@ class Key:
return best_key
def __repr__(self):
def __repr__(self) -> str:
return f"<Key {self.tonic_name} {self.mode}>"
def __str__(self):
def __str__(self) -> str:
return f"{self.tonic_name} {self.mode}"
@property
def scale(self):
def scale(self) -> Scale:
"""The scale for this key."""
return self._scale
@property
def note_names(self):
def note_names(self) -> list[str]:
"""Note names in this key's scale."""
return self._scale.note_names
@property
def chords(self):
def chords(self) -> list[str]:
"""Names of all diatonic triads in this key."""
return [c.identify() for c in self._scale.harmonize()]
@property
def seventh_chords(self):
def seventh_chords(self) -> list[str]:
"""Names of all diatonic seventh chords in this key."""
unique = len(self._scale.tones) - 1
return [self._scale.seventh(i).identify() for i in range(unique)]
def triad(self, degree):
def triad(self, degree: int) -> Chord:
"""Build a diatonic triad on the given degree (0-indexed)."""
return self._scale.triad(degree)
def seventh(self, degree):
def seventh(self, degree: int) -> Chord:
"""Build a diatonic seventh chord on the given degree (0-indexed)."""
return self._scale.seventh(degree)
def progression(self, *numerals):
def progression(self, *numerals: str) -> list[Chord]:
"""Build a chord progression from Roman numerals.
Example::
@@ -387,7 +425,7 @@ class Key:
"""
return self._scale.progression(*numerals)
def nashville(self, *numbers):
def nashville(self, *numbers: Union[int, str]) -> list[Chord]:
"""Build a chord progression using Nashville numbers.
Example::
@@ -396,8 +434,163 @@ class Key:
"""
return self._scale.nashville(*numbers)
def secondary_dominant(self, degree: int) -> Chord:
"""Build a secondary dominant (V/x) for the given scale degree.
A secondary dominant is the dominant chord of a non-tonic
degree. For example, in C major, V/V is D major (the V chord
of G). Secondary dominants create momentary tonicizations
that add color and forward motion.
Common secondary dominants:
- V/V (e.g. D7 in C major) — approaches the dominant
- V/ii (e.g. A7 in C major) — approaches the supertonic
- V/vi (e.g. E7 in C major) — approaches the relative minor
Args:
degree: Scale degree to target (1-indexed). ``5`` means
"build the V of the 5th degree."
Returns:
A dominant 7th Chord that resolves to the given degree.
Example::
>>> Key("C", "major").secondary_dominant(5) # V/V = D7
<Chord D dominant 7th>
"""
target = self._scale.tones[degree - 1]
# Build a dominant 7th a perfect 5th above the target
from .chords import Chord
root = target.add(7)
return Chord(tones=[root, root.add(4), root.add(7), root.add(10)])
@classmethod
def all_keys(cls) -> list[Key]:
"""Return all 24 major and minor keys.
Returns:
A list of Key objects for all 12 major and 12 minor keys.
Example::
>>> for k in Key.all_keys():
... print(k)
"""
chromatic = ["C", "C#", "D", "D#", "E", "F",
"F#", "G", "G#", "A", "A#", "B"]
keys = []
for tonic in chromatic:
keys.append(cls(tonic, "major"))
keys.append(cls(tonic, "minor"))
return keys
@property
def relative(self):
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.
If this is a major key, returns the relative minor (vi).
@@ -414,7 +607,7 @@ class Key:
return None
@property
def parallel(self):
def parallel(self) -> Optional[Key]:
"""The parallel major or minor key (same tonic, different mode)."""
if self.mode == "major":
return Key(self.tonic_name, "minor")
@@ -424,7 +617,13 @@ class Key:
class TonedScale:
def __init__(self, *, system=SYSTEMS["western"], tonic):
def __init__(self, *, system: Union[str, System] = SYSTEMS["western"], tonic: Union[str, Tone]) -> None:
"""Initialize a TonedScale with a tonic note and tone system.
Args:
system: A tone system name or :class:`System` instance.
tonic: The tonic note as a string (e.g. ``"C4"``) or :class:`Tone`.
"""
if isinstance(system, str):
system = SYSTEMS[system]
self.system = system
@@ -433,28 +632,40 @@ class TonedScale:
tonic = Tone.from_string(tonic, system=self.system)
self.tonic = tonic
self._cached_scales: Optional[dict[str, Scale]] = None
def __repr__(self):
def __repr__(self) -> str:
return f"<TonedScale system={self.system!r} tonic={self.tonic}>"
def __getitem__(self, scale):
def __getitem__(self, scale: str) -> Scale:
"""Retrieve a scale by name.
Raises:
KeyError: If the named scale is not found in this system.
"""
result = self.get(scale)
if result is None:
raise KeyError(scale)
return result
def get(self, scale):
def get(self, scale: str) -> Optional[Scale]:
"""Look up a scale by name, returning ``None`` if not found."""
try:
return self._scales[scale]
except KeyError:
pass
@property
def scales(self):
def scales(self) -> tuple[str, ...]:
"""Tuple of all available scale names in this system."""
return tuple(self._scales.keys())
@property
def _scales(self):
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
scales = {}
for scale_type in self.system.scales:
@@ -472,4 +683,5 @@ class TonedScale:
scales[scale] = Scale(tones=tuple(working_scale))
self._cached_scales = scales
return scales
+142 -44
View File
@@ -1,3 +1,7 @@
from __future__ import annotations
from typing import Optional, Union
from ._statics import REFERENCE_A, TEMPERAMENTS
@@ -20,7 +24,24 @@ class Interval:
class Tone:
def __init__(self, name, *, alt_names=None, octave=None, system="western"):
def __init__(
self,
name: str,
*,
alt_names: Optional[list[str]] = None,
octave: Optional[int] = None,
system: Union[str, object] = "western",
) -> None:
"""Initialize a Tone with a name, optional octave, and musical system.
Args:
name: The note name (e.g. ``"C"``, ``"C#4"``). If the name
contains a digit, it is parsed as the octave.
alt_names: Alternate spellings for this tone (e.g. enharmonics).
octave: The octave number. Overrides any octave parsed from *name*.
system: The tuning system, either as a string key (``"western"``)
or a ``ToneSystem`` instance.
"""
if alt_names is None:
alt_names = []
@@ -38,6 +59,7 @@ class Tone:
self.name = name
self.octave = octave
self.alt_names = alt_names
self._frequency: Optional[float] = None
if isinstance(system, str):
self.system_name = system
@@ -47,11 +69,16 @@ class Tone:
self._system = system
@property
def exists(self):
def exists(self) -> bool:
"""True if this tone's name is found in the associated system."""
return self.name in self.system.tones
@property
def system(self):
def system(self) -> object:
"""The ``ToneSystem`` associated with this tone.
Lazily resolved from ``system_name`` on first access and cached.
"""
from .systems import SYSTEMS
if self._system:
@@ -62,32 +89,49 @@ class Tone:
return self.system
@property
def full_name(self):
def full_name(self) -> str:
"""The tone name with octave appended, e.g. ``'C4'`` or ``'C'``."""
if self.octave is not None:
return f"{self.name}{self.octave}"
else:
return self.name
def names(self):
def names(self) -> list[str]:
"""Return a list containing the primary name and all alternate names."""
return [self.name] + self.alt_names
@property
def is_natural(self):
def is_natural(self) -> bool:
"""True if this is a natural note (no sharp or flat)."""
return not self.is_sharp and not self.is_flat
@property
def is_sharp(self):
def is_sharp(self) -> bool:
"""True if this tone has a sharp (#)."""
return "#" in self.name
@property
def is_flat(self):
def is_flat(self) -> bool:
"""True if this tone has a flat (b after the first character)."""
return "b" in self.name[1:]
@property
def enharmonic(self):
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.
Returns the alternate spelling: C# → Db, Db → C#, etc.
@@ -109,16 +153,16 @@ class Tone:
pass
return None
def __repr__(self):
def __repr__(self) -> str:
return f"<Tone {self.full_name}>"
def __str__(self):
def __str__(self) -> str:
return self.full_name
def __add__(self, interval):
def __add__(self, interval: int) -> Tone:
return self.add(interval)
def __sub__(self, other):
def __sub__(self, other: Union[int, Tone]) -> Union[Tone, int]:
# Tone - int: subtract semitones
if isinstance(other, int):
return self.subtract(other)
@@ -134,27 +178,27 @@ class Tone:
return self_from_c0 - other_from_c0
return NotImplemented
def __lt__(self, other):
def __lt__(self, other: Tone) -> bool:
if not isinstance(other, Tone):
return NotImplemented
return self.pitch() < other.pitch()
def __le__(self, other):
def __le__(self, other: Tone) -> bool:
if not isinstance(other, Tone):
return NotImplemented
return self.pitch() <= other.pitch()
def __gt__(self, other):
def __gt__(self, other: Tone) -> bool:
if not isinstance(other, Tone):
return NotImplemented
return self.pitch() > other.pitch()
def __ge__(self, other):
def __ge__(self, other: Tone) -> bool:
if not isinstance(other, Tone):
return NotImplemented
return self.pitch() >= other.pitch()
def __eq__(self, other):
def __eq__(self, other: object) -> bool:
# Comparing string literals.
if isinstance(other, str):
@@ -169,11 +213,20 @@ class Tone:
return False
def __hash__(self):
def __hash__(self) -> int:
return hash((self.name, self.octave))
@classmethod
def from_string(klass, s, system=None):
def from_string(klass, s: str, system: Optional[Union[str, object]] = None) -> Tone:
"""Create a Tone by parsing a string like ``'C#4'`` or ``'Bb'``.
Args:
s: A note string, optionally including an octave number.
system: The tuning system to associate with the tone.
Returns:
A new ``Tone`` instance.
"""
try:
octave = int("".join([c for c in filter(str.isdigit, s)]))
except ValueError:
@@ -187,7 +240,16 @@ class Tone:
return klass(name=tone, octave=octave)
@classmethod
def from_tuple(klass, t):
def from_tuple(klass, t: tuple[str, ...]) -> Tone:
"""Create a Tone from a tuple of ``(name, *alt_names)``.
Args:
t: A tuple where the first element is the primary name and
any remaining elements are alternate names (enharmonics).
Returns:
A new ``Tone`` instance.
"""
if len(t) == 1:
return klass.from_string(s=t[0])
else:
@@ -196,7 +258,7 @@ class Tone:
return tone
@classmethod
def from_frequency(klass, hz, system="western"):
def from_frequency(klass, hz: float, system: Union[str, object] = "western") -> Tone:
"""Create a Tone from a frequency in Hz.
Finds the nearest note in 12-TET tuning (A4=440Hz).
@@ -228,7 +290,7 @@ class Tone:
return klass.from_index(index, octave=octave, system=system)
@classmethod
def from_midi(klass, note_number, system="western"):
def from_midi(klass, note_number: int, system: Union[str, object] = "western") -> Tone:
"""Create a Tone from a MIDI note number.
MIDI note 60 = C4 (middle C), 69 = A4 (440 Hz).
@@ -251,18 +313,33 @@ class Tone:
return klass.from_index(index, octave=octave, system=system)
@classmethod
def from_index(klass, i, *, octave, system):
def from_index(klass, i: int, *, octave: int, system: object) -> 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.
Returns:
A new ``Tone`` instance.
"""
tone = system.tones[i].name
return klass(name=tone, octave=octave, system=system)
@property
def _index(self):
def _index(self) -> int:
"""The index of this tone within its associated system's tone list.
Raises:
ValueError: If no system is associated with this tone.
"""
try:
return self.system.tones.index(self.name)
except AttributeError:
raise ValueError("Tone index cannot be referenced without a system!")
def _math(self, interval):
def _math(self, interval: int) -> tuple[int, int]:
"""Returns (new index, new octave).
Octave boundaries follow scientific pitch notation, where the
@@ -292,11 +369,27 @@ class Tone:
return (new_index, new_octave)
def add(self, interval):
def add(self, interval: int) -> Tone:
"""Return a new Tone that is *interval* semitones above this one.
Args:
interval: Number of semitones to add (positive = up).
Returns:
A new ``Tone`` instance.
"""
index, octave = self._math(interval)
return self.from_index(index, octave=octave, system=self.system)
def subtract(self, interval):
def subtract(self, interval: int) -> Tone:
"""Return a new Tone that is *interval* semitones below this one.
Args:
interval: Number of semitones to subtract (positive = down).
Returns:
A new ``Tone`` instance.
"""
return self.add((-1 * interval))
_INTERVAL_NAMES = {
@@ -306,7 +399,7 @@ class Tone:
12: "octave",
}
def interval_to(self, other):
def interval_to(self, other: Tone) -> str:
"""Name the interval between this tone and another.
Returns a string like ``"perfect 5th"``, ``"major 3rd"``, or
@@ -335,7 +428,7 @@ class Tone:
return f"{name} + {octaves} octaves"
@property
def midi(self):
def midi(self) -> Optional[int]:
"""MIDI note number (C4 = 60, A4 = 69).
The MIDI standard assigns integer note numbers from 0127.
@@ -350,7 +443,7 @@ class Tone:
semitones_from_c0 = ((self._index - c_index) % 12) + (self.octave * 12)
return semitones_from_c0 + 12 # MIDI C0 = 12 (C-1 = 0)
def transpose(self, semitones):
def transpose(self, semitones: int) -> Tone:
"""Return a new Tone transposed by the given number of semitones.
Alias for ``tone + semitones`` / ``tone - semitones``. Positive
@@ -358,7 +451,7 @@ class Tone:
"""
return self.add(semitones)
def circle_of_fifths(self):
def circle_of_fifths(self) -> list[Tone]:
"""The 12 tones of the circle of fifths starting from this tone.
Each step ascends by a perfect fifth (7 semitones). After 12
@@ -372,14 +465,14 @@ class Tone:
Returns:
A list of 12 Tones.
"""
tones = []
tones: list[Tone] = []
t = self
for _ in range(12):
tones.append(t)
t = t.add(7)
return tones
def circle_of_fourths(self):
def circle_of_fourths(self) -> list[Tone]:
"""The 12 tones of the circle of fourths starting from this tone.
Each step ascends by a perfect fourth (5 semitones) — the
@@ -390,7 +483,7 @@ class Tone:
Returns:
A list of 12 Tones.
"""
tones = []
tones: list[Tone] = []
t = self
for _ in range(12):
tones.append(t)
@@ -398,11 +491,16 @@ class Tone:
return tones
@property
def frequency(self):
"""The frequency of this tone in Hz (equal temperament, A4=440)."""
return self.pitch()
def frequency(self) -> float:
"""The frequency of this tone in Hz (equal temperament, A4=440).
def overtones(self, n=8):
The result is cached after the first computation.
"""
if self._frequency is None:
self._frequency = self.pitch()
return self._frequency
def overtones(self, n: int = 8) -> list[float]:
"""The first *n* overtones (harmonic series) of this tone.
The harmonic series is the foundation of timbre and consonance.
@@ -439,11 +537,11 @@ class Tone:
def pitch(
self,
*,
reference_pitch=REFERENCE_A,
temperament="equal",
symbolic=False,
precision=None,
):
reference_pitch: float = REFERENCE_A,
temperament: str = "equal",
symbolic: bool = False,
precision: Optional[int] = None,
) -> float:
try:
tones = len(self.system.tones)
except AttributeError:
+400 -1
View File
@@ -2622,7 +2622,7 @@ def test_tension_empty():
def test_version():
import pytheory
assert pytheory.__version__ == "0.3.0"
assert pytheory.__version__ == "0.4.1"
def test_all_exports():
@@ -3248,3 +3248,402 @@ def test_nashville_on_scale():
prog = scale.nashville(1, 5, 1)
assert prog[0].identify() == "C major"
assert prog[1].identify() == "G major"
# ── Capo ───────────────────────────────────────────────────────────────────
def test_guitar_capo():
fb = Fretboard.guitar(capo=2)
assert fb.tones[0].name == "F#"
assert len(fb) == 6
def test_capo_method():
fb = Fretboard.guitar()
fb3 = fb.capo(3)
assert fb3.tones[0].name == "G"
def test_capo_zero():
fb = Fretboard.guitar(capo=0)
assert fb.tones[0].name == "E"
# ── Chord.__add__ ─────────────────────────────────────────────────────────
def test_chord_add():
c = Chord.from_tones("C", "E", "G")
bass = Chord.from_tones("G", octave=2)
merged = c + bass
assert len(merged) == 4
def test_chord_add_preserves_tones():
a = Chord.from_tones("C", "E")
b = Chord.from_tones("G", "B")
merged = a + b
names = [t.name for t in merged]
assert "C" in names and "G" in names
# ── Tritone substitution ──────────────────────────────────────────────────
def test_tritone_sub():
g7 = Chord.from_name("G7")
sub = g7.tritone_sub()
assert sub.identify() == "C# dominant 7th"
def test_tritone_sub_is_6_semitones():
c = Chord.from_tones("C", "E", "G")
sub = c.tritone_sub()
assert sub.root.name == "F#"
# ── Secondary dominants ──────────────────────────────────────────────────
def test_secondary_dominant_V_of_V():
k = Key("C", "major")
vv = k.secondary_dominant(5)
assert vv.identify() == "D dominant 7th"
def test_secondary_dominant_V_of_ii():
k = Key("C", "major")
assert k.secondary_dominant(2).identify() == "A dominant 7th"
def test_secondary_dominant_V_of_vi():
k = Key("C", "major")
assert k.secondary_dominant(6).identify() == "E dominant 7th"
# ── Key.all_keys ─────────────────────────────────────────────────────────
def test_all_keys():
keys = Key.all_keys()
assert len(keys) == 24
majors = [k for k in keys if k.mode == "major"]
minors = [k for k in keys if k.mode == "minor"]
assert len(majors) == 12
assert len(minors) == 12
# ── More progressions ───────────────────────────────────────────────────
def test_progressions_count():
from pytheory.scales import PROGRESSIONS
assert len(PROGRESSIONS) >= 14
def test_pachelbel_progression():
from pytheory.scales import PROGRESSIONS
k = Key("C", "major")
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)
Generated
+1 -1
View File
@@ -612,7 +612,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.3.0"
version = "0.4.1"
source = { editable = "." }
dependencies = [
{ name = "numeral" },