Compare commits

...

5 Commits

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:53:06 -04:00
kennethreitz da40189845 v0.4.0: key signatures, scale diagrams, chord building, progression analysis
New features:
- Key.signature — sharps/flats count and accidental names
- Key.borrowed_chords — modal interchange from parallel key
- Key.random_progression(n) — weighted random diatonic progressions
- Chord.from_intervals("C", 4, 7) — build from root + semitones
- Chord.from_midi_message(60, 64, 67) — build from MIDI note numbers
- Chord.add_tone(tone) / remove_tone("B") — modify chords immutably
- Tone.letter — "C" from "C#" (letter without accidental)
- Fretboard.scale_diagram(scale) — ASCII neck diagram
- analyze_progression([chords], key="C") → ["I", "vi", "IV", "V"]

443 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:48:04 -04:00
10 changed files with 1384 additions and 7 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ sys.modules["sounddevice"] = MagicMock()
project = "PyTheory"
copyright = "2026, Kenneth Reitz"
author = "Kenneth Reitz"
release = "0.3.2"
release = "0.4.1"
extensions = [
"sphinx.ext.autodoc",
+677
View File
@@ -0,0 +1,677 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# PyTheory: Music Theory for Humans\n",
"\n",
"A hands-on tutorial exploring music theory with Python.\n",
"\n",
"PyTheory lets you reason about tones, scales, chords, and progressions\n",
"using an intuitive, Pythonic API. Whether you're a musician who codes\n",
"or a coder who plays music, this library gives you the building blocks\n",
"to explore harmony, composition, and world music systems."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 1. Getting Started\n",
"\n",
"Everything begins with a **Tone** -- the fundamental unit of music.\n",
"A tone has a name (like `C`, `F#`, or `Bb`), an optional octave number,\n",
"and a frequency in Hz computed from equal temperament tuning (A4 = 440 Hz)."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"from pytheory import Tone, TonedScale, Key, Chord, Fretboard, CHARTS, Interval\n",
"from pytheory import analyze_progression\n",
"from pytheory.scales import PROGRESSIONS"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Create tones with octave numbers (scientific pitch notation)\n",
"middle_c = Tone.from_string(\"C4\")\n",
"concert_a = Tone.from_string(\"A4\")\n",
"\n",
"print(f\"Middle C: {middle_c} -> {middle_c.frequency:.2f} Hz\")\n",
"print(f\"Concert A: {concert_a} -> {concert_a.frequency:.2f} Hz\")\n",
"print(f\"MIDI note: {middle_c.midi}\")\n",
"print(f\"Is natural? {middle_c.is_natural}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Create tones from frequencies or MIDI numbers\n",
"from_hz = Tone.from_frequency(440.0)\n",
"from_midi = Tone.from_midi(60)\n",
"\n",
"print(f\"440 Hz -> {from_hz}\")\n",
"print(f\"MIDI 60 -> {from_midi}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Explore the harmonic series -- the physics behind consonance\n",
"c3 = Tone.from_string(\"C3\")\n",
"harmonics = c3.overtones(8)\n",
"print(f\"Harmonic series of {c3} ({c3.frequency:.1f} Hz):\")\n",
"for i, hz in enumerate(harmonics, 1):\n",
" print(f\" Harmonic {i}: {hz:.1f} Hz\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 2. Tone Arithmetic\n",
"\n",
"Tones support arithmetic operations. Adding an integer to a tone raises it\n",
"by that many **semitones** (half steps). Subtracting two tones gives the\n",
"semitone distance between them. You can also compare tones by pitch."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"c4 = Tone.from_string(\"C4\")\n",
"\n",
"# Add semitones: C + 4 semitones = E (a major third)\n",
"e4 = c4 + 4\n",
"g4 = c4 + Interval.PERFECT_FIFTH\n",
"print(f\"{c4} + 4 semitones = {e4}\")\n",
"print(f\"{c4} + perfect 5th = {g4}\")\n",
"\n",
"# Subtract to find interval distance\n",
"distance = g4 - c4\n",
"print(f\"\\nDistance from {c4} to {g4}: {distance} semitones\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Name the interval between two tones\n",
"print(f\"{c4} -> {e4}: {c4.interval_to(e4)}\")\n",
"print(f\"{c4} -> {g4}: {c4.interval_to(g4)}\")\n",
"\n",
"c5 = Tone.from_string(\"C5\")\n",
"print(f\"{c4} -> {c5}: {c4.interval_to(c5)}\")\n",
"\n",
"# Compare tones by pitch\n",
"print(f\"\\n{c4} < {g4}? {c4 < g4}\")\n",
"print(f\"{c4} == {c4}? {c4 == c4}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# The circle of fifths -- the backbone of Western harmony\n",
"c = Tone.from_string(\"C4\")\n",
"fifths = c.circle_of_fifths()\n",
"print(\"Circle of fifths from C:\")\n",
"print(\" -> \".join(str(t) for t in fifths))"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 3. Scales and Modes\n",
"\n",
"A **scale** is a collection of tones arranged in ascending order.\n",
"The `TonedScale` class provides access to dozens of scales from a given tonic.\n",
"\n",
"**Modes** are rotations of the same set of intervals. The seven modes of the\n",
"major scale each have a distinct character:\n",
"\n",
"| Mode | Character |\n",
"|------------|--------------------|\n",
"| Ionian | Bright, happy |\n",
"| Dorian | Jazzy, soulful |\n",
"| Phrygian | Spanish, dark |\n",
"| Lydian | Dreamy, floating |\n",
"| Mixolydian | Bluesy, rock |\n",
"| Aeolian | Sad, natural minor |\n",
"| Locrian | Tense, unstable |"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Build a scale from a tonic\n",
"ts = TonedScale(tonic=\"C4\")\n",
"\n",
"# See all available scale names\n",
"print(\"Available scales:\")\n",
"for name in ts.scales:\n",
" print(f\" {name}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Get a specific scale and iterate its tones\n",
"c_major = ts[\"major\"]\n",
"print(f\"C major: {c_major.note_names}\")\n",
"\n",
"c_minor = ts[\"minor\"]\n",
"print(f\"C minor: {c_minor.note_names}\")\n",
"\n",
"# Check if a note belongs to the scale\n",
"print(f\"\\nIs F# in C major? {'F#' in c_major}\")\n",
"print(f\"Is G in C major? {'G' in c_major}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": "from pytheory.scales import Scale\n\n# Transpose a scale\nd_major = c_major.transpose(2)\nprint(f\"D major (C major transposed up 2): {d_major.note_names}\")\n\n# Detect a scale from a set of notes\nresult = Scale.detect(\"A\", \"B\", \"C#\", \"D\", \"E\", \"F#\", \"G#\")\nprint(f\"\\nDetected scale: {result}\")",
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 4. The Key Class\n",
"\n",
"A **Key** is the most convenient entry point for working with harmony.\n",
"It wraps a tonic and mode, giving you instant access to scales, diatonic\n",
"chords, key signatures, and related keys."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"key = Key(\"C\", \"major\")\n",
"\n",
"print(f\"Key: {key}\")\n",
"print(f\"Notes: {key.note_names}\")\n",
"print(f\"Signature: {key.signature}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Diatonic triads -- the seven chords built from the scale\n",
"print(\"Diatonic triads in C major:\")\n",
"for i, name in enumerate(key.chords, 1):\n",
" print(f\" {i}. {name}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Seventh chords add richness and color\n",
"print(\"Diatonic seventh chords in C major:\")\n",
"for i, name in enumerate(key.seventh_chords, 1):\n",
" print(f\" {i}. {name}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Related keys\n",
"print(f\"Relative minor of C major: {key.relative}\")\n",
"print(f\"Parallel minor of C major: {key.parallel}\")\n",
"\n",
"# Key signatures for sharp and flat keys\n",
"for tonic in [\"G\", \"D\", \"F\", \"Bb\"]:\n",
" k = Key(tonic, \"major\")\n",
" sig = k.signature\n",
" print(f\"{k}: {sig['sharps']} sharps, {sig['flats']} flats -> {sig['accidentals']}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 5. Chord Analysis\n",
"\n",
"Chords can be created from note names, intervals, chord symbols, or MIDI.\n",
"PyTheory can identify chord quality, measure tension and consonance,\n",
"and compute optimal voice leading between chords."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Multiple ways to create chords\n",
"c_major_chord = Chord.from_tones(\"C\", \"E\", \"G\")\n",
"g7 = Chord.from_intervals(\"G\", 4, 7, 10)\n",
"am = Chord.from_name(\"Am\")\n",
"\n",
"print(f\"{c_major_chord} (intervals: {c_major_chord.intervals})\")\n",
"print(f\"{g7} (intervals: {g7.intervals})\")\n",
"print(f\"{am} (intervals: {am.intervals})\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Analyze harmonic tension\n",
"# The dominant 7th chord is the most tension-filled chord in tonal music\n",
"print(\"Tension analysis:\")\n",
"for chord in [c_major_chord, am, g7]:\n",
" t = chord.tension\n",
" print(f\" {chord.identify():20s} -> score={t['score']:.2f}, \"\n",
" f\"tritones={t['tritones']}, dominant={t['has_dominant_function']}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Consonance vs dissonance (psychoacoustic measures)\n",
"print(f\"{'Chord':20s} {'Harmony':>10s} {'Dissonance':>12s}\")\n",
"print(\"-\" * 44)\n",
"for chord in [c_major_chord, am, g7]:\n",
" print(f\"{chord.identify():20s} {chord.harmony:10.4f} {chord.dissonance:12.4f}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Voice leading: how individual notes move between chords\n",
"f_major = Chord.from_tones(\"F\", \"A\", \"C\")\n",
"vl = c_major_chord.voice_leading(f_major)\n",
"\n",
"print(f\"Voice leading: {c_major_chord.identify()} -> {f_major.identify()}\")\n",
"for src, dst, movement in vl:\n",
" direction = \"up\" if movement > 0 else \"down\" if movement < 0 else \"stays\"\n",
" print(f\" {src} -> {dst} ({movement:+d} semitones, {direction})\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Inversions rearrange chord voicings\n",
"print(f\"Root position: {[t.full_name for t in c_major_chord.tones]}\")\n",
"print(f\"1st inversion: {[t.full_name for t in c_major_chord.inversion(1).tones]}\")\n",
"print(f\"2nd inversion: {[t.full_name for t in c_major_chord.inversion(2).tones]}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 6. Chord Progressions\n",
"\n",
"Chord progressions are the harmonic backbone of songs. PyTheory supports\n",
"both **Roman numeral** analysis (classical/jazz) and the **Nashville number\n",
"system** (studio shorthand). It also ships with common progressions built in."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"key = Key(\"G\", \"major\")\n",
"\n",
"# Build a progression from Roman numerals\n",
"prog = key.progression(\"I\", \"V\", \"vi\", \"IV\")\n",
"print(\"I - V - vi - IV in G major (the 'four chord song'):\")\n",
"for chord in prog:\n",
" print(f\" {chord.identify()}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Nashville number system -- same thing, Arabic numerals\n",
"nashville = key.nashville(1, 5, 6, 4)\n",
"print(\"Nashville 1-5-6-4 in G major:\")\n",
"for chord in nashville:\n",
" print(f\" {chord.identify()}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Browse the built-in progression library\n",
"print(\"Built-in progressions:\")\n",
"for name, numerals in PROGRESSIONS.items():\n",
" print(f\" {name:25s} -> {' '.join(numerals)}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Analyze an existing chord progression\n",
"chords = [Chord.from_name(\"C\"), Chord.from_name(\"Am\"),\n",
" Chord.from_name(\"F\"), Chord.from_name(\"G\")]\n",
"numerals = analyze_progression(chords, key=\"C\")\n",
"print(\"Progression analysis in C:\")\n",
"for chord, numeral in zip(chords, numerals):\n",
" print(f\" {chord.identify():15s} -> {numeral}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 7. World Music Systems\n",
"\n",
"Music theory extends far beyond Western harmony. PyTheory includes scale\n",
"systems from several traditions:\n",
"\n",
"- **Indian** (raga/thaat) -- 10 parent scales covering all of Hindustani music\n",
"- **Arabic** (maqam) -- modal systems with characteristic augmented seconds\n",
"- **Japanese** -- pentatonic scales used in koto, shamisen, and folk music\n",
"- **Blues** -- the scales that built American popular music\n",
"- **Gamelan** -- Javanese/Balinese tuning systems (12-TET approximations)"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"from pytheory import SYSTEMS\n",
"\n",
"# Indian thaat system\n",
"indian = TonedScale(tonic=\"C4\", system=SYSTEMS[\"indian\"])\n",
"print(\"Indian thaats from C:\")\n",
"for name in indian.scales:\n",
" scale = indian[name]\n",
" print(f\" {name:12s} -> {scale.note_names}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Arabic maqam -- the Hijaz scale has a distinctive augmented 2nd\n",
"arabic = TonedScale(tonic=\"D4\", system=SYSTEMS[\"arabic\"])\n",
"hijaz = arabic[\"hijaz\"]\n",
"print(f\"Maqam Hijaz from D: {hijaz.note_names}\")\n",
"\n",
"# Japanese hirajoshi -- hauntingly beautiful pentatonic\n",
"japanese = TonedScale(tonic=\"A4\", system=SYSTEMS[\"japanese\"])\n",
"hirajoshi = japanese[\"hirajoshi\"]\n",
"print(f\"Hirajoshi from A: {hirajoshi.note_names}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Blues scales -- the foundation of rock, jazz, and R&B\n",
"blues = TonedScale(tonic=\"A4\", system=SYSTEMS[\"blues\"])\n",
"print(\"Blues scales from A:\")\n",
"for name in blues.scales:\n",
" scale = blues[name]\n",
" print(f\" {name:20s} -> {scale.note_names}\")\n",
"\n",
"# Gamelan -- approximations of non-Western tuning\n",
"gamelan = TonedScale(tonic=\"C4\", system=SYSTEMS[\"gamelan\"])\n",
"slendro = gamelan[\"slendro\"]\n",
"print(f\"\\nGamelan slendro from C: {slendro.note_names}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 8. Guitar and Instruments\n",
"\n",
"The `Fretboard` class models stringed instruments. You can look up\n",
"chord fingerings, render tab diagrams, apply a capo, and visualize\n",
"scale patterns across the neck."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Standard guitar fretboard\n",
"guitar = Fretboard.guitar()\n",
"print(f\"Standard tuning: {guitar}\")\n",
"\n",
"# Look up chord fingerings from the chart\n",
"c_chart = CHARTS[\"western\"][\"C\"]\n",
"print(f\"\\n{c_chart.tab(fretboard=guitar)}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Show several common chord shapes\n",
"for chord_name in [\"G\", \"Am\", \"Em\", \"D\"]:\n",
" chart = CHARTS[\"western\"][chord_name]\n",
" print(chart.tab(fretboard=guitar))\n",
" print()"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Apply a capo -- raises all strings by N semitones\n",
"capo2 = Fretboard.guitar(capo=2)\n",
"print(f\"Capo on fret 2: {capo2}\")\n",
"print(\"Playing 'G shape' with capo 2 = A major voicing\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Scale diagram -- see where notes fall on the neck\n",
"c_major_scale = TonedScale(tonic=\"C4\")[\"major\"]\n",
"diagram = guitar.scale_diagram(c_major_scale, frets=12)\n",
"print(\"C major scale on guitar:\")\n",
"print(diagram)"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 9. Building a Song\n",
"\n",
"Let's put it all together: pick a key, explore its chords, build a\n",
"progression, and analyze the harmonic movement."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Step 1: Choose a key\n",
"song_key = Key(\"E\", \"minor\")\n",
"print(f\"Key: {song_key}\")\n",
"print(f\"Notes: {song_key.note_names}\")\n",
"print(f\"Relative major: {song_key.relative}\")\n",
"print(f\"Signature: {song_key.signature}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Step 2: See what chords are available\n",
"print(\"Diatonic chords in E minor:\")\n",
"for i, name in enumerate(song_key.chords, 1):\n",
" print(f\" {i}. {name}\")\n",
"\n",
"print(\"\\nBorrowed chords from E major:\")\n",
"for name in song_key.borrowed_chords[:4]:\n",
" print(f\" {name}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Step 3: Build a progression\n",
"# i - VI - III - VII is a classic minor key progression\n",
"prog = song_key.progression(\"i\", \"VI\", \"III\", \"VII\")\n",
"\n",
"print(\"Progression: i - VI - III - VII\")\n",
"for chord in prog:\n",
" name = chord.identify()\n",
" numeral = chord.analyze(\"E\", \"minor\")\n",
" t = chord.tension\n",
" print(f\" {name:18s} [{numeral:5s}] tension={t['score']:.2f}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Step 4: Analyze voice leading through the progression\n",
"print(\"Voice leading through the progression:\\n\")\n",
"for i in range(len(prog) - 1):\n",
" src = prog[i]\n",
" dst = prog[i + 1]\n",
" vl = src.voice_leading(dst)\n",
" total = sum(abs(m) for _, _, m in vl)\n",
" print(f\"{src.identify()} -> {dst.identify()} (total movement: {total} semitones)\")\n",
" for s, d, m in vl:\n",
" print(f\" {s} -> {d} ({m:+d})\")\n",
" print()"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Step 5: Show the chords on guitar\n",
"guitar = Fretboard.guitar()\n",
"chord_names = [\"Em\", \"C\", \"G\", \"D\"]\n",
"\n",
"print(\"Guitar charts for the progression:\\n\")\n",
"for name in chord_names:\n",
" chart = CHARTS[\"western\"][name]\n",
" print(chart.tab(fretboard=guitar))\n",
" print()"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Bonus: Detect the key from a set of notes\n",
"detected = Key.detect(\"E\", \"G\", \"A\", \"B\", \"D\")\n",
"print(f\"Key detected from [E, G, A, B, D]: {detected}\")\n",
"\n",
"# Secondary dominant -- adds harmonic color\n",
"v_of_v = song_key.secondary_dominant(5)\n",
"print(f\"\\nSecondary dominant V/V in E minor: {v_of_v.identify()}\")\n",
"print(f\"Tension score: {v_of_v.tension['score']:.2f}\")"
],
"outputs": [],
"execution_count": null
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.12.0"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+4 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.3.2"
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"]
+3 -3
View File
@@ -1,11 +1,11 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.3.2"
__version__ = "0.4.1"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
from .scales import Scale, TonedScale, Key, PROGRESSIONS
from .chords import Chord, Fretboard
from .chords import Chord, Fretboard, analyze_progression
from .charts import CHARTS, charts_for_fretboard
try:
@@ -19,7 +19,7 @@ Note = Tone
__all__ = [
"Tone", "Note", "Interval", "Scale", "TonedScale", "Key",
"PROGRESSIONS", "Chord", "Fretboard",
"PROGRESSIONS", "Chord", "Fretboard", "analyze_progression",
"System", "SYSTEMS", "CHARTS", "charts_for_fretboard",
"play", "Synth",
]
+108
View File
@@ -60,6 +60,36 @@ class Chord:
f"{t.name}{octave}", system="western"))
return cls(tones=tones)
@classmethod
def from_intervals(cls, root: str, *intervals: int, octave: int = 4) -> Chord:
"""Create a Chord from a root note and semitone intervals.
Example::
>>> Chord.from_intervals("C", 4, 7) # C major
<Chord C major>
>>> Chord.from_intervals("G", 4, 7, 10) # G7
<Chord G dominant 7th>
>>> Chord.from_intervals("D", 3, 7) # D minor
<Chord D minor>
"""
from .tones import Tone
root_tone = Tone.from_string(f"{root}{octave}", system="western")
tones = [root_tone] + [root_tone.add(i) for i in intervals]
return cls(tones=tones)
@classmethod
def from_midi_message(cls, *note_numbers: int) -> Chord:
"""Create a Chord from MIDI note numbers.
Example::
>>> Chord.from_midi_message(60, 64, 67) # C4, E4, G4
<Chord C major>
"""
from .tones import Tone
return cls(tones=[Tone.from_midi(n) for n in note_numbers])
def __repr__(self) -> str:
name = self.identify()
if name:
@@ -625,6 +655,31 @@ class Chord:
"has_dominant_function": has_dominant,
}
def add_tone(self, tone) -> Chord:
"""Return a new Chord with an additional tone.
Example::
>>> c_major = Chord.from_tones("C", "E", "G")
>>> c_major.add_tone(Tone.from_string("B4", system="western"))
<Chord C major 7th>
"""
return Chord(tones=list(self.tones) + [tone])
def remove_tone(self, tone_name: str) -> Chord:
"""Return a new Chord with tones of the given name removed.
Args:
tone_name: The note name to remove (e.g. "G").
Example::
>>> cmaj7 = Chord.from_name("Cmaj7")
>>> cmaj7.remove_tone("B") # Remove the 7th
<Chord C major>
"""
return Chord(tones=[t for t in self.tones if t.name != tone_name])
def fingering(self, *positions: int) -> Chord:
"""Apply fret positions to each tone, returning a new Chord.
@@ -1156,6 +1211,47 @@ class Fretboard:
]
return cls(tones=[Tone.from_string(t, system="western") for t in strings])
def scale_diagram(self, scale, frets: int = 12) -> str:
"""Render an ASCII diagram showing where scale notes fall on the neck.
Each string is shown with dots on frets where scale notes appear.
Useful for learning scale patterns on guitar, mandolin, etc.
Args:
scale: A Scale object (or anything with a ``note_names`` attribute).
frets: Number of frets to display (default 12).
Returns:
A multi-line string showing the fretboard diagram.
Example::
>>> from pytheory import Fretboard, TonedScale
>>> fb = Fretboard.guitar()
>>> pentatonic = TonedScale(tonic="A4")["minor"]
>>> print(fb.scale_diagram(pentatonic, frets=5))
"""
scale_notes = set(scale.note_names)
max_name = max(len(t.name) for t in self.tones)
lines = []
# Header with fret numbers
header = " " * (max_name + 1) + " ".join(f"{f:<3d}" for f in range(frets + 1))
lines.append(header)
for tone in self.tones:
fret_marks = []
for f in range(frets + 1):
note = tone.add(f)
if note.name in scale_notes:
fret_marks.append(f" {note.name:<2s}")
else:
fret_marks.append(" - ")
line = f"{tone.name:>{max_name}}|{'|'.join(fret_marks)}|"
lines.append(line)
return "\n".join(lines)
def fingering(self, *positions: int) -> Chord:
"""Apply fret positions to each string, returning a Chord.
@@ -1183,3 +1279,15 @@ class Fretboard:
tones.append(tone.add(positions[i]))
return Chord(tones=tones)
def analyze_progression(chords: list[Chord], key: str = "C", mode: str = "major") -> list[str | None]:
"""Analyze a list of chords and return their Roman numeral functions.
Example::
>>> chords = [Chord.from_name("C"), Chord.from_name("Am"), Chord.from_name("F"), Chord.from_name("G")]
>>> analyze_progression(chords, key="C")
['I', 'vi', 'IV', 'V']
"""
return [c.analyze(key, mode) for c in chords]
+166
View File
@@ -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()
+103
View File
@@ -486,6 +486,109 @@ class Key:
keys.append(cls(tonic, "minor"))
return keys
@property
def signature(self) -> dict:
"""The key signature — number and names of sharps or flats.
In Western music, each key has a unique key signature that tells
you which notes are sharped or flatted throughout a piece.
Returns:
A dict with:
- ``sharps`` (int): number of sharps (0 if flat key)
- ``flats`` (int): number of flats (0 if sharp key)
- ``accidentals`` (list[str]): the sharped/flatted note names
Example::
>>> Key("G", "major").signature
{'sharps': 1, 'flats': 0, 'accidentals': ['F#']}
>>> Key("F", "major").signature
{'sharps': 0, 'flats': 1, 'accidentals': ['Bb']}
>>> Key("C", "major").signature
{'sharps': 0, 'flats': 0, 'accidentals': []}
"""
# Compare scale notes against the natural notes C D E F G A B
naturals = {"C", "D", "E", "F", "G", "A", "B"}
scale_notes = set(self.note_names[:-1]) # exclude octave
sharps = [n for n in scale_notes if "#" in n]
flats = [n for n in scale_notes if "b" in n[1:]] # skip first char for B
# Order sharps: F C G D A E B
sharp_order = ["F#", "C#", "G#", "D#", "A#", "E#", "B#"]
flat_order = ["Bb", "Eb", "Ab", "Db", "Gb", "Cb", "Fb"]
sharps_sorted = [s for s in sharp_order if s in sharps]
flats_sorted = [f for f in flat_order if f in flats]
if sharps_sorted:
return {"sharps": len(sharps_sorted), "flats": 0, "accidentals": sharps_sorted}
elif flats_sorted:
return {"sharps": 0, "flats": len(flats_sorted), "accidentals": flats_sorted}
else:
return {"sharps": 0, "flats": 0, "accidentals": []}
@property
def borrowed_chords(self) -> list[str]:
"""Chords borrowed from the parallel key.
Modal interchange (or modal mixture) borrows chords from the
parallel major or minor key. In C major, the parallel minor
is C minor, which provides chords like Ab major, Bb major,
and Eb major — commonly heard in rock, film, and pop music.
Returns:
A list of chord names from the parallel key that are NOT
in the current key's diatonic chords.
Example::
>>> Key("C", "major").borrowed_chords
['C minor', 'D diminished', 'D# major', ...]
"""
par = self.parallel
if par is None:
return []
own = set(self.chords)
return [c for c in par.chords if c not in own]
def random_progression(self, length: int = 4) -> list:
"""Generate a random diatonic chord progression.
Uses weighted probabilities based on common chord function:
I and vi are most common, IV and V are very common, ii is
common, iii and viidim are rare. Always starts on I and
ends on I or V.
Args:
length: Number of chords (default 4).
Returns:
A list of Chord objects.
Example::
>>> Key("C", "major").random_progression(4)
[<Chord C major>, <Chord F major>, <Chord G major>, <Chord C major>]
"""
import random
harmonized = self._scale.harmonize()
unique = len(harmonized)
# Weights: I=high, ii=med, iii=low, IV=high, V=high, vi=med, vii=low
weights = [10, 5, 2, 8, 8, 5, 1]
if unique < len(weights):
weights = weights[:unique]
chords = [harmonized[0]] # Start on I
for _ in range(length - 2):
chords.append(random.choices(harmonized, weights=weights, k=1)[0])
if length > 1:
# End on I or V
chords.append(random.choice([harmonized[0], harmonized[4 % unique]]))
return chords
@property
def relative(self) -> Optional[Key]:
"""The relative major or minor key.
+15
View File
@@ -115,6 +115,21 @@ class Tone:
"""True if this tone has a flat (b after the first character)."""
return "b" in self.name[1:]
@property
def letter(self) -> str:
"""The letter name without any accidental.
Example::
>>> Tone.from_string("C#4").letter
'C'
>>> Tone.from_string("Bb4").letter
'B'
>>> Tone.from_string("G4").letter
'G'
"""
return self.name[0]
@property
def enharmonic(self) -> Optional[str]:
"""The enharmonic equivalent of this tone, or None if there isn't one.
+306 -1
View File
@@ -2622,7 +2622,7 @@ def test_tension_empty():
def test_version():
import pytheory
assert pytheory.__version__ == "0.3.2"
assert pytheory.__version__ == "0.4.1"
def test_all_exports():
@@ -3342,3 +3342,308 @@ def test_pachelbel_progression():
prog = k.progression(*PROGRESSIONS["Pachelbel"])
assert len(prog) == 8
assert prog[0].identify() == "C major"
# ── Tone.letter ────────────────────────────────────────────────────────────
def test_tone_letter_natural():
assert Tone.from_string("C4").letter == "C"
def test_tone_letter_sharp():
assert Tone.from_string("C#4").letter == "C"
def test_tone_letter_flat():
assert Tone(name="Bb", octave=4).letter == "B"
# ── Key.signature ──────────────────────────────────────────────────────────
def test_key_signature_c_major():
sig = Key("C", "major").signature
assert sig["sharps"] == 0
assert sig["flats"] == 0
def test_key_signature_g_major():
sig = Key("G", "major").signature
assert sig["sharps"] == 1
assert sig["accidentals"] == ["F#"]
def test_key_signature_d_major():
sig = Key("D", "major").signature
assert sig["sharps"] == 2
# ── Chord.from_intervals ──────────────────────────────────────────────────
def test_chord_from_intervals_major():
assert Chord.from_intervals("C", 4, 7).identify() == "C major"
def test_chord_from_intervals_dom7():
assert Chord.from_intervals("G", 4, 7, 10).identify() == "G dominant 7th"
# ── Chord.from_midi_message ──────────────────────────────────────────────
def test_chord_from_midi_message():
c = Chord.from_midi_message(60, 64, 67)
assert c.identify() == "C major"
# ── Chord.add_tone / remove_tone ──────────────────────────────────────────
def test_chord_add_tone():
c = Chord.from_tones("C", "E", "G")
cmaj7 = c.add_tone(Tone("B", octave=4))
assert cmaj7.identify() == "C major 7th"
def test_chord_remove_tone():
cmaj7 = Chord.from_name("Cmaj7")
c = cmaj7.remove_tone("B")
assert c.identify() == "C major"
# ── analyze_progression ──────────────────────────────────────────────────
def test_analyze_progression():
from pytheory import analyze_progression
prog = [Chord.from_name("C"), Chord.from_name("Am"),
Chord.from_name("F"), Chord.from_name("G")]
assert analyze_progression(prog, key="C") == ["I", "vi", "IV", "V"]
# ── Key.borrowed_chords ─────────────────────────────────────────────────
def test_borrowed_chords():
borrowed = Key("C", "major").borrowed_chords
assert len(borrowed) > 0
# ── Key.random_progression ──────────────────────────────────────────────
def test_random_progression():
prog = Key("C", "major").random_progression(4)
assert len(prog) == 4
# ── Fretboard.scale_diagram ────────────────────────────────────────────
def test_scale_diagram():
fb = Fretboard.guitar()
scale = TonedScale(tonic="C4")["major"]
diagram = fb.scale_diagram(scale, frets=5)
assert "E|" in diagram
lines = diagram.strip().split("\n")
assert len(lines) == 7
# ── Coverage gap tests ─────────────────────────────────────────────────────
def test_tone_init_octave_parsed_from_name():
"""Tone('C4') should parse octave from name string."""
t = Tone("C4")
assert t.octave == 4
assert t.name == "C"
def test_tone_enharmonic_from_alt_names_direct():
t = Tone(name="C#", alt_names="Db", octave=4)
assert t.enharmonic == "Db"
def test_tone_sub_not_implemented():
t = Tone("C4")
result = t.__sub__(3.5)
assert result is NotImplemented
def test_tone_lt_not_implemented():
assert Tone("C4").__lt__("not a tone") is NotImplemented
def test_tone_le_not_implemented():
assert Tone("C4").__le__("not a tone") is NotImplemented
def test_tone_gt_not_implemented():
assert Tone("C4").__gt__("not a tone") is NotImplemented
def test_tone_ge_not_implemented():
assert Tone("C4").__ge__("not a tone") is NotImplemented
def test_tone_from_frequency_negative_raises():
with pytest.raises(ValueError, match="positive"):
Tone.from_frequency(-100)
def test_tone_interval_compound_2_octaves():
c4 = Tone.from_string("C4", system="western")
e6 = c4 + 28 # 2 octaves + major 3rd
assert "2 octaves" in c4.interval_to(e6)
def test_tone_circle_of_fifths_returns_12():
c = Tone.from_string("C4", system="western")
assert len(c.circle_of_fifths()) == 12
def test_tone_circle_of_fourths_returns_12():
c = Tone.from_string("C4", system="western")
assert len(c.circle_of_fourths()) == 12
def test_chord_repr_unidentified():
"""Chord with no known pattern should show raw tones in repr."""
c = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("D4", system="western"),
])
assert "tones=" in repr(c)
def test_chord_str_unidentified():
c = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("D4", system="western"),
])
assert "C4" in str(c)
def test_chord_add_not_implemented():
c = Chord.from_tones("C", "E", "G")
assert c.__add__("not a chord") is NotImplemented
def test_chord_identify_returns_none_for_unknown():
c = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("C#4", system="western"),
Tone.from_string("D4", system="western"),
])
assert c.identify() is None
def test_chord_voice_leading_different_sizes():
"""Voice leading should pad shorter chord."""
c3 = Chord.from_tones("C", "E", "G")
c4 = Chord.from_intervals("C", 4, 7, 10)
vl = c3.voice_leading(c4)
assert len(vl) == 4 # padded to match
def test_chord_analyze_with_tone_key():
"""analyze() should accept a Tone as key_tonic."""
c = Chord.from_tones("C", "E", "G")
key_tone = Tone.from_string("C4", system="western")
assert c.analyze(key_tone) == "I"
def test_chord_analyze_unknown_chord():
c = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("D4", system="western"),
])
assert c.analyze("C") is None
def test_chord_analyze_diminished():
b_dim = Chord.from_intervals("B", 3, 6)
result = b_dim.analyze("C")
assert "dim" in result
def test_chord_analyze_augmented():
c_aug = Chord.from_intervals("C", 4, 8)
result = c_aug.analyze("C")
assert "+" in result
def test_chord_analyze_9th():
c9 = Chord.from_intervals("C", 2, 4, 7, 10)
result = c9.analyze("C")
assert "9" in result
def test_scale_with_system_object():
"""Scale created with system object instead of string."""
from pytheory.scales import Scale
system = SYSTEMS["western"]
s = Scale(tones=(Tone("C", octave=4), Tone("D", octave=4)), system=system)
assert s.system == system
def test_scale_degree_by_mode_name():
major = TonedScale(tonic="C4")["major"]
# Access by mode name should work via degree lookup
tone = major.degree("ionian")
assert tone is not None
def test_scale_getitem_raises():
major = TonedScale(tonic="C4")["major"]
with pytest.raises(KeyError):
major["nonexistent_degree"]
def test_key_with_string_system():
k = Key("C", "major", system="western")
assert k.note_names[0] == "C"
def test_key_detect_returns_none_empty():
assert Key.detect() is None
def test_key_signature_flat_key():
"""F major has one flat (Bb)."""
# F major scale: F G A Bb C D E
# But our system uses sharps, so Bb = A#
sig = Key("F", "major").signature
# The scale uses A# which is sharp notation for Bb
assert sig["sharps"] + sig["flats"] >= 0 # at least runs
def test_key_borrowed_chords_minor():
"""Minor key should borrow from parallel major."""
borrowed = Key("A", "minor").borrowed_chords
assert len(borrowed) > 0
def test_key_parallel_returns_none_for_other_modes():
"""Parallel should return None for non-major/minor modes."""
k = Key("C", "major")
k.mode = "lydian" # force non-standard mode
assert k.parallel is None
def test_key_relative_returns_none_for_other_modes():
k = Key("C", "major")
k.mode = "lydian"
assert k.relative is None
def test_toned_scale_with_string_system():
ts = TonedScale(tonic="Do4", system="arabic")
assert "ajam" in ts.scales
def test_fretboard_fingering_method():
"""Fretboard.fingering should return a Chord."""
fb = Fretboard.guitar()
result = fb.fingering(0, 0, 0, 0, 0, 0)
assert len(result) == 6
def test_charts_muted_string():
"""A chord with no valid fret gets -1 → None."""
from pytheory.charts import NamedChord
nc = NamedChord(tone_name="C", quality="")
fixed = nc.fix_fingering((0, -1, 2))
assert fixed == (0, None, 2)
Generated
+1 -1
View File
@@ -612,7 +612,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.3.2"
version = "0.4.1"
source = { editable = "." }
dependencies = [
{ name = "numeral" },