mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c307c1e41f | |||
| b1f6996cd7 | |||
| 51ca98779d | |||
| 37b41e1bbf | |||
| da40189845 | |||
| 54b82440c4 | |||
| ef003dbd1d |
+1
-1
@@ -10,7 +10,7 @@ sys.modules["sounddevice"] = MagicMock()
|
||||
project = "PyTheory"
|
||||
copyright = "2026, Kenneth Reitz"
|
||||
author = "Kenneth Reitz"
|
||||
release = "0.3.1"
|
||||
release = "0.4.1"
|
||||
|
||||
extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
|
||||
@@ -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.1"
|
||||
version = "0.4.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.1"
|
||||
__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",
|
||||
]
|
||||
|
||||
+227
-58
@@ -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,64 @@ 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 __add__(self, other):
|
||||
def __add__(self, other: Chord) -> Chord:
|
||||
"""Merge two chords into one (layer their tones).
|
||||
|
||||
Example::
|
||||
@@ -86,7 +130,7 @@ class Chord:
|
||||
return Chord(tones=list(self.tones) + list(other.tones))
|
||||
return NotImplemented
|
||||
|
||||
def tritone_sub(self):
|
||||
def tritone_sub(self) -> Chord:
|
||||
"""Return the tritone substitution of this chord.
|
||||
|
||||
In jazz harmony, any dominant chord can be replaced by the
|
||||
@@ -98,7 +142,7 @@ class Chord:
|
||||
"""
|
||||
return self.transpose(6)
|
||||
|
||||
def inversion(self, n=1):
|
||||
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:
|
||||
@@ -121,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
|
||||
@@ -136,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
|
||||
@@ -155,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
|
||||
@@ -168,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
|
||||
@@ -201,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
|
||||
@@ -243,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
|
||||
@@ -296,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,
|
||||
@@ -337,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
|
||||
@@ -379,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
|
||||
@@ -400,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
|
||||
|
||||
@@ -413,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)
|
||||
@@ -471,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
|
||||
@@ -541,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
|
||||
@@ -603,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)."
|
||||
@@ -617,14 +709,20 @@ 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 capo(self, fret):
|
||||
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
|
||||
@@ -652,10 +750,12 @@ class Fretboard:
|
||||
"""
|
||||
return Fretboard(tones=[t.add(fret) for t in self.tones])
|
||||
|
||||
def __iter__(self):
|
||||
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 = [
|
||||
@@ -680,7 +780,7 @@ class Fretboard:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def guitar(cls, tuning="standard", capo=0):
|
||||
def guitar(cls, tuning: Union[str, tuple[str, ...]] = "standard", capo: int = 0) -> Fretboard:
|
||||
"""Guitar with the given tuning and optional capo.
|
||||
|
||||
Args:
|
||||
@@ -699,7 +799,7 @@ class Fretboard:
|
||||
return fb
|
||||
|
||||
@classmethod
|
||||
def bass(cls, five_string=False):
|
||||
def bass(cls, five_string: bool = False) -> Fretboard:
|
||||
"""Standard bass guitar tuning.
|
||||
|
||||
Args:
|
||||
@@ -712,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.
|
||||
@@ -726,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.
|
||||
@@ -741,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
|
||||
@@ -757,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.
|
||||
@@ -774,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
|
||||
@@ -790,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
|
||||
@@ -806,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
|
||||
@@ -821,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
|
||||
@@ -836,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:
|
||||
@@ -858,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
|
||||
@@ -877,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),
|
||||
@@ -905,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
|
||||
@@ -919,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:
|
||||
@@ -939,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
|
||||
@@ -953,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
|
||||
@@ -970,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
|
||||
@@ -988,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
|
||||
@@ -1003,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
|
||||
@@ -1021,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
|
||||
@@ -1037,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
|
||||
@@ -1052,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:
|
||||
@@ -1078,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
|
||||
@@ -1091,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
|
||||
@@ -1111,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)."
|
||||
@@ -1122,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]
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
"""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_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)")
|
||||
|
||||
# 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,
|
||||
"detect": cmd_detect,
|
||||
}
|
||||
commands[args.command](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+187
-39
@@ -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)
|
||||
@@ -301,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):
|
||||
@@ -313,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
|
||||
@@ -355,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::
|
||||
@@ -399,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::
|
||||
@@ -408,7 +434,7 @@ class Key:
|
||||
"""
|
||||
return self._scale.nashville(*numbers)
|
||||
|
||||
def secondary_dominant(self, degree):
|
||||
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
|
||||
@@ -441,7 +467,7 @@ class Key:
|
||||
return Chord(tones=[root, root.add(4), root.add(7), root.add(10)])
|
||||
|
||||
@classmethod
|
||||
def all_keys(cls):
|
||||
def all_keys(cls) -> list[Key]:
|
||||
"""Return all 24 major and minor keys.
|
||||
|
||||
Returns:
|
||||
@@ -461,7 +487,110 @@ class Key:
|
||||
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).
|
||||
@@ -478,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")
|
||||
@@ -488,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
|
||||
@@ -497,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:
|
||||
@@ -536,4 +683,5 @@ class TonedScale:
|
||||
|
||||
scales[scale] = Scale(tones=tuple(working_scale))
|
||||
|
||||
self._cached_scales = scales
|
||||
return scales
|
||||
|
||||
+142
-44
@@ -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 0–127.
|
||||
@@ -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:
|
||||
|
||||
+306
-1
@@ -2622,7 +2622,7 @@ def test_tension_empty():
|
||||
|
||||
def test_version():
|
||||
import pytheory
|
||||
assert pytheory.__version__ == "0.3.1"
|
||||
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