Compare commits

..

73 Commits

Author SHA1 Message Date
kennethreitz 7e1d9e76bd v0.7.0: Add Fretboard.chord() method for named chord lookups
New `fb.chord("G")` API lets you look up fingerings by chord name
instead of knowing fret positions upfront. Updates all docs to use
REPL-style examples with verified output.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

443 tests.

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

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

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

428 tests passing, no behavior changes.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:44:41 -04:00
kennethreitz 66b216cd08 Add Chord.from_tones, Scale.detect, Nashville numbers, ASCII tab
Chord.from_tones("C", "E", "G") — build chords from note name strings
Scale.detect("C", "D", "E", "F", "G", "A", "B") → ("C", "major", 7)
  Tries all tonics and scale types, returns best match.
Nashville number system:
  key.nashville(1, 4, 5, 1) — Arabic numeral alternative to Roman
  scale.nashville(1, "57") — supports 7th chord suffix
ASCII tab output:
  CHARTS["western"]["Am"].tab(fretboard=Fretboard.guitar())
  Renders fingerings as guitar tab with string names

415 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:38:02 -04:00
kennethreitz f6c110a995 Fix octave 0 bugs: full_name and pitch were wrong for A0, C0, etc.
- full_name used 'if self.octave' which is falsy for octave 0,
  so A0 displayed as "A" instead of "A0"
- pitch() used 'self.octave or 4' which defaulted octave 0 to 4,
  so A0 returned 440 Hz instead of 27.5 Hz

Both now use 'is not None' checks. 404 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:34:54 -04:00
kennethreitz 9da0434774 Add 12/8 time to Rhythm and Meter section
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:31:16 -04:00
kennethreitz 59f69079bf Fix pyproject.toml: move dependencies under [project], add build-system
Dependencies were at the root level instead of under [project] — uv
ignored them entirely. This caused uv.lock to omit pytuning, scipy,
numpy, and sounddevice. API reference pages were blank because
autodoc couldn't import the modules.

Also adds [build-system] so uv treats the project as editable
instead of virtual.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:26:28 -04:00
kennethreitz d8619f2e8f Fix docs CI: use uv sync --all-groups to install project + sphinx
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:23:09 -04:00
kennethreitz db04ab0796 Fix docs CI: install project deps before building with sphinx
uv sync --group docs only installs sphinx, not pytheory's deps
(pytuning, scipy, etc). Autodoc needs to import the package to
generate API reference pages. Now runs uv sync first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:22:12 -04:00
kennethreitz 875f281633 Accept string system names in TonedScale and Key
TonedScale(tonic="Sa4", system="indian") now works — no need to
import SYSTEMS. Same for Key("C", "major", system="blues").

Updated README and all docs to use the cleaner string syntax,
removing 'from pytheory.systems import SYSTEMS' boilerplate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:18:35 -04:00
kennethreitz c53584d733 Add key detection, tone properties, instrument listing
Key.detect("C", "E", "G") → <Key C major>
  Tries all major/minor keys, returns best match.
  Prefers major when tied with relative minor.

Tone properties:
  tone.is_natural → True for C, D, E, F, G, A, B
  tone.is_sharp   → True for C#, F#, etc.
  tone.is_flat    → True for Bb, Eb, etc.

Fretboard.INSTRUMENTS — list of all 25 preset names.

402 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:15:22 -04:00
kennethreitz 7153dc908f Add Chord.from_name, Interval constants, PROGRESSIONS, enharmonic, fix API docs
New features:
- Chord.from_name("Am7") — build chords from chart names
- Chord.__str__() — prints "C major" instead of raw tones
- Interval constants: Interval.PERFECT_FIFTH, MAJOR_THIRD, OCTAVE, etc.
- PROGRESSIONS dict: 8 common progressions as Roman numeral tuples
  ("I-V-vi-IV", "ii-V-I", "12-bar blues", etc.)
- Tone.enharmonic: C# → "Db", natural notes → None
- Key.__str__(): "C major"

Fix: docs CI now installs all dependencies (uv sync --group docs)
so autodoc can import pytheory modules. API reference pages were
blank on GitHub Pages because pytuning/scipy weren't installed.

New example: examples/explore.py — comprehensive demo of the full API.

393 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:09:16 -04:00
kennethreitz 240d2564a4 Remove logo from README
Removed logo image from README.
2026-03-22 07:03:41 -04:00
kennethreitz fd6ae888cf Add GitHub link and banner to docs sidebar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:02:45 -04:00
kennethreitz c6e3f56136 Add Key class, Note alias, rewrite README
Key class — the friendliest entry point for music theory:
  Key("C", "major").chords → all diatonic triads
  Key("C", "major").seventh_chords → all diatonic 7th chords
  Key("C", "major").progression("I", "V", "vi", "IV")
  Key("C", "major").relative → Key("A", "minor")
  Key("C", "major").parallel → Key("C", "minor")

Note = Tone alias for discoverability.

README rewritten to showcase the full API:
  tones, scales, diatonic harmony, chord analysis,
  6 systems, 25 instruments, audio playback,
  comprehensive feature list.

381 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:00:22 -04:00
kennethreitz 5850ac00d5 Fix docs build warnings: malformed table and short underline
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:57:44 -04:00
kennethreitz 74ce7e39f8 Rewrite fretboard docs: all 25 instruments documented
Organized by family:
- Guitars: standard, 12-string, bass, alternate tunings
- Mandolin family: mandolin, mandola, octave mandolin, mandocello
- Bowed strings: violin, viola, cello, double bass, erhu
- Plucked: ukulele, banjo, harp
- World: oud, sitar, shamisen, pipa, bouzouki, lute, balalaika, charango
- Steel: pedal steel
- Keyboards: piano (88), synth (61), mini (25)

Each section includes tuning details, cultural context, and Wikipedia links.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:55:26 -04:00
kennethreitz d5beab46f2 Add Fretboard.keyboard() for piano/synth controllers
Fretboard.keyboard(keys=88, start="A0") — configurable key count:
- 88 keys = full piano (A0-C8)
- 61 keys = standard synth controller
- 49/37/25 keys = compact MIDI controllers

25 instrument presets total. 368 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:53:47 -04:00
kennethreitz 5e09e64c2d Add 9 world instruments: bouzouki, oud, sitar, shamisen, erhu, charango, pipa, balalaika, lute
World instruments:
- bouzouki (Irish GDAD / Greek CFAD)
- oud (Arabic, 6 courses, fretless)
- sitar (Indian, 7 main strings)
- shamisen (Japanese, 3 strings, honchoshi tuning)
- erhu (Chinese, 2 strings, bowed)
- charango (Andean, 5 courses, re-entrant)
- pipa (Chinese lute, 4 strings)
- balalaika (Russian, 3 strings, 2 in unison)
- lute (Renaissance, 6 courses)

24 instrument presets total. 365 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:52:39 -04:00
kennethreitz 8bf716d1db Add full mandolin family, double bass, harp, pedal steel
Complete mandolin family (all tuned in fifths):
- mandolin     E5 A4 D4 G3 (= violin)
- mandola      A4 D4 G3 C3 (= viola, a 5th lower)
- octave_mandolin  E4 A3 D3 G2 (octave below mandolin)
- mandocello   A3 D3 G2 C2 (= cello)

New instruments:
- double_bass   G2 D2 A1 E1 (tuned in 4ths, unlike bowed strings)
- harp          47 strings, C1 to G7 (concert pedal harp)
- pedal_steel   10 strings, E9 Nashville tuning

15 instrument presets total. 355 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:50:02 -04:00
kennethreitz fcfba20ec5 Add mandolin, mandola, violin, viola, cello, banjo, 12-string presets
New Fretboard presets:
- Fretboard.mandolin()  — E5 A4 D4 G3 (tuned in 5ths)
- Fretboard.mandola()   — A4 D4 G3 C3 (octave below mandolin)
- Fretboard.violin()    — E5 A4 D4 G3 (same as mandolin)
- Fretboard.viola()     — A4 D4 G3 C3 (5th below violin)
- Fretboard.cello()     — A3 D3 G2 C2 (octave below viola)
- Fretboard.banjo(tuning) — open G, open D, double C
- Fretboard.twelve_string() — 12-string guitar (6 doubled courses)

Updated fretboard docs with string family section and custom
instrument examples (mandola, baritone ukulele, upright bass).

347 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:48:25 -04:00
kennethreitz 858f0c1c06 Add inversions, from_frequency, from_midi, harmonize, progressions
Tone:
- Tone.from_frequency(440) → <Tone A4> (nearest 12-TET note)
- Tone.from_midi(60) → <Tone C4> (MIDI note number conversion)

Chord:
- chord.inversion(1) → first inversion (root up an octave)
- chord.inversion(2) → second inversion
- Inversions preserve chord identity

Scale:
- scale.seventh(0) → Imaj7 seventh chord from scale degree
- scale.harmonize() → all diatonic triads
  ['C major', 'D minor', 'E minor', 'F major', 'G major', 'A minor', 'B diminished']
- scale.progression("I", "V", "vi", "IV") → chord progression from
  Roman numerals, with "7" suffix for seventh chords

339 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:45:19 -04:00
kennethreitz 4b9534eb9b v0.3.0: interval naming, MIDI, transpose, chord root/quality, clean API
New features:
- Tone.interval_to(other): "perfect 5th", "major 3rd", "octave"
- Tone.midi: MIDI note number (C4=60, A4=69)
- Tone.transpose(n): alias for tone arithmetic
- Chord.transpose(n): shift all tones, preserving quality
- Chord.root: identify the root tone
- Chord.quality: identify the chord quality ("major", "minor 7th")
- Scale.transpose(n): shift to a new key

Cleanup:
- __version__ = "0.3.0"
- __all__ defined — no more ceil/floor leaking
- pyproject.toml: author, license, classifiers, project URLs

319 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:42:31 -04:00
kennethreitz fdb724f788 Fix RST inline markup: remove bold wrapping around links
RST cannot nest inline markup — **`link <url>`_** renders the raw
markup instead of a clickable link. Removed all 37 instances across
5 guide pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:39:58 -04:00
kennethreitz 245a8a1257 Add Wikipedia links throughout docs, showcase dissonance API, update copyright
- Link key music theory terms to Wikipedia across all guide pages
- Physics of Consonance section now demos .harmony, .dissonance,
  .tension, and .beat_frequencies with code examples
- Copyright updated to 2026

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:35:40 -04:00
kennethreitz 4ab8be49a5 Add Music Theory Fundamentals page and deepen theory throughout docs
New: Music Theory Fundamentals guide covering:
- Sound and pitch (frequency ranges, logarithmic perception)
- Why twelve notes (harmonic series, Pythagorean comma)
- Intervals as atoms of music (size, quality, perfect vs major/minor)
- Keys and key signatures (sharp/flat key tables, FCGDAEB mnemonic)
- Functional harmony (tonic/subdominant/dominant, T-S-D-T)
- The dominant seventh (leading tone, tritone resolution)
- Rhythm and meter (4/4, 3/4, 6/8, odd meters)
- Physics of consonance (waveform alignment, cultural context)

Enriched existing pages:
- Tones: overtone series table, enharmonic equivalents and spelling rules
- Scales: 12-bar blues, parallel major/minor, borrowed chords,
  more progression examples with song references
- Chords: inversions (root/1st/2nd/3rd), extended chords (9ths/11ths/13ths)

Also: add Gauges analytics tracking to all pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:29:16 -04:00
kennethreitz 427a3fc3b1 Add Tone.circle_of_fifths() and Tone.circle_of_fourths()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:26:15 -04:00
kennethreitz c3f9e98332 Add chord identification, voice leading, harmonic analysis, tension, overtones
New features:
- Chord.identify(): name any chord (17 patterns — triads, 7ths, 9ths, sus, power)
- Chord.voice_leading(other): find smoothest voice motion between chords
- Chord.analyze(key): Roman numeral analysis (I, ii, V7, etc.)
- Chord.tension: score with tritone count, dominant function detection
- Tone.overtones(n): harmonic series frequencies

Also:
- Rewrite README with all current features and docs link
- Add all new features to chords guide with music theory context
- Remove logo from docs sidebar
- 296 tests passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:25:31 -04:00
kennethreitz e1c2ef03d7 Comprehensive music theory documentation pass
Every guide page rewritten with deep music theory content:

- Tones: scientific pitch notation, frequency/pitch relationship,
  temperament history (equal/Pythagorean/meantone), interval table
  with song examples, circle of fifths
- Scales: interval pattern construction, major/minor/harmonic minor
  theory, all 7 modes with character descriptions and song references,
  scale degree names and functions, diatonic harmony and common
  chord progressions (I-IV-V, I-V-vi-IV, ii-V-I)
- Chords: triad and seventh chord construction tables, all 12 chord
  qualities with interval formulas, consonance/dissonance theory
  (Pythagoras to Plomp-Levelt), beat frequency perceptual ranges
- Fretboard: how frets work, string interval explanation, reading
  fingering notation, 8 alternate tunings with musical context,
  custom instrument examples (banjo, mandolin)
- Playback: waveform physics (harmonics, Fourier), temperament
  listening guide
- Quickstart: updated feature list (6 systems, 40+ scales, 144 chords)
- Fix duplicate logo/title in sidebar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:20:01 -04:00
kennethreitz 21cd99425b Add Blues/Pentatonic and Javanese Gamelan systems
Blues: major/minor pentatonic, blues scale, major blues, dominant,
  minor (Dorian). The foundational scales of blues, rock, and jazz.

Gamelan: slendro (5-tone equidistant), pelog (7-tone with 3 pathet
  subsets: nem, barang, lima). 12-TET approximations of Javanese
  gamelan tuning with traditional tone names (ji, ro, lu, pat, mo,
  nem, pi/barang).

Total systems: 6 (western, indian, arabic, japanese, blues, gamelan)
277 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:15:11 -04:00
kennethreitz 69ddb1eb64 Rewrite chord analysis with musically accurate models
intervals: now returns semitone counts (integers) instead of Hz
  differences — octave-invariant and musically meaningful

harmony: frequency ratio simplicity model — reduces each pairwise
  frequency ratio to simplest form and scores by 1/(num+denom).
  Simple ratios (octave 2:1, fifth 3:2) score highest.

dissonance: Plomp-Levelt roughness with Bark-scale critical bandwidth
  (Zwicker & Terhardt 1980). Models sensory roughness from interfering
  fundamentals — peaks when freq difference ≈ critical bandwidth.

beat_frequencies: new property returning all pairwise beat frequencies
  as sorted (tone, tone, hz) tuples

beat_pulse: returns smallest non-zero beat frequency (most perceptible)

All properties have detailed docstrings with psychoacoustic references,
perceptual ranges, and usage examples.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:13:05 -04:00
kennethreitz 23c17589eb Simplify installation instructions in docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:07:57 -04:00
kennethreitz e09dfe70b8 Remove redundant title below logo in docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:07:31 -04:00
kennethreitz cceac40a88 Add Arabic and Japanese systems, guitar tuning presets, logo, systems docs
New systems:
- Arabic (Maqam): 10 maqamat (ajam, hijaz, nahawand, nikriz, saba, etc.)
  with Arabic solfège tone names (Do, Re, Mi, Fa, Sol, La, Si)
- Japanese: 6 pentatonic scales (hirajoshi, in, yo, iwato, kumoi, insen)
  and 2 heptatonic scales (ritsu, ryo)

Fretboard improvements:
- Fretboard.guitar() now accepts tuning parameter
- Built-in tunings: standard, drop d, open g, open d, open e, open a,
  dadgad, half step down
- Custom tuning via tuple: Fretboard.guitar(("E4", "B3", ...))
- Fretboard.bass(five_string=True) for 5-string bass

Docs:
- Add Musical Systems guide page with all 4 systems
- Add logo to docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:06:17 -04:00
kennethreitz da08d30e8d Add Indian classical (Hindustani) music system with 10 thaats
- 12 swaras: Sa, komal Re, Re, komal Ga, Ga, Ma, tivra Ma, Pa, komal Dha, Dha, komal Ni, Ni
- 10 thaats (parent scales): Bilawal, Khamaj, Kafi, Asavari, Bhairavi,
  Kalyan, Bhairav, Poorvi, Marwa, Todi
- Extends generate_scale to accept direct interval patterns
- Sa maps to C's pitch position (Sa4 = 261.63 Hz)
- 17 new tests for Indian system

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:00:00 -04:00
kennethreitz e79b3ac15e Add CNAME for pytheory.kennethreitz.org
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 05:53:59 -04:00
kennethreitz cb3fd4526a Fix docs build: mock sounddevice for environments without PortAudio
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 05:52:50 -04:00
kennethreitz 7229399135 Add GitHub Pages deployment for Sphinx docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 05:51:55 -04:00
kennethreitz 0d740cd6fe Merge pull request #35 from kennethreitz/improvements
Fix music theory accuracy and core bugs
2026-03-22 05:49:33 -04:00
44 changed files with 9639 additions and 510 deletions
+4 -1
View File
@@ -25,8 +25,11 @@ jobs:
- name: Set up Python
run: uv python install 3.13
- name: Install dependencies
run: uv sync --all-groups
- name: Build docs
run: uv run --group docs sphinx-build -b html docs docs/_build/html
run: uv run sphinx-build -b html docs docs/_build/html
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
+161 -38
View File
@@ -1,57 +1,180 @@
# PyTheory: Music Theory for Humans
This (work in progress) library attempts to make exploring music theory approachable to humans.
This library makes exploring music theory approachable and fun, treating Python as a musical instrument.
![logo](https://github.com/kennethreitz/pytheory/raw/master/ext/pytheory-small.png)
## Installation
## True Scale -> Pitch Evaluation
```
$ pip install pytheory
```
## Tones
```pycon
>>> from pytheory import Tone
>>> c4 = Tone.from_string("C4", system="western")
>>> c4.frequency
261.63
>>> c4 + 7 # perfect fifth
<Tone G4>
>>> c4.interval_to(c4 + 7)
'perfect 5th'
>>> c4.midi
60
>>> Tone.from_frequency(440)
<Tone A4>
>>> Tone.from_midi(69)
<Tone A4>
```
## Scales and Modes
```pycon
>>> from pytheory import TonedScale
>>> c_minor = TonedScale(tonic='C4')['minor']
>>> c_major = TonedScale(tonic="C4")["major"]
>>> c_major.note_names
['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
>>> c_minor
<Scale I=C4 II=D4 III=Eb4 IV=F4 V=G4 VI=Ab4 VII=Bb5 VIII=C5>
>>> c_minor[0].pitch()
523.251130601197
>>> c_minor["I"].pitch(symbolic=True)
440*2**(1/4)
>>> c_minor["tonic"].pitch(temperament='pythagorean', symbolic=True)
14080/27
>>> TonedScale(tonic="C4")["dorian"].note_names
['C', 'D', 'D#', 'F', 'G', 'A', 'A#', 'C']
```
## Audibly play a note (or chord)
>>> from pytheory import play
play(c_minor[0], t=1_000)
## Chord Fingerings for Custom Tunings
## Diatonic Harmony
```pycon
>>> from pytheory import Tone, Fretboard, CHARTS
>>> c_major.triad(0).identify()
'C major'
>>> tones = (
... Tone.from_string("F2"),
... Tone.from_string("C3"),
... Tone.from_string("G3"),
... Tone.from_string("D4"),
... Tone.from_string("A5"),
... Tone.from_string("E5")
... )
>>> c_major.seventh(4).identify()
'G dominant 7th'
>>> fretboard = Fretboard(tones=tones)
>>>
>>> c_chord = CHARTS['western']["C"]
>>> [c.identify() for c in c_major.harmonize()]
['C major', 'D minor', 'E minor', 'F major', 'G major', 'A minor', 'B diminished']
>>> print(c_chord.fingering(fretboard=fretboard))
(0, 0, 0, 3, 3, 3)
>>> [c.identify() for c in c_major.progression("I", "V", "vi", "IV")]
['C major', 'G major', 'A minor', 'F major']
```
It can also [generate charts for all known chords](https://gist.github.com/kennethreitz/b363660145064fc330c206294cff92fc) for any instrument (accuracy to be determined!).
## Keys and Progressions
✨🍰✨
```pycon
>>> from pytheory import Key
>>> key = Key("G", "major")
>>> key.chords
['G major', 'A minor', 'B minor', 'C major', 'D major', 'E minor', 'F# diminished']
>>> [c.identify() for c in key.progression("I", "V", "vi", "IV")]
['G major', 'D major', 'E minor', 'C major']
>>> Key.detect("C", "E", "G", "A", "D")
<Key C major>
```
## Chord Analysis
```pycon
>>> from pytheory import Chord, Tone
>>> C4 = Tone.from_string("C4", system="western")
>>> G4 = Tone.from_string("G4", system="western")
>>> g7 = Chord([G4, G4+4, G4+7, G4+10])
>>> g7.identify()
'G dominant 7th'
>>> g7.analyze("C")
'V7'
>>> g7.tension
{'score': 0.6, 'tritones': 1, 'minor_seconds': 0, 'has_dominant_function': True}
>>> g7.transpose(-7).identify()
'C dominant 7th'
```
## Six Musical Systems
```pycon
>>> from pytheory import TonedScale
>>> TonedScale(tonic="Sa4", system="indian")["bhairav"].note_names
['Sa', 'komal Re', 'Ga', 'Ma', 'Pa', 'komal Dha', 'Ni', 'Sa']
>>> TonedScale(tonic="Do4", system="arabic")["hijaz"].note_names
['Do', 'Reb', 'Mi', 'Fa', 'Sol', 'Solb', 'Sib', 'Do']
>>> TonedScale(tonic="C4", system="japanese")["hirajoshi"].note_names
['C', 'D', 'D#', 'G', 'G#', 'C']
>>> TonedScale(tonic="C4", system="blues")["blues"].note_names
['C', 'D#', 'F', 'F#', 'G', 'A#', 'C']
```
## 25 Instrument Presets
```pycon
>>> from pytheory import Fretboard, CHARTS
>>> Fretboard.guitar() # standard tuning
>>> Fretboard.guitar("drop d") # 8 alternate tunings
>>> Fretboard.mandolin() # + mandola, octave mandolin, mandocello
>>> Fretboard.violin() # + viola, cello, double bass
>>> Fretboard.ukulele() # + banjo, harp, charango, erhu...
>>> Fretboard.keyboard() # 88-key piano
>>> Fretboard.keyboard(25, "C3") # 25-key MIDI controller
>>> CHARTS['western']['Am'].fingering(fretboard=Fretboard.guitar())
Fingering(e=0, B=1, G=2, D=2, A=0, E=0)
>>> Fretboard.guitar().fingering(0, 1, 0, 2, 3, 0).identify()
'C major'
```
## Audio Playback
```pycon
>>> from pytheory import play, Synth, Tone
>>> tone = Tone.from_string("A4", system="western")
>>> play(tone, t=1_000) # sine wave, 1 second
>>> play(tone, synth=Synth.SAW, t=1_000) # sawtooth wave
>>> from pytheory import save, Chord
>>> save(Chord.from_name("Am7"), "am7.wav", t=2_000) # save to WAV
```
## Command-Line Interface
```
$ pytheory tone A4 # frequency, MIDI, overtones
$ pytheory chord C E G # identify chord from notes
$ pytheory key G major # explore a key
$ pytheory scale C dorian # show a scale
$ pytheory fingering Am --capo 2 # guitar fingering
$ pytheory progression C major I V vi IV # build a progression
$ pytheory detect C E G A D # detect key from notes
$ pytheory play Am7 --synth triangle # play a chord
```
## Features
- **6 musical systems**: Western, Indian (Hindustani), Arabic (Maqam), Japanese, Blues/Pentatonic, Javanese Gamelan
- **40+ scales**: major, minor, harmonic minor, 7 modes, 10 thaats, 10 maqamat, pentatonic, blues, hirajoshi, pelog, slendro, and more
- **Chord analysis**: identification (17 types), Roman numeral analysis, tension scoring, voice leading, Plomp-Levelt dissonance, beat frequencies
- **Diatonic harmony**: triads, seventh chords, harmonize entire scales, build progressions from Roman numerals
- **25 instrument presets**: guitar (8 tunings), 12-string, bass, mandolin family, violin family, banjo, harp, oud, sitar, shamisen, erhu, charango, pipa, balalaika, lute, pedal steel, keyboard
- **Pitch tools**: frequency ↔ tone conversion, MIDI ↔ tone, interval naming, circle of fifths, overtone series, transposition
- **3 temperaments**: equal, Pythagorean, quarter-comma meantone
- **Audio synthesis**: sine, sawtooth, and triangle wave playback + WAV export
## Documentation
Full documentation with music theory guides: **[pytheory.kennethreitz.org](https://pytheory.kennethreitz.org)**
+1
View File
@@ -0,0 +1 @@
pytheory.kennethreitz.org
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+18
View File
@@ -0,0 +1,18 @@
{% extends "!layout.html" %}
{% block footer %}
{{ super() }}
<script type="text/javascript">
var _gauges = _gauges || [];
(function() {
var t = document.createElement('script');
t.type = 'text/javascript';
t.async = true;
t.id = 'gauges-tracker';
t.setAttribute('data-site-id', '69bfc431e7e47c1200fc74bc');
t.setAttribute('data-track-path', 'https://track.gaug.es/track.gif');
t.src = 'https://d2fuc4clr7gvcn.cloudfront.net/track.js';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(t, s);
})();
</script>
{% endblock %}
+22 -3
View File
@@ -1,12 +1,18 @@
import os
import sys
from unittest.mock import MagicMock
sys.path.insert(0, os.path.abspath(".."))
# Mock sounddevice so Sphinx can import pytheory.play without PortAudio
sys.modules["sounddevice"] = MagicMock()
project = "PyTheory"
copyright = "2024, Kenneth Reitz"
copyright = "2026, Kenneth Reitz"
author = "Kenneth Reitz"
release = "0.2.0"
import pytheory
release = pytheory.__version__
version = pytheory.__version__
extensions = [
"sphinx.ext.autodoc",
@@ -30,5 +36,18 @@ templates_path = ["_templates"]
exclude_patterns = ["_build"]
html_theme = "alabaster"
html_title = "PyTheory"
html_theme_options = {
"github_user": "kennethreitz",
"github_repo": "pytheory",
"github_banner": True,
"github_button": True,
"github_type": "star",
"github_count": True,
"description": "Music Theory for Humans",
"extra_nav_links": {
f"v{pytheory.__version__}": "https://pypi.org/project/pytheory/",
},
"show_powered_by": False,
}
html_static_path = ["_static"]
html_extra_path = ["CNAME"]
+388 -35
View File
@@ -1,19 +1,93 @@
Working with Chords
===================
Chords and Chord Charts
-----------------------
A `chord <https://en.wikipedia.org/wiki/Chord_(music)>`_ is two or more tones sounding simultaneously. Chords are the
vertical dimension of music — while melody moves horizontally through
time, harmony stacks tones on top of each other.
PyTheory provides two chord-related classes:
Chord Construction
------------------
- :class:`~pytheory.chords.Chord` — a collection of tones played together
- :class:`~pytheory.charts.NamedChord` — a chord from the chart database with
fingering support
Chords are built by stacking **intervals** above a **root** note. The
most common chord type is the `triad <https://en.wikipedia.org/wiki/Triad_(music)>`_ — three notes built from
alternating scale degrees (root, 3rd, 5th).
The four triad types::
Major root + major 3rd (4) + perfect 5th (7) Bright, stable
Minor root + minor 3rd (3) + perfect 5th (7) Dark, sad
Diminished root + minor 3rd (3) + diminished 5th (6) Tense, unstable
Augmented root + major 3rd (4) + augmented 5th (8) Eerie, unresolved
Adding a 7th creates a `seventh chord <https://en.wikipedia.org/wiki/Seventh_chord>`_ — the foundation of jazz
harmony::
Dominant 7th root + 4 + 7 + 10 Bluesy, wants to resolve (G7)
Major 7th root + 4 + 7 + 11 Dreamy, sophisticated (Cmaj7)
Minor 7th root + 3 + 7 + 10 Warm, mellow (Am7)
Diminished 7th root + 3 + 6 + 9 Dramatic, symmetrical
Inversions
----------
A chord is in **root position** when the root is the lowest note.
When a different chord tone is in the bass, the chord is `inverted <https://en.wikipedia.org/wiki/Inversion_(music)>`_:
- **Root position**: C E G (root in bass)
- **First inversion**: E G C (3rd in bass) — notated C/E
- **Second inversion**: G C E (5th in bass) — notated C/G
Inversions change the color and weight of a chord without changing its
identity. First inversion sounds lighter; second inversion sounds
suspended, often used as a passing chord.
For seventh chords, there's also **third inversion** (7th in bass):
- G7 in third inversion: F G B D (notated G7/F)
.. code-block:: python
from pytheory import Chord, Tone
# All three are "C major" — identify() finds the root
root = Chord([Tone.from_string(n, system="western") for n in ["C4", "E4", "G4"]])
first = Chord([Tone.from_string(n, system="western") for n in ["E3", "G3", "C4"]])
second = Chord([Tone.from_string(n, system="western") for n in ["G3", "C4", "E4"]])
root.identify() # 'C major'
first.identify() # 'C major'
second.identify() # 'C major'
Extended Chords
---------------
Beyond seventh chords, jazz harmony builds `extended chords <https://en.wikipedia.org/wiki/Extended_chord>`_ by
continuing to stack thirds:
- **9th chord**: adds the 9th (= 2nd, one octave up)
- **11th chord**: adds the 9th and 11th (= 4th)
- **13th chord**: adds the 9th, 11th, and 13th (= 6th)
A full 13th chord contains all 7 notes of the scale! In practice,
tones are usually omitted — the 5th is typically dropped first, then
the 11th (which clashes with the 3rd in dominant chords).
.. code-block:: python
from pytheory import TonedScale
scale = TonedScale(tonic="C4")["major"]
# Build a Cmaj9 from the scale: C E G B D
cmaj9 = scale.chord(0, 2, 4, 6, 8)
# Build a full C13 (in theory): C E G B D F A
c13 = scale.chord(0, 2, 4, 6, 8, 10, 12)
Using the Chord Chart
---------------------
The built-in chart contains 144 chords (12 roots x 12 qualities):
PyTheory includes 144 pre-built chords (12 roots x 12 qualities):
.. code-block:: python
@@ -21,63 +95,342 @@ The built-in chart contains 144 chords (12 roots x 12 qualities):
chart = CHARTS["western"]
# Access a chord
c_major = chart["C"]
a_minor = chart["Am"]
g_seven = chart["G7"]
c_major = chart["C"] # C major (root position)
a_minor = chart["Am"] # A minor
g_seven = chart["G7"] # G dominant 7th
d_dim = chart["Ddim"] # D diminished
# Available qualities: "", "maj", "m", "5", "7", "9",
# "dim", "m6", "m7", "m9", "maj7", "maj9"
Available qualities:
Chord Tones
-----------
Each named chord knows which tones it contains:
============ ================ ================================
Quality Intervals Example tones (from C)
============ ================ ================================
``""`` 4, 7 C E G (major triad)
``"maj"`` 4, 7 C E G (explicit major)
``"m"`` 3, 7 C Eb G (minor triad)
``"5"`` 7 C G (power chord)
``"7"`` 4, 7, 10 C E G Bb (dominant 7th)
``"9"`` 4, 7, 10, 14 C E G Bb D (dominant 9th)
``"dim"`` 3, 6 C Eb Gb (diminished)
``"m6"`` 3, 7, 9 C Eb G A (minor 6th)
``"m7"`` 3, 7, 10 C Eb G Bb (minor 7th)
``"m9"`` 3, 7, 10, 14 C Eb G Bb D (minor 9th)
``"maj7"`` 4, 7, 11 C E G B (major 7th)
``"maj9"`` 4, 7, 11, 14 C E G B D (major 9th)
============ ================ ================================
.. code-block:: python
>>> chart["C"].acceptable_tone_names
('C', 'E', 'G')
>>> chart["Am"].acceptable_tone_names
('A', 'C', 'E')
>>> chart["Cm7"].acceptable_tone_names
('C', 'D#', 'G', 'A#') # Eb and Bb shown as sharps
>>> chart["G7"].acceptable_tone_names
('G', 'B', 'D', 'F')
Building Chords
---------------
Building Chords Manually
-------------------------
Several convenience constructors make chord creation concise:
.. code-block:: python
from pytheory import Tone, Chord
from pytheory import Chord
# From note names (simplest)
Chord.from_tones("C", "E", "G") # <Chord C major>
Chord.from_tones("A", "C", "E") # <Chord A minor>
# From a chord name (uses the built-in chart)
Chord.from_name("Am7") # <Chord A minor 7th>
Chord.from_name("G7") # <Chord G dominant 7th>
# From root + semitone intervals
Chord.from_intervals("C", 4, 7) # <Chord C major>
Chord.from_intervals("D", 3, 7) # <Chord D minor>
Chord.from_intervals("G", 4, 7, 10) # <Chord G dominant 7th>
# From MIDI note numbers
Chord.from_midi_message(60, 64, 67) # <Chord C major>
# Full manual construction
from pytheory import Tone
c_major = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
])
# Iteration
for tone in c_major:
print(tone)
len(c_major) # 3
"C" in c_major # True
Chord Properties
----------------
Intervals
---------
The ``intervals`` property returns semitone distances between adjacent
tones — these are musically meaningful and octave-invariant:
.. code-block:: python
# Frequency intervals between adjacent tones (Hz)
c_major.intervals
>>> c_major.intervals
[4, 3] # major 3rd (4) + minor 3rd (3) = major triad
# Harmony score (higher = more consonant intervals)
c_major.harmony
>>> Chord(tones=[C4, Eb4, G4]).intervals
[3, 4] # minor 3rd + major 3rd = minor triad
# Dissonance score (higher = wider intervals)
c_major.dissonance
Consonance and Dissonance
-------------------------
# Beat frequency between closest tone pair
c_major.beat_pulse
**Consonance** is the perception of stability and "pleasantness" when
tones sound together. **Dissonance** is the perception of tension and
roughness. Neither is inherently good or bad — music needs both.
Harmony Score
~~~~~~~~~~~~~
The ``harmony`` property measures consonance using **frequency ratio
simplicity**. The insight dates back to Pythagoras (6th century BC):
intervals whose frequencies form simple integer ratios sound consonant.
=========== ===== ====================
Interval Ratio Why it sounds "good"
=========== ===== ====================
Octave 2:1 Every 2nd wave aligns
Perfect 5th 3:2 Every 3rd wave aligns
Perfect 4th 4:3 Every 4th wave aligns
Major 3rd 5:4 Every 5th wave aligns
Minor 3rd 6:5 Every 6th wave aligns
Tritone 45:32 Waves rarely align
=========== ===== ====================
.. code-block:: python
fifth = Chord([C4, G4])
tritone = Chord([C4, F_sharp_4])
fifth.harmony > tritone.harmony # True
# The perfect fifth's 3:2 ratio scores higher
Dissonance Score
~~~~~~~~~~~~~~~~
The ``dissonance`` property uses the Plomp-Levelt `roughness <https://en.wikipedia.org/wiki/Roughness_(psychoacoustics)>`_ model
(1965). When two frequencies are close together, their sound waves
interfere and produce rapid amplitude fluctuations called `beating <https://en.wikipedia.org/wiki/Beat_(acoustics)>`_.
This beating is perceived as roughness — the physiological basis of
dissonance.
The roughness depends on the frequency difference relative to the
**critical bandwidth** of the human ear (~25% of the frequency at
that register). Maximum roughness occurs when the difference equals
the critical bandwidth.
.. code-block:: python
# Octave: frequencies far apart → low roughness
octave = Chord([C4, C5])
# Major 3rd: closer frequencies → higher roughness
third = Chord([C4, E4])
octave.dissonance < third.dissonance # True
Beat Frequencies
~~~~~~~~~~~~~~~~
When two tones with slightly different frequencies are played together,
you hear a pulsing at the **beat frequency**: ``|f1 - f2|`` Hz.
- **< 1 Hz**: Slow pulsing, used for tuning instruments
- **115 Hz**: Audible rhythmic beating
- **1530 Hz**: Perceived as buzzing/roughness
- **> 30 Hz**: No longer beating — becomes part of the timbre
.. code-block:: python
chord = Chord(tones=[A4, E5, A5])
# All pairwise beat frequencies, sorted ascending
chord.beat_frequencies
# [(A4, E5, 189.6), (E5, A5, 220.0), (A4, A5, 440.0)]
# The slowest (most perceptible) beat
chord.beat_pulse # 189.6 Hz
Transposition
-------------
Shift an entire chord up or down by any number of semitones:
.. code-block:: python
>>> Chord.from_name("C").transpose(7).identify()
'G major'
>>> Chord.from_name("Am7").transpose(-2).identify()
'G minor 7th'
Chord Manipulation
------------------
Add or remove individual tones from a chord:
.. code-block:: python
from pytheory import Chord, Tone
c_major = Chord.from_tones("C", "E", "G")
# Add a tone to build a seventh chord
b4 = Tone.from_string("B4", system="western")
cmaj7 = c_major.add_tone(b4)
cmaj7.identify() # 'C major 7th'
# Remove a tone
c_again = cmaj7.remove_tone("B")
c_again.identify() # 'C major'
Chord Identification
--------------------
Give PyTheory any set of tones and it will tell you what chord it is.
It tries every tone as a potential root and matches the interval pattern
against 17 known chord types (triads, 7ths, 9ths, sus, power chords).
.. code-block:: python
from pytheory import Chord
# From note names
Chord.from_tones("A", "C", "E").identify() # 'A minor'
Chord.from_tones("G", "B", "D", "F").identify() # 'G dominant 7th'
# Works with any voicing or inversion
Chord.from_tones("E", "G", "C").identify() # 'C major'
# Flats work too
Chord.from_tones("Bb", "D", "F").identify() # 'Bb major'
You can also access the root and quality separately:
.. code-block:: python
chord = Chord.from_name("Am7")
chord.root # <Tone A4>
chord.quality # 'minor 7th'
Harmonic Analysis
-----------------
`Roman numeral analysis <https://en.wikipedia.org/wiki/Roman_numeral_analysis>`_ labels each chord by its function within a
key. This is how musicians describe chord progressions independent of
key — "I-IV-V" means the same thing in C major (C-F-G) as in G major
(G-C-D).
.. code-block:: python
from pytheory import Chord, Tone
C4 = Tone.from_string("C4", system="western")
D4 = Tone.from_string("D4", system="western")
E4 = Tone.from_string("E4", system="western")
F4 = Tone.from_string("F4", system="western")
G4 = Tone.from_string("G4", system="western")
A4 = Tone.from_string("A4", system="western")
B4 = Tone.from_string("B4", system="western")
Chord([C4, E4, G4]).analyze("C") # 'I' (tonic)
Chord([D4, F4, A4]).analyze("C") # 'ii' (supertonic minor)
Chord([G4, B4, G4+5]).analyze("C") # 'V' (dominant)
Chord([G4, B4, G4+5, G4+10]).analyze("C") # 'V7' (dominant 7th)
Tension and Resolution
----------------------
**Tension** is what makes music move forward. Without it, there's no
desire to resolve — no drama, no narrative. The ``tension`` property
quantifies this based on:
- **Tritones** (6 semitones): the most unstable interval. The tritone
between the 3rd and 7th of a dominant chord (e.g. B and F in G7)
creates the strongest pull toward resolution.
- **Minor 2nds**: semitone clashes that add bite and urgency.
- **Dominant function**: the specific combination of a major 3rd and
minor 7th above the root — the hallmark of the V7 chord.
.. code-block:: python
# A C major triad is fully resolved — no tension
c_major = Chord([C4, E4, G4])
c_major.tension['score'] # 0.0
c_major.tension['tritones'] # 0
# G7 is loaded with tension — it wants to resolve to C
g7 = Chord([G4, B4, G4+5, G4+10])
g7.tension['score'] # 0.6
g7.tension['tritones'] # 1
g7.tension['has_dominant_function'] # True
Voice Leading
-------------
`Voice leading <https://en.wikipedia.org/wiki/Voice_leading>`_ is the art of connecting chords smoothly. Instead of
jumping all voices to new positions, good voice leading moves each note
the minimum distance to reach the next chord. Bach's chorales are the
gold standard — every voice moves by step whenever possible.
.. code-block:: python
c_maj = Chord([C4, E4, G4])
f_maj = Chord([F4, A4, C4+12])
for src, dst, motion in c_maj.voice_leading(f_maj):
print(f"{src} -> {dst} ({motion:+d} semitones)")
# Each voice moves the minimum distance to reach the target chord
Tritone Substitution
--------------------
In jazz harmony, any `dominant chord <https://en.wikipedia.org/wiki/Dominant_seventh_chord>`_
can be replaced by the dominant chord a
`tritone <https://en.wikipedia.org/wiki/Tritone_substitution>`_ (6
semitones) away. This works because the two chords share the same
tritone interval — the 3rd and 7th simply swap roles.
Common tritone subs: G7 <-> Db7, C7 <-> F#7, D7 <-> Ab7.
.. code-block:: python
from pytheory import Chord
g7 = Chord.from_name("G7")
sub = g7.tritone_sub()
sub.identify() # 'C# dominant 7th' (enharmonic Db7)
# Both resolve to C — try them in a ii-V-I:
# Dm7 → G7 → Cmaj7 (standard)
# Dm7 → Db7 → Cmaj7 (with tritone sub — chromatic bass line!)
The Overtone Series
-------------------
Every musical tone is actually a stack of frequencies — the
**fundamental** plus its `overtones <https://en.wikipedia.org/wiki/Overtone>`_ (harmonics). The overtone series
is nature's chord: it contains the octave, perfect fifth, perfect
fourth, major third, and more, in that order.
This is *why* consonance exists. When you play C and G together, the
overtones of C already contain G. The two tones share acoustic energy,
reinforcing each other. A dissonant interval like C and C# shares
almost no overtones — the waves clash.
.. code-block:: python
from pytheory import Tone
a4 = Tone.from_string("A4", system="western")
a4.overtones(8)
# [440.0, 880.0, 1320.0, 1760.0, 2200.0, 2640.0, 3080.0, 3520.0]
# A4 A5 E6 A6 C#7 E7 ~G7 A7
# fund. oct. 5th+oct 2oct 3rd 5th ~7th 3oct
+129
View File
@@ -0,0 +1,129 @@
Command-Line Interface
======================
PyTheory includes a CLI for quick music theory lookups from the terminal.
Tone Lookup
-----------
Look up any note's frequency, MIDI number, enharmonic spelling, and
overtones::
$ pytheory tone A4
Note: A4
Frequency: 440.00 Hz (equal temperament)
MIDI: 69
Overtones: 440.0, 880.0, 1320.0, 1760.0, 2200.0, 2640.0
Compare temperaments with ``--temperament``::
$ pytheory tone C5 --temperament pythagorean
Note: C5
Frequency: 521.48 Hz (pythagorean temperament)
Equal temp: 523.25 Hz (diff: -5.9 cents)
Scale Display
-------------
Show any scale in any system::
$ pytheory scale C major
C major: C D E F G A B C
Intervals: C4 -2- D4 -2- E4 -1- F4 -2- G4 -2- A4 -2- B4 -1- C5
$ pytheory scale C dorian
$ pytheory scale Sa bhairav --system indian
Chord Identification
--------------------
Identify a chord from its notes::
$ pytheory chord C E G
Chord: C major
Tones: C4 E4 G4
Intervals: [4, 3]
Harmony: 0.5833
Dissonance: 0.0712
Tension: 0.00 (tritones=0)
$ pytheory chord G B D F
Chord: G dominant 7th
Key Explorer
------------
Get a complete breakdown of any key — signature, diatonic triads,
seventh chords, relative and parallel keys::
$ pytheory key G major
Key: G major
Signature: 1 sharps, 0 flats (F#)
Scale: G A B C D E F#
Triads:
I G major
ii A minor
iii B minor
IV C major
V D major
vi E minor
vii° F# diminished
7th chords:
G major 7th
A minor 7th
...
Relative: <Key E minor>
Parallel: <Key G minor>
Guitar Fingerings
-----------------
Get tablature for any of the 144 built-in chords::
$ pytheory fingering Am
Am
E|--0--
B|--1--
G|--2--
D|--2--
A|--0--
E|--0--
Use ``--capo`` to see fingerings with a capo::
$ pytheory fingering G --capo 2
Chord Progressions
------------------
Build progressions from Roman numerals::
$ pytheory progression G major I V vi IV
Key: G major
Progression: I → V → vi → IV
I G major
V D major
vi E minor
IV C major
Key Detection
-------------
Detect the most likely key from a set of notes::
$ pytheory detect C E G A D
Detected key: C major
Scale: C D E F G A B C
Audio Playback
--------------
Play individual notes or chords (requires PortAudio)::
$ pytheory play A4 # Single note
$ pytheory play C E G # Notes as chord
$ pytheory play Am7 # Chord by name
$ pytheory play C E G --synth saw # Sawtooth wave
$ pytheory play A4 --duration 2000 # 2 seconds
$ pytheory play C E G --temperament meantone
+265 -41
View File
@@ -1,57 +1,262 @@
Fretboard and Fingerings
========================
Instruments and Fingerings
==========================
The :class:`~pytheory.chords.Fretboard` class represents a fretted instrument's
tuning and generates chord fingerings.
The :class:`~pytheory.chords.Fretboard` class models any stringed
instrument and generates chord fingerings. PyTheory includes **25
instrument presets** spanning Western, Asian, Middle Eastern, Latin
American, and Russian traditions.
Preset Tunings
--------------
How It Works
------------
Each `fret <https://en.wikipedia.org/wiki/Fret>`_ on a stringed
instrument raises the pitch by exactly **one semitone**. The open
string is fret 0; fret 1 is one semitone up, and so on. Even fretless
instruments (violin, oud, erhu) can be modeled this way — the "fret"
positions are just semitone steps along the fingerboard.
Guitars
-------
`Standard guitar tuning <https://en.wikipedia.org/wiki/Guitar_tunings>`_
(high to low)::
String 1: E4 (highest)
String 2: B3
String 3: G3
String 4: D3
String 5: A2
String 6: E2 (lowest)
This tuning uses intervals of a perfect 4th (5 semitones) between most
strings, except between G and B which is a major 3rd (4 semitones).
.. code-block:: python
from pytheory import Fretboard
guitar = Fretboard.guitar() # E4 B3 G3 D3 A2 E2
bass = Fretboard.bass() # G2 D2 A1 E1
ukulele = Fretboard.ukulele() # A4 E4 C4 G4
guitar = Fretboard.guitar() # Standard EADGBE
twelve = Fretboard.twelve_string() # 12-string (6 doubled courses)
bass = Fretboard.bass() # Standard 4-string EADG
bass5 = Fretboard.bass(five_string=True) # 5-string with low B
Custom Tunings
--------------
**Alternate tunings** — 8 built-in presets:
.. code-block:: python
from pytheory import Tone, Fretboard
Fretboard.guitar("drop d") # DADGBE — heavy riffs, metal
Fretboard.guitar("open g") # DGDGBD — slide guitar, Keith Richards
Fretboard.guitar("open d") # DADF#AD — slide, folk
Fretboard.guitar("open e") # EBEG#BE — slide blues
Fretboard.guitar("open a") # EAC#EAE
Fretboard.guitar("dadgad") # DADGAD — Celtic, fingerstyle
Fretboard.guitar("half step down") # Eb standard — Hendrix, SRV
# Open D tuning
open_d = Fretboard(tones=[
Tone.from_string("D4"),
Tone.from_string("A3"),
Tone.from_string("F#3"),
Tone.from_string("D3"),
Tone.from_string("A2"),
Tone.from_string("D2"),
])
# Custom tuning with any notes
Fretboard.guitar(("C4", "G3", "C3", "G2", "C2", "G1"))
**Capo** — a `capo <https://en.wikipedia.org/wiki/Capo>`_ raises all
strings by a number of frets, letting you play open chord shapes in
higher keys:
.. code-block:: python
# Capo on fret 2 — open G shape now sounds as A major
fb = Fretboard.guitar(capo=2)
# Or apply a capo to an existing fretboard
fb = Fretboard.guitar()
fb_capo3 = fb.capo(3)
The Mandolin Family
-------------------
The `mandolin family <https://en.wikipedia.org/wiki/Mandolin_family>`_
mirrors the `violin family <https://en.wikipedia.org/wiki/Violin_family>`_
— all tuned in perfect fifths, with each member a fifth or octave
lower than the last:
.. code-block:: python
Fretboard.mandolin() # E5 A4 D4 G3 — soprano (= violin)
Fretboard.mandola() # A4 D4 G3 C3 — alto (= viola)
Fretboard.octave_mandolin() # E4 A3 D3 G2 — tenor (octave below mandolin)
Fretboard.mandocello() # A3 D3 G2 C2 — bass (= cello)
The mandolin's doubled courses (pairs of strings) create a natural
chorus effect. The `octave mandolin <https://en.wikipedia.org/wiki/Octave_mandolin>`_
is popular in Irish and Celtic folk music.
The Bowed String Family
-----------------------
The orchestral `string family <https://en.wikipedia.org/wiki/String_section>`_
is tuned in perfect fifths (except the double bass, which uses fourths):
.. code-block:: python
Fretboard.violin() # E5 A4 D4 G3 — soprano
Fretboard.viola() # A4 D4 G3 C3 — alto (5th below violin)
Fretboard.cello() # A3 D3 G2 C2 — tenor/bass (octave below viola)
Fretboard.double_bass() # G2 D2 A1 E1 — bass (tuned in 4ths!)
Bowed strings have no frets — the player can produce any pitch along
the fingerboard, enabling continuous
`vibrato <https://en.wikipedia.org/wiki/Vibrato>`_ and microtonal
inflections not possible on fretted instruments.
The `erhu <https://en.wikipedia.org/wiki/Erhu>`_ — a 2-stringed Chinese
bowed instrument with a hauntingly vocal quality:
.. code-block:: python
Fretboard.erhu() # A4 D4 — tuned a 5th apart, no fingerboard
Plucked Strings
---------------
.. code-block:: python
Fretboard.ukulele() # A4 E4 C4 G4 — re-entrant tuning
Fretboard.banjo() # Open G (bluegrass, 5th string is high drone)
Fretboard.banjo("open d") # Open D (clawhammer, old-time)
Fretboard.harp() # 47 strings, C1 to G7 (concert pedal harp)
The `banjo <https://en.wikipedia.org/wiki/Banjo>`_'s short 5th string
is a high drone — a defining feature of the instrument's sound.
The `harp <https://en.wikipedia.org/wiki/Harp>`_ has one string per
diatonic note across nearly 7 octaves. Pedals alter each note name
by up to two semitones across all octaves simultaneously.
World Instruments
-----------------
.. code-block:: python
# Middle Eastern
Fretboard.oud() # C4 G3 D3 A2 G2 C2 — fretless, ancestor of the lute
Fretboard.sitar() # 7 main strings — Indian classical
# East Asian
Fretboard.shamisen() # C4 G3 C3 — 3-string Japanese, honchoshi tuning
Fretboard.pipa() # D4 A3 E3 A2 — 4-string Chinese lute
Fretboard.erhu() # A4 D4 — 2-string Chinese bowed
# European
Fretboard.bouzouki() # D4 A3 D3 G2 — Irish (Celtic music)
Fretboard.bouzouki("greek") # D4 A3 F3 C3 — Greek
Fretboard.lute() # G4 D4 A3 F3 C3 G2 — Renaissance (6 courses)
Fretboard.balalaika() # A4 E4 E4 — Russian (2 unison strings)
# Latin American
Fretboard.charango() # E5 A4 E5 C5 G4 — Andean (re-entrant tuning)
# Steel guitar
Fretboard.pedal_steel() # 10 strings, E9 Nashville — country music
The `oud <https://en.wikipedia.org/wiki/Oud>`_ is fretless, allowing
the quarter-tone inflections essential to
`maqam <https://en.wikipedia.org/wiki/Maqam>`_ performance. The
`sitar <https://en.wikipedia.org/wiki/Sitar>`_ has moveable frets and
sympathetic strings that resonate in harmony with the played notes.
Keyboards
---------
.. code-block:: python
Fretboard.keyboard() # 88-key piano (A0 to C8)
Fretboard.keyboard(61, "C2") # 61-key synth controller
Fretboard.keyboard(49, "C2") # 49-key controller
Fretboard.keyboard(25, "C3") # 25-key mini MIDI controller
While keyboards don't have strings or frets, they map naturally to a
sequence of tones. A full 88-key piano spans over 7 octaves — the
widest range of any standard acoustic instrument.
Getting Fingerings
------------------
The fingering algorithm finds the most playable voicing for any chord
on any instrument. It scores each possibility by:
1. Preferring **open strings** (fret 0) — they ring freely
2. Preferring **ascending** fret patterns — easier hand position
3. Minimizing the number of **fingers needed**
.. code-block:: pycon
>>> from pytheory import Fretboard, CHARTS
>>> fb = Fretboard.guitar()
>>> f = fb.chord("C")
>>> f
Fingering(e=0, B=1, G=0, D=2, A=3, E=0)
>>> f['A']
3
>>> f[1]
1
>>> f.identify()
'C major'
>>> chord = f.to_chord()
>>> chord.identify()
'C major'
>>> # All equally-scored fingerings via CHARTS
>>> CHARTS["western"]["C"].fingering(fretboard=fb, multiple=True)
[...]
>>> # Muted strings appear as None
>>> CHARTS["western"]["F"].fingering(fretboard=fb)
...
You can also go from fret positions to chord identification:
.. code-block:: pycon
>>> # "What chord am I playing?"
>>> fb = Fretboard.guitar()
>>> f = fb.fingering(0, 0, 0, 2, 2, 0)
>>> f
Fingering(e=0, B=0, G=0, D=2, A=2, E=0)
>>> f.identify()
'E minor'
Reading Fingerings
~~~~~~~~~~~~~~~~~~
Each position is labeled with its string name. Duplicate string names
are disambiguated — on a standard guitar, high E appears as ``e`` and
low E as ``E``::
e|--0-- (open — E)
B|--1-- (fret 1 — C)
G|--0-- (open — G)
D|--2-- (fret 2 — E)
A|--3-- (fret 3 — C)
E|--0-- (open — E)
A value of ``None`` means the string is muted (not played).
ASCII Tablature
~~~~~~~~~~~~~~~
For a more visual representation, use ``tab()``:
.. code-block:: python
from pytheory import Fretboard, CHARTS
fb = Fretboard.guitar()
# Best fingering for a chord
c = CHARTS["western"]["C"]
print(c.fingering(fretboard=fb))
# (0, 1, 0, 2, 3, 0)
# All possible fingerings
all_c = c.fingering(fretboard=fb, multiple=True)
# Muted strings appear as None
f = CHARTS["western"]["F"]
print(f.fingering(fretboard=fb))
>>> print(CHARTS["western"]["C"].tab(fretboard=fb))
C
E|--0--
B|--1--
G|--0--
D|--2--
A|--3--
E|--0--
Generating Full Charts
----------------------
@@ -68,11 +273,30 @@ Generate fingerings for every chord at once:
for name, fingering in chart.items():
print(f"{name:6s} {fingering}")
Ukulele Example
---------------
# Works with any instrument
uke_chart = charts_for_fretboard(fretboard=Fretboard.ukulele())
mando_chart = charts_for_fretboard(fretboard=Fretboard.mandolin())
Custom Instruments
------------------
Any instrument can be modeled with custom string tunings:
.. code-block:: python
fb = Fretboard.ukulele()
c = CHARTS["western"]["C"]
print(c.fingering(fretboard=fb)) # 4-string fingering
from pytheory import Tone, Fretboard
# Baritone ukulele (DGBE — top 4 guitar strings)
bari_uke = Fretboard(tones=[
Tone.from_string("E4"),
Tone.from_string("B3"),
Tone.from_string("G3"),
Tone.from_string("D3"),
])
# Tres cubano (Cuban guitar, 3 doubled courses)
tres = Fretboard(tones=[
Tone.from_string("E4"),
Tone.from_string("B3"),
Tone.from_string("G3"),
])
+54 -15
View File
@@ -1,7 +1,8 @@
Audio Playback
==============
PyTheory can synthesize and play tones and chords through your speakers.
PyTheory can synthesize and play tones and chords through your speakers
using basic `waveform <https://en.wikipedia.org/wiki/Waveform>`_ synthesis.
.. note::
@@ -24,19 +25,32 @@ Playing a Chord
.. code-block:: python
from pytheory import Chord, Tone, play
from pytheory import Chord, play
c_major = Chord(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
])
play(c_major, t=2_000) # Play for 2 seconds
# From a chord name
play(Chord.from_name("Am7"), t=2_000)
# From note names
play(Chord.from_tones("C", "E", "G"), t=2_000)
Waveform Types
--------------
Choose between sine, sawtooth, and triangle wave synthesis:
The waveform shape determines the `timbre <https://en.wikipedia.org/wiki/Timbre>`_ (tonal color) of the sound.
Different waveforms contain different combinations of **harmonics**
integer multiples of the fundamental frequency.
- `Sine wave <https://en.wikipedia.org/wiki/Sine_wave>`_ — the purest tone. Contains only the fundamental
frequency with no harmonics. Sounds smooth, clear, and "electronic."
This is the building block of all other waveforms (`Fourier's theorem <https://en.wikipedia.org/wiki/Fourier_series>`_).
- `Sawtooth wave <https://en.wikipedia.org/wiki/Sawtooth_wave>`_ — contains all harmonics (both odd and even),
each at amplitude 1/n. Sounds bright, buzzy, and aggressive.
Named for its shape. Used extensively in `additive synthesis <https://en.wikipedia.org/wiki/Additive_synthesis>`_ and analog synthesizers.
- `Triangle wave <https://en.wikipedia.org/wiki/Triangle_wave>`_ — contains only odd harmonics, each at amplitude
1/n². Sounds softer and more mellow than sawtooth — somewhere between
sine and sawtooth. Often described as "woody" or "hollow."
.. code-block:: python
@@ -44,17 +58,42 @@ Choose between sine, sawtooth, and triangle wave synthesis:
tone = Tone.from_string("C4", system="western")
play(tone, synth=Synth.SINE) # Smooth, pure tone
play(tone, synth=Synth.SINE) # Pure, clean
play(tone, synth=Synth.SAW) # Bright, buzzy
play(tone, synth=Synth.TRIANGLE) # Softer than sawtooth
play(tone, synth=Synth.TRIANGLE) # Mellow, hollow
Temperaments
------------
Play in different tuning systems:
Hear the difference between tuning systems:
.. code-block:: python
play(tone, temperament="equal") # Default, modern tuning
play(tone, temperament="pythagorean") # Ancient Greek tuning
play(tone, temperament="meantone") # Renaissance tuning
play(tone, temperament="equal") # Modern standard (since ~1917)
play(tone, temperament="pythagorean") # Pure fifths, wolf intervals
play(tone, temperament="meantone") # Pure thirds, Renaissance sound
Try playing a C major chord in each temperament — you'll hear subtle
differences in the "color" of the major third. Equal temperament is
a compromise; the other systems sacrifice some keys to make the good
keys sound better.
Saving to WAV
-------------
Render tones or chords to a WAV file instead of playing them live.
This works even without speakers or PortAudio:
.. code-block:: python
from pytheory import save, Chord, Tone, Synth
# Save a single tone
save(Tone.from_string("A4"), "a440.wav", t=1_000)
# Save a chord
save(Chord.from_name("Am7"), "am7.wav", t=2_000)
# Choose waveform and temperament
save(Chord.from_name("C"), "c_triangle.wav",
synth=Synth.TRIANGLE, temperament="meantone", t=3_000)
+164 -33
View File
@@ -4,59 +4,190 @@ Quickstart
Installation
------------
.. code-block:: bash
::
pip install pytheory
$ pip install pytheory
Or with `uv <https://github.com/astral-sh/uv>`_:
For audio playback, you'll also need `PortAudio <http://www.portaudio.com/>`_:
.. code-block:: bash
- macOS: ``brew install portaudio``
- Ubuntu: ``apt install libportaudio2``
- Windows: included with the ``sounddevice`` package
uv add pytheory
Tones
-----
Basic Usage
-----------
Create tones, build scales, and explore music theory:
A :class:`~pytheory.tones.Tone` is a single musical note:
.. code-block:: python
from pytheory import Tone, TonedScale, Fretboard, CHARTS
from pytheory import Tone
# Create a tone
c4 = Tone.from_string("C4")
print(c4) # C4
print(c4.frequency) # 261.63 Hz
# Create tones — sharps and flats both work
a4 = Tone.from_string("A4", system="western")
a4.frequency # 440.0 Hz — the tuning standard
c4 = Tone.from_string("C4", system="western")
c4.midi # 60 — middle C
# From a frequency or MIDI number
Tone.from_frequency(440) # <Tone A4>
Tone.from_midi(60) # <Tone C4>
# Tone arithmetic
e4 = c4 + 4 # Major third up
g4 = c4 + 7 # Perfect fifth up
print(e4, g4) # E4 G4
c4 + 4 # <Tone E4> — major third up
c4 + 7 # <Tone G4> — perfect fifth up
# Measure intervals
print(g4 - c4) # 7 (semitones)
# Interval between two tones
g4 = c4 + 7
g4 - c4 # 7 semitones
c4.interval_to(g4) # 'perfect 5th'
# Build a scale
c_major = TonedScale(tonic="C4")["major"]
print(c_major.note_names)
# Enharmonics
Tone.from_string("C#4", system="western").enharmonic # 'Db'
Scales
------
Build scales in any key and mode:
.. code-block:: python
from pytheory import TonedScale
c = TonedScale(tonic="C4")
c["major"].note_names
# ['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
# Build chords from the scale
I = c_major.triad(0) # C major
IV = c_major.triad(3) # F major
V = c_major.triad(4) # G major
c["minor"].note_names
# ['C', 'D', 'D#', 'F', 'G', 'G#', 'A#', 'C']
# Guitar chord fingerings
fb = Fretboard.guitar()
fingering = CHARTS["western"]["Am"].fingering(fretboard=fb)
print(fingering) # (0, 1, 2, 2, 0, 0)
c["dorian"].note_names
# ['C', 'D', 'D#', 'F', 'G', 'A', 'A#', 'C']
# Access scale degrees by name or numeral
major = c["major"]
major["tonic"] # C4
major["dominant"] # G4
major["V"] # G4
Keys and Chords
---------------
The :class:`~pytheory.scales.Key` class ties everything together —
scales, chords, and progressions:
.. code-block:: python
from pytheory import Key
key = Key("G", "major")
key.note_names # ['G', 'A', 'B', 'C', 'D', 'E', 'F#', 'G']
# All diatonic triads
key.chords
# ['G major', 'A minor', 'B minor', 'C major',
# 'D major', 'E minor', 'F# diminished']
# Build progressions from Roman numerals
chords = key.progression("I", "V", "vi", "IV")
[c.identify() for c in chords]
# ['G major', 'D major', 'E minor', 'C major']
# Detect the key from notes
Key.detect("C", "E", "G", "A", "D") # <Key C major>
Build chords directly:
.. code-block:: python
from pytheory import Chord
Chord.from_tones("C", "E", "G") # <Chord C major>
Chord.from_name("Am7") # <Chord A minor 7th>
Chord.from_intervals("G", 4, 7, 10) # <Chord G dominant 7th>
# Identify any chord
Chord.from_tones("Bb", "D", "F").identify() # 'Bb major'
# Analyze in a key
Chord.from_name("G7").analyze("C") # 'V7'
Guitar Fingerings
-----------------
.. code-block:: pycon
>>> from pytheory import Fretboard
>>> fb = Fretboard.guitar()
>>> fb.chord("C")
Fingering(e=0, B=1, G=0, D=2, A=3, E=0)
>>> fb.chord("C")['A']
3
>>> fb.fingering(0, 0, 0, 2, 2, 0).identify()
'E minor'
>>> from pytheory import CHARTS
>>> print(CHARTS["western"]["Am"].tab(fretboard=fb))
Am
E|--0--
B|--1--
G|--2--
D|--2--
A|--0--
E|--0--
Audio Playback
--------------
.. code-block:: python
from pytheory import Tone, Chord, play, save, Synth
# Play a tone
play(Tone.from_string("A4"), t=1_000)
# Play a chord with a different waveform
play(Chord.from_name("Am7"), synth=Synth.TRIANGLE, t=2_000)
# Save to a WAV file
save(Chord.from_name("C"), "c_major.wav", t=2_000)
Command Line
------------
PyTheory also works from the terminal::
$ pytheory tone A4
$ pytheory chord C E G
$ pytheory key G major
$ pytheory scale C dorian
$ pytheory fingering Am
$ pytheory progression C major I V vi IV
$ pytheory detect C E G A D
$ pytheory play Am7 --synth triangle
What's Included
---------------
- **12-tone Western system** with all chromatic notes
- **Scales**: major, minor, harmonic minor, and all 7 modes
- **6 musical systems**: Western, Indian (Hindustani), Arabic (Maqam),
Japanese, Blues/Pentatonic, Javanese Gamelan
- **40+ scales**: major, minor, harmonic minor, 7 modes, 10 thaats,
10 maqamat, 6 Japanese pentatonic scales, blues, pentatonic,
slendro, pelog, and more
- **Pitch calculation** in equal, Pythagorean, and meantone temperaments
- **Chord identification**: name any chord from its notes, intervals, or
MIDI numbers (17 chord types recognized)
- **Chord charts** with 144 pre-built chords (12 roots x 12 qualities)
- **Fingering generation** for any fretted instrument
- **Chord analysis**: consonance scoring, Plomp-Levelt dissonance,
beat frequency calculation, harmonic tension, voice leading
- **Key detection** and **Roman numeral analysis** (I-IV-V-I progressions)
- **Fingering generation** for 25 instruments with labeled string names,
including guitar (8 tunings), bass, ukulele, mandolin, and more
- **Audio playback** with sine, sawtooth, and triangle wave synthesis
- **WAV export** for saving rendered audio to disk
+307 -39
View File
@@ -1,7 +1,29 @@
Working with Scales
===================
Scales are sequences of tones following a specific interval pattern.
A **scale** is an ordered set of tones spanning an octave, defined by a
pattern of intervals. Scales are the foundation of melody and harmony —
they determine which notes "belong" in a piece of music and shape its
emotional character.
Scale Construction
------------------
Every scale is defined by its **interval pattern** — the sequence of
whole steps (W = 2 semitones) and half steps (H = 1 semitone) between
consecutive tones.
The `major scale <https://en.wikipedia.org/wiki/Major_scale>`_::
W W H W W W H
C D E F G A B C
2 2 1 2 2 2 1 ← semitones between each note
The `natural minor scale <https://en.wikipedia.org/wiki/Minor_scale>`_::
W H W W H W W
C D Eb F G Ab Bb C
2 1 2 2 1 2 2
Building Scales
---------------
@@ -14,7 +36,6 @@ Use :class:`~pytheory.scales.TonedScale` to generate scales in any key:
c = TonedScale(tonic="C4")
# Access scales by name
major = c["major"]
minor = c["minor"]
harmonic_minor = c["harmonic minor"]
@@ -22,62 +43,117 @@ Use :class:`~pytheory.scales.TonedScale` to generate scales in any key:
print(major.note_names)
# ['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
Available Scales
----------------
Major and Minor
---------------
The **major scale** (`Ionian <https://en.wikipedia.org/wiki/Ionian_mode>`_ mode) is the foundation of Western tonal
music. Its pattern of whole and half steps creates a bright, resolved
sound. Every major key has a `relative minor <https://en.wikipedia.org/wiki/Relative_key>`_ that shares the same
notes but starts from the 6th degree:
- C major → A minor (both use only white keys)
- G major → E minor (both have one sharp: F#)
- F major → D minor (both have one flat: Bb)
.. code-block:: python
>>> c = TonedScale(tonic="C4")
>>> c.scales
('chromatic', 'major', 'minor', 'harmonic minor',
'ionian', 'dorian', 'phrygian', 'lydian',
'mixolydian', 'aeolian', 'locrian')
c_major = TonedScale(tonic="C4")["major"]
a_minor = TonedScale(tonic="A4")["minor"]
# Same notes, different starting point
set(c_major.note_names) == set(a_minor.note_names) # True
The `harmonic minor <https://en.wikipedia.org/wiki/Harmonic_minor_scale>`_ raises the 7th degree of the natural minor,
creating an augmented 2nd interval (3 semitones) between the 6th and
7th degrees. This gives it a distinctive "Middle Eastern" or "classical"
sound and provides the leading tone needed for dominant harmony::
Natural minor: C D Eb F G Ab Bb C
Harmonic minor: C D Eb F G Ab B C
↑ raised 7th
Modes
-----
All seven modes of the major scale are supported:
The seven `modes <https://en.wikipedia.org/wiki/Mode_(music)>`_ of the major scale are rotations of the same interval
pattern, each starting from a different degree. Each mode has a distinct
emotional character:
.. code-block:: python
c = TonedScale(tonic="C4")
c["ionian"] # Same as major: C D E F G A B C
c["dorian"] # C D Eb F G A Bb C
c["phrygian"] # C Db Eb F G Ab Bb C
c["lydian"] # C D E F# G A B C
c["mixolydian"] # C D E F G A Bb C
c["aeolian"] # Same as minor: C D Eb F G Ab Bb C
c["locrian"] # C Db Eb F Gb Ab Bb C
**Ionian** (I) — the major scale itself. Bright, happy, resolved::
Accessing Degrees
-----------------
c["ionian"] # C D E F G A B C
Scale tones can be accessed by index, Roman numeral, or degree name:
`Dorian <https://en.wikipedia.org/wiki/Dorian_mode>`_ (ii) — minor with a raised 6th. Jazzy, soulful (So What,
Scarborough Fair)::
c["dorian"] # C D Eb F G A Bb C
`Phrygian <https://en.wikipedia.org/wiki/Phrygian_mode>`_ (iii) — minor with a flat 2nd. Spanish, flamenco, dark
(White Rabbit)::
c["phrygian"] # C Db Eb F G Ab Bb C
`Lydian <https://en.wikipedia.org/wiki/Lydian_mode>`_ (IV) — major with a raised 4th. Dreamy, floating, ethereal
(The Simpsons theme, Flying by ET)::
c["lydian"] # C D E F# G A B C
`Mixolydian <https://en.wikipedia.org/wiki/Mixolydian_mode>`_ (V) — major with a flat 7th. Bluesy, rock, dominant
(Norwegian Wood, Sweet Home Alabama)::
c["mixolydian"] # C D E F G A Bb C
`Aeolian <https://en.wikipedia.org/wiki/Aeolian_mode>`_ (vi) — the natural minor scale. Sad, dark, introspective
(Stairway to Heaven, Losing My Religion)::
c["aeolian"] # C D Eb F G Ab Bb C
`Locrian <https://en.wikipedia.org/wiki/Locrian_mode>`_ (vii) — minor with flat 2nd and flat 5th. Unstable,
rarely used as a home key (used in metal and jazz over diminished
chords)::
c["locrian"] # C Db Eb F Gb Ab Bb C
Scale Degrees
-------------
Each note in a scale has a **degree name** that describes its function:
============ ====== =======================================
Degree Number Function
============ ====== =======================================
Tonic I Home base — the key center
Supertonic II One step above tonic
Mediant III Halfway between tonic and dominant
Subdominant IV A fifth below tonic (or fourth above)
Dominant V The strongest pull back to tonic
Submediant VI Root of the relative minor (or major)
Leading Tone VII One semitone below tonic — pulls upward
============ ====== =======================================
Access degrees by index, Roman numeral, or name:
.. code-block:: python
major = TonedScale(tonic="C4")["major"]
# By index
major[0] # C4
major[4] # G4
major[0] # C4 (by index)
major["I"] # C4 (by Roman numeral)
major["tonic"] # C4 (by degree name)
# By Roman numeral
major["I"] # C4
major["V"] # G4
# By degree name
major["tonic"] # C4
major["V"] # G4 (dominant)
major["dominant"] # G4
# Slicing
major[0:3] # (C4, D4, E4)
major[0:3] # (C4, D4, E4) — slicing works too
Iteration
---------
Scales are iterable:
Scales are iterable and support ``len()`` and ``in``:
.. code-block:: python
@@ -91,16 +167,208 @@ Scales are iterable:
Building Chords from Scales
----------------------------
Build chords directly from scale degrees:
`Diatonic <https://en.wikipedia.org/wiki/Diatonic_and_chromatic>`_ harmony builds chords by stacking every other note of the
scale. A **triad** takes the 1st, 3rd, and 5th; a **seventh chord** adds
the 7th.
In the C major scale, the diatonic triads are::
I C E G = C major
ii D F A = D minor
iii E G B = E minor
IV F A C = F major
V G B D = G major
vi A C E = A minor
vii° B D F = B diminished
Notice the pattern: **major** triads on I, IV, V; **minor** triads on
ii, iii, vi; **diminished** on vii°. This pattern holds for every major
key.
.. code-block:: python
major = TonedScale(tonic="C4")["major"]
# Build a triad (root, 3rd, 5th)
I = major.triad(0) # C E G (C major)
ii = major.triad(1) # D F A (D minor)
V = major.triad(4) # G B D (G major)
# Build diatonic triads
I = major.triad(0) # C E G (C major)
ii = major.triad(1) # D F A (D minor)
iii = major.triad(2) # E G B (E minor)
IV = major.triad(3) # F A C (F major)
V = major.triad(4) # G B D (G major)
vi = major.triad(5) # A C E (A minor)
# Custom chord voicings
cmaj7 = major.chord(0, 2, 4, 6) # C E G B
# Build seventh chords
Imaj7 = major.chord(0, 2, 4, 6) # C E G B = Cmaj7
V7 = major.chord(4, 6, 8, 10) # G B D F = G7 (dominant 7th)
Common Progressions
~~~~~~~~~~~~~~~~~~~
Some of the most-used chord progressions in Western music:
- **IIVVI** — the foundation of blues, rock, country, folk
- **IVviIV** — the "pop progression" (Let It Be, No Woman No Cry,
With or Without You, Someone Like You)
- **iiVI** — the backbone of jazz harmony
- **IviIVV** — the "50s progression" (Stand By Me, Every Breath You Take)
- **ibVIbIIIbVII** — the "epic" minor progression (Stairway to Heaven,
My Heart Will Go On)
- **IIVviV** — axis of awesome (many, many pop songs)
The :class:`~pytheory.scales.Key` class makes working with progressions
easy:
.. code-block:: python
from pytheory import Key
key = Key("G", "major")
# Build a progression from Roman numerals
chords = key.progression("I", "V", "vi", "IV")
for c in chords:
print(c.identify())
# G major, D major, E minor, C major
# Nashville number system (same thing, with integers)
key.nashville(1, 5, 6, 4)
# All diatonic triads in the key
key.chords
# ['G major', 'A minor', 'B minor', 'C major', ...]
# All diatonic seventh chords
key.seventh_chords
# ['G major 7th', 'A minor 7th', ...]
# Detect the key from a set of notes
Key.detect("C", "E", "G", "A", "D") # <Key C major>
The 12-Bar Blues
~~~~~~~~~~~~~~~~
The `12-bar blues <https://en.wikipedia.org/wiki/Twelve-bar_blues>`_ is the most influential chord progression in
American music. It's 12 measures long and uses only three chords
(I, IV, V)::
| I | I | I | I |
| IV | IV | I | I |
| V | IV | I | V |
Every blues, early rock and roll, and much of jazz is built on this
structure. In the key of A::
| A | A | A | A |
| D | D | A | A |
| E | D | A | E |
.. code-block:: python
from pytheory import TonedScale
a = TonedScale(tonic="A4")["major"]
I = a.triad(0) # A major
IV = a.triad(3) # D major
V = a.triad(4) # E major
# The 12-bar blues progression
blues_12 = [I, I, I, I, IV, IV, I, I, V, IV, I, V]
Key Signatures
~~~~~~~~~~~~~~
The ``signature`` property tells you how many sharps or flats a key has:
.. code-block:: python
>>> 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': []}
Relative and Parallel Keys
~~~~~~~~~~~~~~~~~~~~~~~~~~
Two keys are **relative** if they share the same notes (C major and
A minor). Two keys are `parallel <https://en.wikipedia.org/wiki/Parallel_key>`_ if they share the same tonic but
have different notes (C major and C minor):
.. code-block:: python
>>> Key("C", "major").relative
<Key A minor>
>>> Key("A", "minor").relative
<Key C major>
>>> Key("C", "major").parallel
<Key C minor>
Borrowed Chords
~~~~~~~~~~~~~~~
`Modal interchange <https://en.wikipedia.org/wiki/Borrowed_chord>`_
borrowing chords from the parallel key — is one of the most powerful
tools in songwriting. The bVI and bVII chords (Ab and Bb in C major)
are borrowed from C minor and appear constantly in rock and film music:
.. code-block:: python
>>> Key("C", "major").borrowed_chords
# Chords from C minor that aren't in C major
Secondary Dominants
~~~~~~~~~~~~~~~~~~~
A `secondary dominant <https://en.wikipedia.org/wiki/Secondary_dominant>`_
is the V chord *of* a non-tonic chord. It creates a momentary pull
toward that chord, adding harmonic color:
.. code-block:: python
key = Key("C", "major")
# V/V — the dominant of the dominant (D7 → G)
key.secondary_dominant(5) # D dominant 7th
# V/ii — the dominant of the supertonic (A7 → Dm)
key.secondary_dominant(2) # A dominant 7th
Random Progressions
~~~~~~~~~~~~~~~~~~~
Need inspiration? Generate weighted random progressions. The weights
favor common chord functions (I and vi most likely, vii least):
.. code-block:: python
key = Key("C", "major")
chords = key.random_progression(4) # 4 chords
[c.identify() for c in chords]
# e.g. ['C major', 'F major', 'A minor', 'G major']
All Keys
~~~~~~~~
Enumerate all 24 major and minor keys:
.. code-block:: python
>>> Key.all_keys()
[<Key C major>, <Key C minor>, <Key C# major>, <Key C# minor>, ...]
Scale Transposition
~~~~~~~~~~~~~~~~~~~
Transpose an entire scale by a number of semitones:
.. code-block:: python
c_major = TonedScale(tonic="C4")["major"]
d_major = c_major.transpose(2) # Up a whole step
d_major.note_names
# ['D', 'E', 'F#', 'G', 'A', 'B', 'C#', 'D']
+214
View File
@@ -0,0 +1,214 @@
Musical Systems
===============
PyTheory supports four musical systems, each with its own tone names
and scale patterns.
Western
-------
The standard 12-tone equal temperament system with major/minor scales
and all seven modes.
.. code-block:: python
from pytheory import TonedScale
c = TonedScale(tonic="C4")
c["major"].note_names
# ['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
c["dorian"].note_names
# ['C', 'D', 'D#', 'F', 'G', 'A', 'A#', 'C']
**Scales:** major, minor, harmonic minor, ionian, dorian, phrygian,
lydian, mixolydian, aeolian, locrian, chromatic
Indian Classical (Hindustani)
-----------------------------
The Hindustani system uses **swaras** (Sa, Re, Ga, Ma, Pa, Dha, Ni) and
organizes scales into `thaats <https://en.wikipedia.org/wiki/Thaat>`_ — the 10 parent scales from which `ragas <https://en.wikipedia.org/wiki/Raga>`_
are derived.
.. code-block:: python
from pytheory import TonedScale
sa = TonedScale(tonic="Sa4", system="indian")
sa["bilawal"].note_names # = major scale
# ['Sa', 'Re', 'Ga', 'Ma', 'Pa', 'Dha', 'Ni', 'Sa']
sa["bhairav"].note_names # unique to Indian music
# ['Sa', 'komal Re', 'Ga', 'Ma', 'Pa', 'komal Dha', 'Ni', 'Sa']
sa["todi"].note_names
# ['Sa', 'komal Re', 'komal Ga', 'tivra Ma', 'Pa', 'komal Dha', 'Ni', 'Sa']
**Thaats:** bilawal, khamaj, kafi, asavari, bhairavi, kalyan, bhairav,
poorvi, marwa, todi
**Swara notation:**
- Uppercase = shuddha (natural): Sa, Re, Ga, Ma, Pa, Dha, Ni
- ``komal`` prefix = flat: komal Re, komal Ga, komal Dha, komal Ni
- ``tivra`` prefix = sharp: tivra Ma
Arabic Maqam
------------
The Arabic system uses **solfège-based names** (Do, Re, Mi, Fa, Sol, La, Si)
and organizes scales into **maqamat** (plural of `maqam <https://en.wikipedia.org/wiki/Maqam>`_).
.. note::
True maqam music uses quarter-tones that cannot be represented in
12-tone equal temperament. These scales are the closest 12-TET
approximations.
.. code-block:: python
from pytheory import TonedScale
do = TonedScale(tonic="Do4", system="arabic")
do["ajam"].note_names # = major scale
# ['Do', 'Re', 'Mi', 'Fa', 'Sol', 'La', 'Si', 'Do']
do["hijaz"].note_names # characteristic augmented 2nd
# ['Do', 'Reb', 'Mi', 'Fa', 'Sol', 'Solb', 'Sib', 'Do']
do["nikriz"].note_names
# ['Do', 'Re', 'Mib', 'Fa#', 'Sol', 'La', 'Sib', 'Do']
**Maqamat:** ajam, nahawand, kurd, hijaz, nikriz, bayati, rast, saba,
sikah, jiharkah
Japanese
--------
The Japanese system uses Western note names with traditional pentatonic
and heptatonic scales from Japanese music.
.. code-block:: python
from pytheory import TonedScale
c = TonedScale(tonic="C4", system="japanese")
c["hirajoshi"].note_names # most iconic Japanese scale
# ['C', 'D', 'D#', 'G', 'G#', 'C']
c["in"].note_names # Miyako-bushi, used in koto music
# ['C', 'C#', 'F', 'G', 'G#', 'C']
c["yo"].note_names # folk music scale
# ['C', 'D', 'F', 'G', 'A#', 'C']
c["ritsu"].note_names # gagaku court music (= Dorian)
# ['C', 'D', 'D#', 'F', 'G', 'A', 'A#', 'C']
**Pentatonic scales:** hirajoshi, in, yo, iwato, kumoi, insen
**Heptatonic scales:** ritsu, ryo
Blues and Pentatonic
--------------------
The blues system provides the scales foundational to blues, rock, jazz,
and folk music worldwide. `Pentatonic scales <https://en.wikipedia.org/wiki/Pentatonic_scale>`_ (5 notes) are the oldest
known musical scales, found independently in cultures across every
continent.
The `blues scale <https://en.wikipedia.org/wiki/Blues_scale>`_ adds the "`blue note <https://en.wikipedia.org/wiki/Blue_note>`_" (flat 5th / sharp 4th) to the
minor pentatonic — this chromatic passing tone is the defining sound
of the blues.
.. code-block:: python
from pytheory import TonedScale
c = TonedScale(tonic="C4", system="blues")
c["major pentatonic"].note_names # the "happy" pentatonic
# ['C', 'D', 'E', 'G', 'A', 'C']
c["minor pentatonic"].note_names # the "sad" pentatonic
# ['C', 'D#', 'F', 'G', 'A#', 'C']
c["blues"].note_names # minor pentatonic + blue note
# ['C', 'D#', 'F', 'F#', 'G', 'A#', 'C']
c["major blues"].note_names # major pentatonic + blue note
# ['C', 'D', 'D#', 'E', 'G', 'A', 'C']
**Pentatonic:** major pentatonic, minor pentatonic
**Hexatonic:** blues, major blues
**Heptatonic:** dominant (Mixolydian — the dominant 7th sound),
minor (Dorian — the jazz minor sound)
Javanese Gamelan
----------------
The `gamelan <https://en.wikipedia.org/wiki/Gamelan>`_ system approximates the scales of the Javanese and Balinese
gamelan orchestra in 12-tone equal temperament. True gamelan tuning is
unique to each ensemble and does not conform to Western intonation —
these are the closest 12-TET approximations.
`Slendro <https://en.wikipedia.org/wiki/Slendro>`_ is a roughly equal 5-tone division of the octave, producing
an ethereal, floating quality. `Pelog <https://en.wikipedia.org/wiki/Pelog>`_ is a 7-tone scale with unequal
intervals, typically performed using 5-note subsets called *pathet*.
.. code-block:: python
from pytheory import TonedScale
ji = TonedScale(tonic="ji4", system="gamelan")
ji["slendro"].note_names # the 5-tone equidistant scale
# ['ji', 'ro', 'pat', 'mo', 'pi', 'ji']
ji["pelog"].note_names # full 7-tone pelog
# ['ji', 'ro-', 'lu', 'pat', 'mo', 'nem-', 'barang', 'ji']
ji["pelog nem"].note_names # pathet nem subset
# ['ji', 'ro-', 'lu', 'pat', 'mo', 'ji']
**Pentatonic:** slendro, pelog nem, pelog barang, pelog lima
**Heptatonic:** pelog (full 7-tone)
.. note::
Gamelan tone names follow Javanese numbering: ji (1), ro (2),
lu (3), pat (4), mo (5), nem (6), pi/barang (7). Suffixes
indicate microtonal variants approximated to the nearest semitone.
Cross-System Comparison
-----------------------
Since all systems use 12-tone equal temperament, equivalent scales
produce the same pitches:
.. code-block:: python
from pytheory import TonedScale, Tone
# These are all the same scale with different names
western = TonedScale(tonic="C4")["major"]
indian = TonedScale(tonic="Sa4", system="indian")["bilawal"]
arabic = TonedScale(tonic="Do4", system="arabic")["ajam"]
# Same pitches
c4 = Tone.from_string("C4", system="western")
sa4 = Tone.from_string("Sa4", system="indian")
do4 = Tone.from_string("Do4", system="arabic")
c4.frequency # 261.63
sa4.frequency # 261.63
do4.frequency # 261.63
+330
View File
@@ -0,0 +1,330 @@
Music Theory Fundamentals
=========================
This page covers the essential concepts of music theory — the framework
behind everything PyTheory does.
Sound and Pitch
---------------
All sound is vibration. When an object vibrates, it pushes air molecules
back and forth, creating pressure waves that travel to your ears. The
speed of this vibration — measured in cycles per second
(`Hertz <https://en.wikipedia.org/wiki/Hertz>`_, Hz) — determines the
`pitch <https://en.wikipedia.org/wiki/Pitch_(music)>`_ you hear.
- **20 Hz**: the lowest pitch most humans can hear
- **60250 Hz**: the range of the human voice (speaking)
- **261.63 Hz**: `middle C <https://en.wikipedia.org/wiki/C_(musical_note)#Middle_C>`_ (C4)
- **440 Hz**: the `concert pitch <https://en.wikipedia.org/wiki/Concert_pitch>`_ tuning standard A (A4)
- **4186 Hz**: the highest C on a piano (C8)
- **20,000 Hz**: the upper limit of `human hearing <https://en.wikipedia.org/wiki/Hearing_range>`_
The relationship between pitch and frequency is **logarithmic** — each
`octave <https://en.wikipedia.org/wiki/Octave>`_ doubles the frequency.
This means the distance from A3 (220 Hz) to A4 (440 Hz) is 220 Hz, but
the distance from A4 to A5 (880 Hz) is 440 Hz. Both sound like "one
octave" to our ears.
Why Twelve Notes?
-----------------
The Western `chromatic scale <https://en.wikipedia.org/wiki/Chromatic_scale>`_
has 12 notes per octave. This isn't arbitrary — it emerges from the
physics of vibrating strings and air columns.
The `harmonic series <https://en.wikipedia.org/wiki/Harmonic_series_(music)>`_
is the sequence of frequencies produced when a string vibrates: f, 2f,
3f, 4f, 5f... The relationships between these harmonics create the
intervals we perceive as `consonant <https://en.wikipedia.org/wiki/Consonance_and_dissonance>`_:
- 2:1 = `octave <https://en.wikipedia.org/wiki/Octave>`_ (the most fundamental)
- 3:2 = `perfect fifth <https://en.wikipedia.org/wiki/Perfect_fifth>`_
- 4:3 = `perfect fourth <https://en.wikipedia.org/wiki/Perfect_fourth>`_
- 5:4 = `major third <https://en.wikipedia.org/wiki/Major_third>`_
- 6:5 = `minor third <https://en.wikipedia.org/wiki/Minor_third>`_
If you stack perfect fifths (multiply by 3/2 repeatedly) and reduce to
within one octave, you get 12 roughly evenly-spaced notes before the
cycle almost closes. The tiny gap where it doesn't close perfectly is
the `Pythagorean comma <https://en.wikipedia.org/wiki/Pythagorean_comma>`_
— the reason we need `temperament <https://en.wikipedia.org/wiki/Musical_temperament>`_.
.. code-block:: python
from pytheory import Tone
# Walk the circle of fifths — all 12 notes
c = Tone.from_string("C4", system="western")
[t.name for t in c.circle_of_fifths()]
# ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'C#', 'G#', 'D#', 'A#', 'F']
Other cultures divide the octave differently: Indonesian
`gamelan <https://en.wikipedia.org/wiki/Gamelan>`_ uses 5 or 7 unequal
divisions; Indian classical music theoretically has 22
`shrutis <https://en.wikipedia.org/wiki/Shruti_(music)>`_ (microtones);
Arabic `maqam <https://en.wikipedia.org/wiki/Maqam>`_ uses
`quarter-tones <https://en.wikipedia.org/wiki/Quarter_tone>`_.
Intervals: The Atoms of Music
------------------------------
An `interval <https://en.wikipedia.org/wiki/Interval_(music)>`_ is the
distance between two pitches. Intervals are the building blocks of
everything — melodies are sequences of intervals, chords are stacks
of intervals, and scales are patterns of intervals.
Every interval has two properties:
**Size** (how many scale steps)::
Unison → 2nd → 3rd → 4th → 5th → 6th → 7th → Octave
**Quality** (exact number of semitones)::
Perfect: unison (0), 4th (5), 5th (7), octave (12)
Major: 2nd (2), 3rd (4), 6th (9), 7th (11)
Minor: 2nd (1), 3rd (3), 6th (8), 7th (10)
Augmented: one semitone larger than perfect or major
Diminished: one semitone smaller than perfect or minor
The "`perfect <https://en.wikipedia.org/wiki/Perfect_fifth>`_" intervals
(unison, 4th, 5th, octave) are called perfect because they appear in
both major AND minor scales unchanged. They've been considered consonant
across virtually all musical cultures throughout history.
The `tritone <https://en.wikipedia.org/wiki/Tritone>`_ (augmented 4th /
diminished 5th = 6 semitones) divides the octave exactly in half.
Medieval theorists called it *diabolus in musica* ("the devil in music")
because of its extreme instability. Today it's the foundation of
`dominant harmony <https://en.wikipedia.org/wiki/Dominant_(music)>`_
and the `blues <https://en.wikipedia.org/wiki/Blue_note>`_.
Keys and Key Signatures
-----------------------
A `key <https://en.wikipedia.org/wiki/Key_(music)>`_ is a group of
notes that form the tonal center of a piece. The key of C major uses
only the white keys on the piano: C D E F G A B. The key of G major
uses the same notes except F becomes F#.
`Key signatures <https://en.wikipedia.org/wiki/Key_signature>`_ tell
you which notes are sharped or flatted throughout a piece. They follow
the `circle of fifths <https://en.wikipedia.org/wiki/Circle_of_fifths>`_:
**Sharp keys** (add one sharp per step clockwise)::
C major: no sharps or flats
G major: F#
D major: F# C#
A major: F# C# G#
E major: F# C# G# D#
B major: F# C# G# D# A#
**Flat keys** (add one flat per step counter-clockwise)::
C major: no sharps or flats
F major: Bb
Bb major: Bb Eb
Eb major: Bb Eb Ab
Ab major: Bb Eb Ab Db
Db major: Bb Eb Ab Db Gb
The order of sharps is always F C G D A E B (Father Charles Goes Down
And Ends Battle). The order of flats is the reverse: B E A D G C F.
Harmony: How Chords Work
-------------------------
`Harmony <https://en.wikipedia.org/wiki/Harmony>`_ is the art of
combining tones simultaneously. While
`melody <https://en.wikipedia.org/wiki/Melody>`_ is horizontal (tones
in sequence), harmony is vertical (tones stacked).
The simplest harmony is the `triad <https://en.wikipedia.org/wiki/Triad_(music)>`_
— three notes built by stacking `thirds <https://en.wikipedia.org/wiki/Third_(music)>`_.
The quality of each third determines the chord type:
- **Major triad** = major 3rd + minor 3rd (e.g. C-E-G)
- **Minor triad** = minor 3rd + major 3rd (e.g. C-Eb-G)
- `Diminished triad <https://en.wikipedia.org/wiki/Diminished_triad>`_ = minor 3rd + minor 3rd (e.g. B-D-F)
- `Augmented triad <https://en.wikipedia.org/wiki/Augmented_triad>`_ = major 3rd + major 3rd (e.g. C-E-G#)
In any major key, the triads built on each
`scale degree <https://en.wikipedia.org/wiki/Degree_(music)>`_ always
follow the same pattern::
Degree Quality Function
I Major Tonic (home)
ii Minor Pre-dominant
iii Minor Tonic substitute
IV Major Subdominant (departure)
V Major Dominant (tension, wants to go home)
vi Minor Tonic substitute, relative minor
vii° Diminished Dominant substitute (leading tone chord)
This pattern is the DNA of Western harmony. Pop songs, classical
sonatas, jazz standards, and church hymns all derive from it.
Functional Harmony
~~~~~~~~~~~~~~~~~~
Chords don't just have names — they have
`functions <https://en.wikipedia.org/wiki/Function_(music)>`_:
- **Tonic function** (I, iii, vi): stability, rest, home
- **Subdominant function** (ii, IV): motion away from home
- **Dominant function** (V, vii°): tension, desire to return home
The most fundamental progression in Western music is **T → S → D → T**
(tonic → subdominant → dominant → tonic). The classic
`I-IV-V-I <https://en.wikipedia.org/wiki/I%E2%80%93IV%E2%80%93V%E2%80%93I>`_
is exactly this pattern. Every "Louie Louie" and every
`Bach chorale <https://en.wikipedia.org/wiki/Bach_chorale>`_ follows
this basic tonal gravity.
.. code-block:: python
from pytheory import TonedScale
scale = TonedScale(tonic="C4")["major"]
# The I-IV-V-I progression
I = scale.triad(0) # C major — home
IV = scale.triad(3) # F major — departure
V = scale.triad(4) # G major — tension
# I again # C major — resolution
The Dominant Seventh
~~~~~~~~~~~~~~~~~~~~
The most important chord in `tonal music <https://en.wikipedia.org/wiki/Tonality>`_
is the `dominant seventh <https://en.wikipedia.org/wiki/Dominant_seventh_chord>`_
— the V7 chord. In C major, this is G-B-D-F. It contains:
- A `leading tone <https://en.wikipedia.org/wiki/Leading-tone>`_ (B) that pulls up to the tonic (C) by half step
- A `tritone <https://en.wikipedia.org/wiki/Tritone>`_ (B-F) that wants to resolve inward (B→C, F→E)
- The `dominant note <https://en.wikipedia.org/wiki/Dominant_(music)>`_ (G) that falls to the tonic by a fifth
This combination creates the strongest possible pull toward
`resolution <https://en.wikipedia.org/wiki/Resolution_(music)>`_.
When you hear V7→I, you feel arrival.
.. code-block:: python
from pytheory import Chord, Tone
C4 = Tone.from_string("C4", system="western")
G4 = Tone.from_string("G4", system="western")
g7 = Chord([G4, G4+4, G4+7, G4+10]) # G B D F
g7.identify() # 'G dominant 7th'
g7.tension['has_dominant_function'] # True
g7.tension['tritones'] # 1
c_major = Chord([C4, C4+4, C4+7]) # C E G
c_major.tension['score'] # 0.0 — fully resolved
Rhythm and Meter
----------------
While PyTheory focuses on pitch,
`rhythm <https://en.wikipedia.org/wiki/Rhythm>`_ is the other half
of music.
**Rhythm** is the pattern of durations.
`Meter <https://en.wikipedia.org/wiki/Metre_(music)>`_ is the recurring
pattern of strong and weak beats that organizes rhythm.
- `4/4 time <https://en.wikipedia.org/wiki/Time_signature#Simple_time_signatures>`_: the most common meter. Strong-weak-medium-weak.
Used in rock, pop, hip-hop, most Western music.
- `3/4 time <https://en.wikipedia.org/wiki/Triple_metre>`_: waltz time. Strong-weak-weak. A lilting, circular feel.
- `6/8 time <https://en.wikipedia.org/wiki/Compound_meter_(music)>`_: compound duple. Two groups of three. Irish jigs, many
ballads.
- `12/8 time <https://en.wikipedia.org/wiki/Compound_meter_(music)>`_: compound quadruple. Four groups of three. Slow blues,
doo-wop, gospel. Has a triplet feel over a 4/4 pulse — the shuffle
groove of "Stormy Monday" and "Oh! Darling."
- 5/4 time: asymmetric. "`Take Five <https://en.wikipedia.org/wiki/Take_Five>`_"
by Dave Brubeck. Creates constant forward momentum because it never
fully settles.
- `7/8 time <https://en.wikipedia.org/wiki/Additive_rhythm_and_divisive_rhythm>`_: common in Balkan folk music. Often felt as 2+2+3 or
3+2+2.
The Physics of Consonance
-------------------------
Why do some intervals sound "good" and others "bad"? The answer lies
in the physics of sound waves and the
`Plomp-Levelt <https://en.wikipedia.org/wiki/Consonance_and_dissonance#Physiological_basis>`_
model of sensory dissonance.
When two frequencies are related by a simple ratio (like 3:2 for a
perfect fifth), their waveforms align regularly. The combined wave
is smooth and periodic — the brain perceives this as consonant.
When two frequencies are related by a complex ratio (like 45:32 for
a tritone), their waveforms rarely align. The combined wave is
irregular and the brain perceives
`roughness <https://en.wikipedia.org/wiki/Roughness_(psychoacoustics)>`_
— dissonance.
But `consonance and dissonance <https://en.wikipedia.org/wiki/Consonance_and_dissonance>`_
are also cultural. The
`major third <https://en.wikipedia.org/wiki/Major_third>`_ (5:4) was
considered dissonant in medieval European music but consonant since the
Renaissance. The tritone was forbidden in church music but is the
foundation of blues and jazz. Indonesian gamelan embraces
`beating <https://en.wikipedia.org/wiki/Beat_(acoustics)>`_ between
paired instruments as a core aesthetic.
.. code-block:: python
from pytheory import Chord, Tone
C4 = Tone.from_string("C4", system="western")
E4 = Tone.from_string("E4", system="western")
G4 = Tone.from_string("G4", system="western")
# The overtone series — the fifth is "built into" every tone
C4.overtones(6)
# [261.63, 523.25, 784.88, 1046.50, 1308.13, 1569.75]
# 3rd harmonic (784.88) ≈ G5 (783.99) — a perfect fifth
# Consonance: simple frequency ratios score high
fifth = Chord([C4, G4]) # 3:2 ratio
tritone = Chord([C4, C4 + 6]) # 45:32 ratio
fifth.harmony > tritone.harmony # True
# Dissonance: Plomp-Levelt roughness model
# An octave has low roughness (frequencies far apart)
# A major 3rd has more roughness (closer frequencies)
octave = Chord([C4, C4 + 12])
third = Chord([C4, E4])
octave.dissonance < third.dissonance # True
# Tension: tritones and dominant function
c_major = Chord([C4, E4, G4])
c_major.tension['score'] # 0.0 — fully resolved
g7 = Chord([G4, G4+4, G4+7, G4+10]) # G dominant 7th
g7.tension['score'] # 0.6 — wants to resolve
g7.tension['tritones'] # 1 (B-F)
g7.tension['has_dominant_function'] # True
# Beat frequencies — the pulsing between close pitches
g7.beat_frequencies
# [(tone_a, tone_b, hz), ...] sorted by frequency
Further Reading
---------------
- `Music theory <https://en.wikipedia.org/wiki/Music_theory>`_ — Wikipedia overview
- `Equal temperament <https://en.wikipedia.org/wiki/Equal_temperament>`_ — the modern tuning system
- `Circle of fifths <https://en.wikipedia.org/wiki/Circle_of_fifths>`_ — key relationships
- `Chord progression <https://en.wikipedia.org/wiki/Chord_progression>`_ — common patterns
- `Voice leading <https://en.wikipedia.org/wiki/Voice_leading>`_ — smooth chord connections
- `Raga <https://en.wikipedia.org/wiki/Raga>`_ — Indian melodic framework
- `Maqam <https://en.wikipedia.org/wiki/Maqam>`_ — Arabic melodic system
- `Gamelan <https://en.wikipedia.org/wiki/Gamelan>`_ — Indonesian ensemble music
- `Blues <https://en.wikipedia.org/wiki/Blues>`_ — the foundation of American popular music
- `Twelve-bar blues <https://en.wikipedia.org/wiki/Twelve-bar_blues>`_ — the most common blues form
+304 -15
View File
@@ -2,7 +2,40 @@ Working with Tones
==================
A :class:`~pytheory.tones.Tone` represents a single musical note, optionally
with an octave number (scientific pitch notation).
with an octave number in `scientific pitch notation <https://en.wikipedia.org/wiki/Scientific_pitch_notation>`_ (e.g. C4 = middle C).
What is a Tone?
---------------
A musical tone is a sound with a definite pitch — a periodic vibration at
a specific frequency. In the Western 12-tone system, the octave (a 2:1
frequency ratio) is divided into 12 equal steps called **semitones** or
**half steps**. Two semitones make a **whole step** (whole tone).
The 12 chromatic tones are::
C C#/Db D D#/Eb E F F#/Gb G G#/Ab A A#/Bb B
Notes with two names (like C# and Db) are `enharmonic equivalents <https://en.wikipedia.org/wiki/Enharmonic>`_
different names for the same pitch. Whether you call it C# or Db depends
on the musical context (key signature, harmonic function).
Scientific Pitch Notation
-------------------------
Each tone can be assigned an octave number. The standard is **scientific
pitch notation**, where the octave number increments at C::
... B3 C4 C#4 D4 ... A4 B4 C5 C#5 ...
^ ^
middle C one octave up
Key reference points:
- `A4 = 440 Hz <https://en.wikipedia.org/wiki/A440_(pitch_standard)>`_ — the international tuning standard (ISO 16)
- **C4 = 261.63 Hz** — middle C on the piano
- **A0 = 27.5 Hz** — the lowest A on a standard piano
- **C8 = 4186 Hz** — the highest C on a standard piano
Creating Tones
--------------
@@ -11,9 +44,10 @@ Creating Tones
from pytheory import Tone
# From a string
# From a string (most common) — sharps and flats both work
c4 = Tone.from_string("C4")
cs4 = Tone.from_string("C#4")
db4 = Tone.from_string("Db4") # Same pitch as C#4
# Direct construction
d = Tone(name="D", octave=3)
@@ -21,42 +55,155 @@ Creating Tones
# With a specific system
a4 = Tone.from_string("A4", system="western")
# From a frequency (finds the nearest note)
Tone.from_frequency(440) # <Tone A4>
Tone.from_frequency(261.63) # <Tone C4>
# From a MIDI note number
Tone.from_midi(60) # <Tone C4> (middle C)
Tone.from_midi(69) # <Tone A4>
Properties
----------
.. code-block:: python
>>> c4 = Tone.from_string("C4")
>>> c4 = Tone.from_string("C4", system="western")
>>> c4.name
'C'
>>> c4.octave
4
>>> c4.full_name
'C4'
>>> str(c4)
'C4'
>>> c4.letter # Note letter without accidentals
'C'
>>> c4.midi # MIDI note number
60
>>> c4.exists # Is this note in the system?
True
Pitch and Frequency
-------------------
Every tone vibrates at a specific frequency measured in Hertz (Hz —
cycles per second). The relationship between pitch and frequency is
**logarithmic**: each octave doubles the frequency, and each semitone
multiplies by the 12th root of 2 (~1.05946).
.. code-block:: python
>>> a4 = Tone.from_string("A4", system="western")
>>> a4.frequency
440.0
>>> a4.pitch()
440.0
# Different temperaments
>>> Tone.from_string("A3", system="western").frequency
220.0 # One octave down = half the frequency
>>> Tone.from_string("C4", system="western").frequency
261.63 # Middle C
Temperament
~~~~~~~~~~~
**Temperament** is the system used to tune the intervals between notes.
Different temperaments produce slightly different frequencies for the
same note name:
- `Equal temperament <https://en.wikipedia.org/wiki/Equal_temperament>`_ (default): Every semitone has an identical
frequency ratio of 2^(1/12). This is the modern standard — it allows
free modulation between all keys but no interval is acoustically
"pure" except the octave.
- `Pythagorean temperament <https://en.wikipedia.org/wiki/Pythagorean_tuning>`_: Built entirely from pure perfect fifths
(3:2 ratio). Produces beatless fifths but introduces the "Pythagorean
comma" — a small discrepancy when 12 fifths don't quite equal 7
octaves. Used in medieval European music.
- `Quarter-comma meantone <https://en.wikipedia.org/wiki/Quarter-comma_meantone>`_: Tunes major thirds to the pure ratio of
5:4, distributing the resulting error across the fifths. Dominant in
Renaissance and Baroque music (15th18th century). Sounds beautiful
in closely related keys but "wolf intervals" make distant keys
unusable.
.. code-block:: python
>>> a4.pitch(temperament="equal")
440.0
>>> a4.pitch(temperament="pythagorean")
440.0
440.0 # A4 is always 440 (it's the reference)
# Symbolic (SymPy expression)
>>> c5 = Tone.from_string("C5", system="western")
>>> c5.pitch(temperament="equal")
523.25
>>> c5.pitch(temperament="pythagorean")
521.48 # Slightly different!
Symbolic Pitch
~~~~~~~~~~~~~~
Pass ``symbolic=True`` to get exact pitch ratios as
`SymPy <https://en.wikipedia.org/wiki/SymPy>`_ expressions instead of
floating-point approximations. This is useful for mathematical analysis,
proving tuning relationships, or comparing temperaments with exact
arithmetic.
.. code-block:: python
>>> a4 = Tone.from_string("A4", system="western")
# Equal temperament: irrational ratios (roots of 2)
>>> a4.pitch(symbolic=True)
440
>>> Tone.from_string("C5", system="western").pitch(symbolic=True)
440*2**(1/4)
Arithmetic
----------
# Pythagorean: pure rational ratios (powers of 3/2)
>>> Tone.from_string("G4", system="western").pitch(
... temperament="pythagorean", symbolic=True)
660
# Compare the major third across temperaments
>>> e4 = Tone.from_string("E4", system="western")
>>> e4.pitch(temperament="equal", symbolic=True)
440*2**(1/3)
>>> e4.pitch(temperament="pythagorean", symbolic=True)
12160/27
>>> e4.pitch(temperament="meantone", symbolic=True)
550
# Symbolic expressions can be evaluated to any precision
>>> e4.pitch(symbolic=True).evalf(50)
329.62755691286991583007431157433859631791591649985
The symbolic output reveals *why* temperaments differ: equal temperament
uses irrational numbers (roots of 2), Pythagorean uses powers of 3/2
(rational but accumulating error), and meantone tunes thirds to the
pure 5/4 ratio (sacrificing fifths).
Intervals and Arithmetic
-------------------------
An **interval** is the distance between two pitches, measured in
semitones. Intervals have both a **quantity** (number of scale steps)
and a **quality** (perfect, major, minor, augmented, diminished).
Common intervals::
Semitones Name Sound
───────── ──── ─────
0 Unison Same note
1 Minor 2nd Tense, dissonant (Jaws theme)
2 Major 2nd A whole step (Do-Re)
3 Minor 3rd Sad, dark (Greensleeves)
4 Major 3rd Happy, bright (Kumbaya)
5 Perfect 4th Open, hollow (Here Comes the Bride)
6 Tritone Unstable, tense (The Simpsons)
7 Perfect 5th Strong, stable (Star Wars)
8 Minor 6th Bittersweet
9 Major 6th Warm (My Bonnie)
10 Minor 7th Bluesy (Star Trek TOS)
11 Major 7th Dreamy, yearning
12 Octave Same note, higher
Tones support ``+`` and ``-`` operators for semitone math:
@@ -75,13 +222,69 @@ Subtracting two tones gives the semitone distance:
.. code-block:: python
>>> g4 = Tone.from_string("G4", system="western")
>>> g4 - c4 # Semitone distance
>>> g4 - c4 # Perfect fifth = 7 semitones
7
>>> c5 = Tone.from_string("C5", system="western")
>>> c5 - c4 # Octave = 12 semitones
12
Naming Intervals
~~~~~~~~~~~~~~~~
The ``interval_to`` method gives the musical name of the interval
between two tones, including compound intervals that span more than
one octave:
.. code-block:: python
>>> c4.interval_to(g4)
'perfect 5th'
>>> c4.interval_to(c4 + 4)
'major 3rd'
>>> c4.interval_to(c5)
'octave'
# Compound intervals (more than an octave)
>>> c4.interval_to(c4 + 19) # Octave + perfect 5th
'perfect 5th + 1 octave'
Transposition
~~~~~~~~~~~~~
The ``transpose`` method returns a new tone shifted by a number of
semitones — equivalent to the ``+`` operator but reads more clearly
in some contexts:
.. code-block:: python
>>> c4.transpose(7) # Same as c4 + 7
<Tone G4>
>>> c4.transpose(-2) # Two semitones down
<Tone A#3>
MIDI
~~~~
Every tone maps to a `MIDI note number <https://en.wikipedia.org/wiki/MIDI>`_
(0127), the standard for communicating with synthesizers, DAWs, and
digital instruments:
.. code-block:: python
>>> c4.midi
60 # Middle C
>>> Tone.from_string("A4", system="western").midi
69 # Concert A
# Round-trip: MIDI → Tone → MIDI
>>> Tone.from_midi(60).midi
60
Comparison and Sorting
----------------------
Tones can be compared and sorted by pitch:
Tones can be compared and sorted by pitch frequency:
.. code-block:: python
@@ -94,7 +297,93 @@ Equality checks note name and octave:
.. code-block:: python
>>> c4 == "C" # Compare with string
>>> c4 == "C" # Compare with string (name only)
True
>>> c4 == Tone(name="C", octave=4)
True
The Overtone Series
-------------------
Every tone you hear is actually a composite of many frequencies. When
a string vibrates, it doesn't just vibrate as a whole — it also vibrates
in halves, thirds, quarters, and so on, producing the `harmonic series <https://en.wikipedia.org/wiki/Harmonic_series_(music)>`_:
.. code-block:: python
>>> a4 = Tone.from_string("A4", system="western")
>>> a4.overtones(8)
[440.0, 880.0, 1320.0, 1760.0, 2200.0, 2640.0, 3080.0, 3520.0]
These harmonics correspond to musical intervals::
Harmonic Frequency Interval from fundamental
1st 440 Hz Unison (A4)
2nd 880 Hz Octave (A5)
3rd 1320 Hz Octave + perfect 5th (E6)
4th 1760 Hz Two octaves (A6)
5th 2200 Hz Two octaves + major 3rd (C#7)
6th 2640 Hz Two octaves + perfect 5th (E7)
7th 3080 Hz Two octaves + minor 7th (≈G7, slightly flat)
8th 3520 Hz Three octaves (A7)
The overtone series is why a perfect fifth sounds consonant — the 3rd
harmonic of the lower note matches the 2nd harmonic of the upper note.
It's also why the major triad (root, major 3rd, perfect 5th) feels
"natural" — these intervals appear in the first 6 harmonics.
Different instruments emphasize different harmonics, which is why a
violin and a flute playing the same note sound different. This quality
is called `timbre <https://en.wikipedia.org/wiki/Timbre>`_.
Enharmonic Equivalents
----------------------
In equal temperament, C# and Db are the same pitch (they have the
same frequency). They're called **enharmonic equivalents**. Which name
you use depends on context:
- In the key of **D major** (2 sharps), you write **C#**
- In the key of **Gb major** (6 flats), you write **Db**
The rule: each letter name should appear exactly once in a scale. The
D major scale is D E F# G A B C# — not D E Gb G A B Db, even though
F#=Gb and C#=Db.
PyTheory uses sharps by default (following the tone list ordering), but
every tone knows its enharmonic spelling:
.. code-block:: python
>>> Tone.from_string("C#4", system="western").enharmonic
'Db'
>>> Tone.from_string("A#4", system="western").enharmonic
'Bb'
# Natural notes have no enharmonic
>>> Tone.from_string("C4", system="western").enharmonic is None
True
The Circle of Fifths
--------------------
The `circle of fifths <https://en.wikipedia.org/wiki/Circle_of_fifths>`_ is the most important diagram in Western music
theory. Starting from any note and ascending by perfect fifths (7
semitones), you pass through all 12 chromatic tones before returning
to the starting note:
.. code-block:: python
>>> c4 = Tone.from_string("C4", system="western")
# Clockwise — ascending fifths (adds sharps)
>>> [t.name for t in c4.circle_of_fifths()]
['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'C#', 'G#', 'D#', 'A#', 'F']
# Counter-clockwise — ascending fourths (adds flats)
>>> [t.name for t in c4.circle_of_fourths()]
['C', 'F', 'A#', 'D#', 'G#', 'C#', 'F#', 'B', 'E', 'A', 'D', 'G']
Each step clockwise adds one sharp to the key signature; each step
counter-clockwise (ascending by fourths = 5 semitones) adds one flat.
+61 -15
View File
@@ -1,37 +1,83 @@
PyTheory: Music Theory for Humans
=================================
**PyTheory** is a Python library that makes exploring music theory approachable.
Work with tones, scales, chords, and fretboards using a clean, Pythonic API.
**PyTheory** is a Python library that makes exploring music theory
approachable and fun. Work with tones, scales, chords, keys, and
instruments using a clean, Pythonic API.
.. code-block:: python
::
from pytheory import TonedScale, Fretboard, CHARTS
$ pip install pytheory
# Build a C major scale
c_major = TonedScale(tonic="C4")["major"]
print(c_major.note_names)
# ['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
.. code-block:: pycon
# Build a triad from the scale
chord = c_major.triad(0) # C major triad
for tone in chord:
print(f"{tone}: {tone.frequency:.1f} Hz")
>>> from pytheory import Key, Chord, Tone, Fretboard
# Get guitar fingerings
fb = Fretboard.guitar()
print(CHARTS["western"]["C"].fingering(fretboard=fb))
>>> key = Key("C", "major")
>>> key.chords
['C major', 'D minor', 'E minor', 'F major',
'G major', 'A minor', 'B diminished']
>>> [c.identify() for c in key.progression("I", "V", "vi", "IV")]
['C major', 'G major', 'A minor', 'F major']
>>> Chord.from_tones("Bb", "D", "F").identify()
'Bb major'
>>> c4 = Tone.from_string("C4", system="western")
>>> c4.interval_to(c4 + 7)
'perfect 5th'
>>> fb = Fretboard.guitar()
>>> fb.chord("G")
Fingering(e=3, B=0, G=0, D=0, A=2, E=3)
It also works from the command line::
$ pytheory key G major
Key: G major
Signature: 1 sharps, 0 flats (F#)
Scale: G A B C D E F# G
...
$ pytheory chord C E G
Chord: C major
Tones: C4 E4 G4
Intervals: [4, 3]
...
$ pytheory play Am7 --synth triangle
Playing: A minor 7th (A4 C4 E4 G4)
Synth: triangle
Highlights
----------
- **Tones**: frequencies, MIDI, intervals, transposition, circle of fifths,
overtone series, 3 temperaments (equal, Pythagorean, meantone)
- **Scales**: 40+ scales across 6 musical systems — Western, Indian,
Arabic, Japanese, Blues, Javanese Gamelan
- **Chords**: 17 chord types identified automatically, Roman numeral
analysis, tension scoring, voice leading, consonance/dissonance
- **Keys**: key detection, signatures, progressions (Roman numerals and
Nashville numbers), borrowed chords, secondary dominants
- **Instruments**: 25 presets (guitar, bass, ukulele, mandolin, violin,
banjo, oud, sitar, erhu, and more) with fingering generation
- **Audio**: sine, sawtooth, and triangle wave playback + WAV export
.. toctree::
:maxdepth: 2
:caption: User Guide
guide/quickstart
guide/theory
guide/tones
guide/scales
guide/chords
guide/fretboard
guide/systems
guide/playback
guide/cli
.. toctree::
:maxdepth: 2
+46
View File
@@ -0,0 +1,46 @@
"""Identify chords from notes or guitar fingerings."""
from pytheory import Chord, Fretboard
print("=== Chord Identification from Notes ===")
print()
test_chords = [
("C", "E", "G"),
("A", "C", "E"),
("G", "B", "D", "F"),
("D", "F#", "A"),
("Bb", "D", "F"),
("E", "G#", "B"),
("C", "Eb", "Gb"),
("C", "G"),
("C", "F", "G"),
("C", "D", "G"),
]
for notes in test_chords:
chord = Chord.from_tones(*notes)
name = chord.identify() or "Unknown"
print(f" {', '.join(notes):20s}{name}")
print()
print("=== Chord Identification from Guitar Fingerings ===")
print()
fb = Fretboard.guitar()
# Common guitar chord shapes
shapes = [
("Open C", (0, 1, 0, 2, 3, 0)),
("Open G", (3, 0, 0, 0, 2, 3)),
("Open D", (2, 3, 2, 0, 0, 0)),
("Open Am", (0, 1, 2, 2, 0, 0)),
("Open Em", (0, 0, 0, 2, 2, 0)),
("Barre F", (1, 1, 2, 3, 3, 1)),
("Power E5", (0, 0, 0, 0, 2, 0)),
]
for label, positions in shapes:
f = fb.fingering(*positions)
name = f.identify() or "Unknown"
print(f" {label:12s} {f}{name}")
+52
View File
@@ -0,0 +1,52 @@
"""Analyze harmonic tension and resolution across chords."""
from pytheory import Chord
print("Chord Tension Analysis")
print("=" * 70)
print()
print(f"{'Chord':>20s} {'Tension':>8s} {'Harmony':>8s} {'Dissonance':>11s} {'Notes'}")
print(f"{'' * 20} {'' * 8} {'' * 8} {'' * 11} {'' * 15}")
chords = [
# Stable chords
"C", "Am",
# Moderate tension
"Dm7", "Cmaj7",
# High tension
"G7", "Bdim",
# Extended
"Am7", "Cmaj9",
]
for name in chords:
chord = Chord.from_name(name)
t = chord.tension
tones = " ".join(tone.name for tone in chord.tones)
print(
f"{name:>20s} {t['score']:>8.2f} {chord.harmony:>8.4f}"
f" {chord.dissonance:>11.4f} {tones}"
)
# Show the V7 → I resolution
print()
print("" * 70)
print()
print("The V7 → I resolution (the strongest pull in tonal music):")
print()
g7 = Chord.from_name("G7")
c = Chord.from_name("C")
print(f" G7 (dominant): tension={g7.tension['score']:.2f} "
f"tritones={g7.tension['tritones']} "
f"dominant_function={g7.tension['has_dominant_function']}")
print(f" C (tonic): tension={c.tension['score']:.2f} "
f"tritones={c.tension['tritones']} "
f"dominant_function={c.tension['has_dominant_function']}")
print()
print("Voice leading (G7 → C):")
for src, dst, motion in g7.voice_leading(c):
direction = "" if motion > 0 else "" if motion < 0 else "="
print(f" {src.name:3s}{dst.name:3s} ({direction} {abs(motion)} semitones)")
+34
View File
@@ -0,0 +1,34 @@
"""Visualize the circle of fifths with key signatures."""
from pytheory import Tone, Key
c = Tone.from_string("C4", system="western")
print("╔══════════════════════════════════════════════╗")
print("║ THE CIRCLE OF FIFTHS ║")
print("╠══════════════════════════════════════════════╣")
print("║ Key Sig Accidentals ║")
print("╠══════════════════════════════════════════════╣")
for tone in c.circle_of_fifths():
key = Key(tone.name, "major")
sig = key.signature
relative = key.relative
if sig["sharps"]:
mark = f'{sig["sharps"]}#'
elif sig["flats"]:
mark = f'{sig["flats"]}b'
else:
mark = "--"
accidentals = ", ".join(sig["accidentals"]) if sig["accidentals"] else "none"
print(f"{tone.name:3s} {mark:3s} {accidentals:20s} rel: {relative.tonic_name} {relative.mode:5s}")
print("╚══════════════════════════════════════════════╝")
# Show that 12 fifths returns to the start
print()
print("Proof: 12 perfect fifths cycle through all 12 tones")
names = [t.name for t in c.circle_of_fifths()]
print(f" {''.join(names)}{names[0]}")
+74
View File
@@ -0,0 +1,74 @@
"""Explore music theory with PyTheory."""
from pytheory import Key, Chord, Tone, Interval, PROGRESSIONS, Fretboard
# ── Keys and Scales ──────────────────────────────────────────────────────
key = Key("C", "major")
print(f"Key: {key}")
print(f"Notes: {key.note_names}")
print()
# ── Diatonic Harmony ─────────────────────────────────────────────────────
print("Diatonic triads:")
for i, chord in enumerate(key.scale.harmonize()):
analysis = chord.analyze("C")
print(f" {analysis:4s} {chord}")
print()
print("Diatonic seventh chords:")
for name in key.seventh_chords:
print(f" {name}")
# ── Progressions ─────────────────────────────────────────────────────────
print()
print("Common progressions in C major:")
for name, numerals in PROGRESSIONS.items():
chords = key.progression(*numerals)
chord_names = [str(c) for c in chords]
print(f" {name:20s} {''.join(chord_names)}")
# ── Intervals ────────────────────────────────────────────────────────────
print()
c4 = Tone.from_string("C4", system="western")
print("Intervals from C4:")
for semitones in range(13):
tone = c4 + semitones
name = c4.interval_to(tone)
print(f" {semitones:2d} semitones = {tone.name:3s} ({name})")
# ── Circle of Fifths ─────────────────────────────────────────────────────
print()
print("Circle of fifths:", "".join(t.name for t in c4.circle_of_fifths()))
# ── Chord Analysis ───────────────────────────────────────────────────────
print()
g7 = Chord.from_name("G7")
print(f"Chord: {g7}")
print(f" Intervals: {g7.intervals}")
print(f" Tension: {g7.tension}")
print(f" Analysis in C: {g7.analyze('C')}")
# ── Guitar Fingerings ────────────────────────────────────────────────────
print()
fb = Fretboard.guitar()
print("Guitar fingerings:")
for name in ["C", "G", "Am", "F", "Dm", "E7"]:
from pytheory import CHARTS
fingering = CHARTS["western"][name].fingering(fretboard=fb)
print(f" {name:4s} {fingering}")
# ── Overtone Series ──────────────────────────────────────────────────────
print()
a4 = Tone.from_string("A4", system="western")
print(f"Overtone series of {a4}:")
for i, hz in enumerate(a4.overtones(8), 1):
nearest = Tone.from_frequency(hz)
print(f" Harmonic {i}: {hz:8.1f} Hz ≈ {nearest.full_name}")
+74
View File
@@ -0,0 +1,74 @@
"""Explore instruments, tunings, and chord fingerings."""
from pytheory import Fretboard, CHARTS
# ── Compare Instruments ─────────────────────────────────────────────────
print("Instrument Tunings")
print("=" * 55)
instruments = [
("Guitar (standard)", Fretboard.guitar()),
("Guitar (drop D)", Fretboard.guitar("drop d")),
("Guitar (open G)", Fretboard.guitar("open g")),
("Guitar (DADGAD)", Fretboard.guitar("dadgad")),
("Bass", Fretboard.bass()),
("Ukulele", Fretboard.ukulele()),
("Mandolin", Fretboard.mandolin()),
("Violin", Fretboard.violin()),
("Banjo", Fretboard.banjo()),
("Bouzouki (Irish)", Fretboard.bouzouki()),
]
for name, fb in instruments:
tuning = " ".join(t.full_name for t in fb.tones)
print(f" {name:22s} {tuning}")
# ── Guitar Chord Chart ──────────────────────────────────────────────────
print()
print("Guitar Chord Chart (standard tuning)")
print("=" * 55)
fb = Fretboard.guitar()
chart = CHARTS["western"]
for chord_name in ["C", "G", "D", "Am", "Em", "F", "A", "E", "Dm", "G7", "C7", "Am7"]:
f = chart[chord_name].fingering(fretboard=fb)
print(f" {chord_name:5s} {f}")
# ── Capo Magic ──────────────────────────────────────────────────────────
print()
print("Capo Transposition")
print("=" * 55)
print(" Playing open chord shapes with a capo changes the key:")
print()
open_shapes = ["C", "G", "D", "Am", "Em"]
for capo_fret in range(1, 6):
fb_capo = Fretboard.guitar(capo=capo_fret)
results = []
for shape in open_shapes:
f = chart[shape].fingering(fretboard=fb_capo)
actual = f.identify() or "?"
results.append(f"{shape}{actual.split()[0]}")
print(f" Capo {capo_fret}: {', '.join(results)}")
# ── Same Chord on Different Instruments ─────────────────────────────────
print()
print("C Major on Different Instruments")
print("=" * 55)
c_chord = chart["C"]
for name, fb in [("Guitar", Fretboard.guitar()),
("Ukulele", Fretboard.ukulele()),
("Mandolin", Fretboard.mandolin()),
("Banjo", Fretboard.banjo())]:
try:
f = c_chord.fingering(fretboard=fb)
print(f" {name:12s} {f}")
except Exception:
print(f" {name:12s} (not available for this tuning)")
+93
View File
@@ -0,0 +1,93 @@
"""Learn intervals — names, sounds, and relationships."""
from pytheory import Tone, Chord, Interval
c4 = Tone.from_string("C4", system="western")
# ── Interval Reference ──────────────────────────────────────────────────
print("Interval Reference (from C4)")
print("=" * 70)
print()
print(f"{'Semitones':>10s} {'Note':>5s} {'Interval Name':>18s} {'Sound / Song'}")
print(f"{'' * 10} {'' * 5} {'' * 18} {'' * 30}")
songs = {
0: "Same note",
1: "Jaws",
2: "Happy Birthday",
3: "Greensleeves",
4: "Here Comes the Sun",
5: "Here Comes the Bride",
6: "The Simpsons",
7: "Star Wars (main theme)",
8: "Love Story",
9: "My Bonnie Lies Over the Ocean",
10: "Somewhere (West Side Story)",
11: "Take On Me (chorus)",
12: "Somewhere Over the Rainbow",
}
for semitones in range(13):
tone = c4 + semitones
name = c4.interval_to(tone)
song = songs.get(semitones, "")
print(f"{semitones:>10d} {tone.name:>5s} {name:>18s} {song}")
# ── Interval Constants ──────────────────────────────────────────────────
print()
print("Interval Constants (pytheory.Interval)")
print("=" * 40)
constants = [
("UNISON", Interval.UNISON),
("MINOR_SECOND", Interval.MINOR_SECOND),
("MAJOR_SECOND", Interval.MAJOR_SECOND),
("MINOR_THIRD", Interval.MINOR_THIRD),
("MAJOR_THIRD", Interval.MAJOR_THIRD),
("PERFECT_FOURTH", Interval.PERFECT_FOURTH),
("TRITONE", Interval.TRITONE),
("PERFECT_FIFTH", Interval.PERFECT_FIFTH),
("MINOR_SIXTH", Interval.MINOR_SIXTH),
("MAJOR_SIXTH", Interval.MAJOR_SIXTH),
("MINOR_SEVENTH", Interval.MINOR_SEVENTH),
("MAJOR_SEVENTH", Interval.MAJOR_SEVENTH),
("OCTAVE", Interval.OCTAVE),
]
for name, value in constants:
print(f" Interval.{name:16s} = {value}")
# ── Compound Intervals ─────────────────────────────────────────────────
print()
print("Compound Intervals (beyond one octave)")
print("=" * 50)
for semitones in [13, 14, 15, 16, 19, 24]:
tone = c4 + semitones
name = c4.interval_to(tone)
print(f" {semitones:2d} semitones {tone.full_name:5s} {name}")
# ── Consonance Ranking ──────────────────────────────────────────────────
print()
print("Intervals Ranked by Consonance")
print("=" * 50)
intervals = []
for semitones in range(1, 13):
tone = c4 + semitones
dyad = Chord.from_tones("C", tone.name)
name = c4.interval_to(tone)
intervals.append((dyad.harmony, dyad.dissonance, semitones, name))
# Sort by harmony score (descending)
intervals.sort(key=lambda x: x[0], reverse=True)
print(f"{'Rank':>5s} {'Interval':>18s} {'Harmony':>8s} {'Dissonance':>11s}")
print(f"{'' * 5} {'' * 18} {'' * 8} {'' * 11}")
for rank, (harmony, dissonance, _, name) in enumerate(intervals, 1):
print(f"{rank:>5d} {name:>18s} {harmony:>8.4f} {dissonance:>11.4f}")
+64
View File
@@ -0,0 +1,64 @@
"""Detect the key of a melody or chord progression."""
from pytheory import Key, Chord
print("Key Detection")
print("=" * 55)
print()
# ── Detect from Melody Notes ────────────────────────────────────────────
melodies = [
("Twinkle Twinkle", ["C", "G", "A", "F", "E", "D"]),
("Happy Birthday", ["G", "A", "B", "C", "D", "F#"]),
("Yesterday", ["F", "E", "D", "C", "Bb", "A", "G"]),
("Minor melody", ["A", "B", "C", "D", "E", "F", "G"]),
("Blues lick", ["E", "G", "A", "B", "D"]),
("Chromatic fragment", ["C", "C#", "D", "D#", "E"]),
]
print("Detecting key from melody notes:")
print()
for label, notes in melodies:
key = Key.detect(*notes)
print(f" {label:22s} {', '.join(notes):30s}{key}")
# ── Detect from Chord Progression ──────────────────────────────────────
print()
print("Detecting key from chord tones:")
print()
progressions = [
("I-IV-V", [("C", "E", "G"), ("F", "A", "C"), ("G", "B", "D")]),
("Pop in G", [("G", "B", "D"), ("D", "F#", "A"), ("E", "G", "B"), ("C", "E", "G")]),
("Jazz ii-V-I", [("D", "F", "A"), ("G", "B", "D", "F"), ("C", "E", "G", "B")]),
]
for label, chord_tones in progressions:
# Collect all unique note names
all_notes = set()
for tones in chord_tones:
all_notes.update(tones)
key = Key.detect(*all_notes)
chord_names = [Chord.from_tones(*t).identify() for t in chord_tones]
print(f" {label:15s} {''.join(chord_names):40s}{key}")
# ── All 24 Keys ─────────────────────────────────────────────────────────
print()
print("All 24 Major and Minor Keys")
print("=" * 55)
print()
for key in Key.all_keys():
sig = key.signature
acc = ", ".join(sig["accidentals"]) if sig["accidentals"] else "none"
rel = key.relative
print(
f" {str(key):12s} "
f"{sig['sharps']}# {sig['flats']}b "
f"({acc:15s}) "
f"rel: {rel}"
)
+58
View File
@@ -0,0 +1,58 @@
"""Explore a key — its chords, progressions, and relationships."""
from pytheory import Key
def explore_key(tonic, mode="major"):
key = Key(tonic, mode)
sig = key.signature
acc = ", ".join(sig["accidentals"]) or "none"
print(f"{'=' * 60}")
print(f" {key}")
print(f"{'=' * 60}")
print()
print(f" Scale: {' '.join(key.note_names)}")
print(f" Signature: {sig['sharps']} sharps, {sig['flats']} flats ({acc})")
print(f" Relative: {key.relative}")
print(f" Parallel: {key.parallel}")
print()
# Diatonic triads
print(" Diatonic Triads:")
for chord in key.scale.harmonize():
numeral = chord.analyze(tonic, mode) or "?"
print(f" {numeral:6s} {chord.identify()}")
print()
# Seventh chords
print(" Seventh Chords:")
for name in key.seventh_chords:
print(f" {name}")
print()
# Common progressions
print(" Common Progressions:")
progressions = {
"Pop": ("I", "V", "vi", "IV"),
"Blues": ("I", "IV", "V"),
"50s": ("I", "vi", "IV", "V"),
"Jazz": ("ii", "V", "I"),
}
for label, numerals in progressions.items():
chords = key.progression(*numerals)
names = [c.identify() for c in chords]
print(f" {label:8s} {''.join(numerals):20s} {''.join(names)}")
print()
# Borrowed chords
borrowed = key.borrowed_chords
if borrowed:
print(f" Borrowed from {key.parallel}:")
for name in borrowed[:4]:
print(f" {name}")
print()
# Explore several keys
for tonic, mode in [("C", "major"), ("G", "major"), ("A", "minor"), ("E", "major")]:
explore_key(tonic, mode)
+35
View File
@@ -0,0 +1,35 @@
"""Convert between MIDI note numbers, frequencies, and note names."""
from pytheory import Tone
print("MIDI ↔ Note ↔ Frequency Reference")
print("=" * 50)
print()
print(f"{'MIDI':>5s} {'Note':>5s} {'Freq (Hz)':>10s} {'Octave':>6s}")
print(f"{'' * 5} {'' * 5} {'' * 10} {'' * 6}")
# Show all notes from C2 to C7
for midi in range(36, 97):
tone = Tone.from_midi(midi)
freq = tone.frequency
print(f"{midi:>5d} {tone.full_name:>5s} {freq:>10.2f} {tone.octave:>6d}")
# Useful reference points
print()
print("Key Reference Points:")
print(f" Lowest piano note: A0 = MIDI {Tone.from_string('A0', system='western').midi}")
print(f" Middle C: C4 = MIDI {Tone.from_string('C4', system='western').midi}")
print(f" Concert A: A4 = MIDI {Tone.from_string('A4', system='western').midi}")
print(f" Highest piano note: C8 = MIDI {Tone.from_string('C8', system='western').midi}")
# Round-trip demo
print()
print("Round-trip conversions:")
for start in ["C4", "A4", "F#3", "Bb5"]:
tone = Tone.from_string(start, system="western")
midi = tone.midi
freq = tone.frequency
from_midi = Tone.from_midi(midi)
from_freq = Tone.from_frequency(freq)
print(f" {start:4s} → MIDI {midi}{from_midi.full_name:4s} | "
f"{start:4s}{freq:.2f} Hz → {from_freq.full_name}")
+68
View File
@@ -0,0 +1,68 @@
"""Explore the overtone series — nature's chord."""
from pytheory import Tone, Chord
a4 = Tone.from_string("A4", system="western")
print("The Overtone Series")
print("=" * 65)
print()
print("When you play a note, you're actually hearing many frequencies")
print("at once. The fundamental plus its integer multiples:")
print()
print(f"{'Harmonic':>9s} {'Frequency':>10s} {'Nearest Note':>13s} {'Interval from Root'}")
print(f"{'' * 9} {'' * 10} {'' * 13} {'' * 25}")
overtones = a4.overtones(16)
for i, hz in enumerate(overtones, 1):
nearest = Tone.from_frequency(hz)
if i == 1:
interval = "Fundamental"
else:
interval = a4.interval_to(nearest)
print(f"{i:>9d} {hz:>10.1f} {nearest.full_name:>13s} {interval}")
# ── Why Chords Sound Good ───────────────────────────────────────────────
print()
print("Why the Major Triad Sounds 'Natural'")
print("=" * 65)
print()
print("The first 6 harmonics contain: root, octave, 5th, 2nd octave, 3rd, 5th")
print("That's a major triad! The major chord is literally embedded in physics.")
print()
c4 = Tone.from_string("C4", system="western")
harmonics = c4.overtones(6)
harmonic_names = [Tone.from_frequency(hz).name for hz in harmonics]
unique = []
for n in harmonic_names:
if n not in unique:
unique.append(n)
print(f" First 6 harmonics of C: {', '.join(harmonic_names)}")
print(f" Unique pitch classes: {', '.join(unique)}")
print(f" C major triad: C, E, G")
print()
# ── Shared Overtones = Consonance ───────────────────────────────────────
print("Shared Overtones Between Intervals")
print("=" * 65)
print()
print("The more overtones two notes share, the more consonant they sound.")
print()
root = Tone.from_string("C4", system="western")
root_overtones = set(round(h, 1) for h in root.overtones(12))
for semitones, label in [(7, "Perfect 5th (C→G)"),
(4, "Major 3rd (C→E)"),
(5, "Perfect 4th (C→F)"),
(3, "Minor 3rd (C→Eb)"),
(6, "Tritone (C→F#)"),
(1, "Minor 2nd (C→C#)")]:
other = root + semitones
other_overtones = set(round(h, 1) for h in other.overtones(12))
shared = root_overtones & other_overtones
print(f" {label:25s} {len(shared):2d} shared overtones (of first 12)")
+81
View File
@@ -0,0 +1,81 @@
"""Build and analyze chord progressions in any key."""
from pytheory import Key, Chord
def show_progression(key, numerals, label=""):
chords = key.progression(*numerals)
if label:
print(f" {label}")
print(f" Key: {key}")
print(f" Progression: {' '.join(numerals)}")
print()
for numeral, chord in zip(numerals, chords):
t = chord.tension
print(
f" {numeral:6s} {chord.identify():20s} "
f"tension={t['score']:.2f} "
f"{'*** DOMINANT ***' if t['has_dominant_function'] else ''}"
)
print()
# ── Famous Progressions ─────────────────────────────────────────────────
print("Famous Chord Progressions")
print("=" * 65)
print()
key_c = Key("C", "major")
show_progression(key_c, ("I", "V", "vi", "IV"),
"The Pop Progression (Let It Be, No Woman No Cry, Someone Like You)")
show_progression(key_c, ("I", "vi", "IV", "V"),
"The 50s Progression (Stand By Me, Every Breath You Take)")
show_progression(key_c, ("ii", "V", "I"),
"Jazz iiVI (the backbone of jazz harmony)")
show_progression(key_c, ("I", "IV", "V", "I"),
"The Three-Chord Trick (blues, rock, country)")
# ── Same Progression in Different Keys ──────────────────────────────────
print("" * 65)
print()
print("I V vi IV in every key:")
print()
for tonic in ["C", "G", "D", "A", "E", "F", "Bb", "Eb"]:
key = Key(tonic, "major")
chords = key.progression("I", "V", "vi", "IV")
names = [c.identify() for c in chords]
print(f" {tonic} major: {''.join(names)}")
# ── Nashville Number System ─────────────────────────────────────────────
print()
print("" * 65)
print()
print("Nashville Number System:")
print(" (Same thing as Roman numerals, but with integers)")
print()
key_g = Key("G", "major")
chords = key_g.nashville(1, 5, 6, 4)
names = [c.identify() for c in chords]
print(f" G major: 1 5 6 4 → {''.join(names)}")
# ── Random Progression Generator ────────────────────────────────────────
print()
print("" * 65)
print()
print("Random 8-bar progressions:")
print()
for _ in range(3):
key = Key("C", "major")
chords = key.random_progression(8)
names = [c.identify().split()[0] for c in chords] # Just root names
print(f" | {' | '.join(names)} |")
+201 -63
View File
@@ -1,78 +1,216 @@
from time import sleep
"""Play melodies and chord progressions with PyTheory.
from pytheory import TonedScale, Tone, CHARTS, play
Requires PortAudio: brew install portaudio (macOS)
"""
from pytheory import Tone, Chord, Key, TonedScale, play, Synth
# ── Helpers ─────────────────────────────────────────────────────────────
BPM = 180
BEAT = 60_000 // BPM # ms per beat
# Add this constant at the top of the file, after the imports
EIGHTH_NOTE = 0.25
QUARTER_NOTE = 0.5
# Add scale definition after the constants
C_MAJOR = TonedScale(tonic="C4")
def play_melody(notes, synth=Synth.SINE):
"""Play a sequence of (note_string, beats) tuples."""
try:
for note, beats in notes:
if note == "REST":
import time
time.sleep(beats * BEAT / 1000)
else:
tone = Tone.from_string(note, system="western")
play(tone, synth=synth, t=int(beats * BEAT))
except KeyboardInterrupt:
print("\n Stopped.")
def play_note(note, t=0.1):
# Convert scale degree (1-7) to note name (0-based index)
scale_notes = ["C4", "D4", "E4", "F4", "G4", "A4", "B4"]
note_name = scale_notes[note - 1] # Subtract 1 because scale degrees are 1-based
tone = Tone(note_name)
play(tone, t=t * 1_000)
sleep(t)
def play_progression(chords, beats_each=2, synth=Synth.SINE):
"""Play a list of Chord objects."""
try:
for chord in chords:
name = chord.identify() or "?"
tones = " ".join(t.full_name for t in chord.tones)
print(f" {name:20s} {tones}")
play(chord, synth=synth, t=int(beats_each * BEAT))
except KeyboardInterrupt:
print("\n Stopped.")
# Twinkle Twinkle Little Star in C major
# C C G G A A G (first line)
# F F E E D D C (second line)
# G G F F E E D (third line)
# G G F F E E D (fourth line)
# C C G G A A G (fifth line)
# F F E E D D C (sixth line)
# ── Songs ───────────────────────────────────────────────────────────────
def twinkle_twinkle():
"""Twinkle Twinkle Little Star — C major."""
print("Twinkle Twinkle Little Star")
print("=" * 40)
def play_twinkle():
# Define the patterns using scale degrees instead of note names
line1 = [
(1, EIGHTH_NOTE), # C4
(1, EIGHTH_NOTE), # C4
(5, EIGHTH_NOTE), # G4
(5, EIGHTH_NOTE), # G4
(6, EIGHTH_NOTE), # A4
(6, EIGHTH_NOTE), # A4
(5, QUARTER_NOTE), # G4
]
line2 = [
(4, EIGHTH_NOTE), # F4
(4, EIGHTH_NOTE), # F4
(3, EIGHTH_NOTE), # E4
(3, EIGHTH_NOTE), # E4
(2, EIGHTH_NOTE), # D4
(2, EIGHTH_NOTE), # D4
(1, QUARTER_NOTE), # C4
]
line3 = [
(5, EIGHTH_NOTE), # G4
(5, EIGHTH_NOTE), # G4
(4, EIGHTH_NOTE), # F4
(4, EIGHTH_NOTE), # F4
(3, EIGHTH_NOTE), # E4
(3, EIGHTH_NOTE), # E4
(2, QUARTER_NOTE), # D4
melody = [
# Twinkle twinkle little star
("C4", 1), ("C4", 1), ("G4", 1), ("G4", 1),
("A4", 1), ("A4", 1), ("G4", 2),
# How I wonder what you are
("F4", 1), ("F4", 1), ("E4", 1), ("E4", 1),
("D4", 1), ("D4", 1), ("C4", 2),
# Up above the world so high
("G4", 1), ("G4", 1), ("F4", 1), ("F4", 1),
("E4", 1), ("E4", 1), ("D4", 2),
# Like a diamond in the sky
("G4", 1), ("G4", 1), ("F4", 1), ("F4", 1),
("E4", 1), ("E4", 1), ("D4", 2),
# Twinkle twinkle little star
("C4", 1), ("C4", 1), ("G4", 1), ("G4", 1),
("A4", 1), ("A4", 1), ("G4", 2),
# How I wonder what you are
("F4", 1), ("F4", 1), ("E4", 1), ("E4", 1),
("D4", 1), ("D4", 1), ("C4", 2),
]
# Construct the full melody using the patterns
melody = (
line1 # Twinkle twinkle little star
+ line2 # How I wonder what you are
+ line3 # Up above the world so high
+ line3 # Like a diamond in the sky
+ line1 # Twinkle twinkle little star
+ line2 # How I wonder what you are
)
play_melody(melody)
print("Playing Twinkle Twinkle Little Star...")
for note, duration in melody:
play_note(note, duration)
def ode_to_joy():
"""Ode to Joy — Beethoven's 9th Symphony, D major."""
print("Ode to Joy (Beethoven)")
print("=" * 40)
melody = [
# Main theme
("F#4", 1), ("F#4", 1), ("G4", 1), ("A4", 1),
("A4", 1), ("G4", 1), ("F#4", 1), ("E4", 1),
("D4", 1), ("D4", 1), ("E4", 1), ("F#4", 1),
("F#4", 1.5), ("E4", 0.5), ("E4", 2),
# Repeat with variation
("F#4", 1), ("F#4", 1), ("G4", 1), ("A4", 1),
("A4", 1), ("G4", 1), ("F#4", 1), ("E4", 1),
("D4", 1), ("D4", 1), ("E4", 1), ("F#4", 1),
("E4", 1.5), ("D4", 0.5), ("D4", 2),
]
play_melody(melody)
def happy_birthday():
"""Happy Birthday — G major."""
print("Happy Birthday")
print("=" * 40)
melody = [
# Happy birthday to you
("G4", 0.75), ("G4", 0.25), ("A4", 1), ("G4", 1),
("C5", 1), ("B4", 2),
# Happy birthday to you
("G4", 0.75), ("G4", 0.25), ("A4", 1), ("G4", 1),
("D5", 1), ("C5", 2),
# Happy birthday dear [name]
("G4", 0.75), ("G4", 0.25), ("G5", 1), ("E5", 1),
("C5", 1), ("B4", 1), ("A4", 2),
# Happy birthday to you
("F5", 0.75), ("F5", 0.25), ("E5", 1), ("C5", 1),
("D5", 1), ("C5", 2),
]
play_melody(melody)
def fur_elise():
"""Fur Elise — opening bars (A minor)."""
print("Fur Elise (opening)")
print("=" * 40)
melody = [
("E5", 0.5), ("D#5", 0.5), ("E5", 0.5), ("D#5", 0.5),
("E5", 0.5), ("B4", 0.5), ("D5", 0.5), ("C5", 0.5),
("A4", 1), ("REST", 0.5),
("C4", 0.5), ("E4", 0.5), ("A4", 0.5),
("B4", 1), ("REST", 0.5),
("E4", 0.5), ("G#4", 0.5), ("B4", 0.5),
("C5", 1), ("REST", 0.5),
("E4", 0.5), ("E5", 0.5), ("D#5", 0.5),
("E5", 0.5), ("D#5", 0.5), ("E5", 0.5), ("B4", 0.5),
("D5", 0.5), ("C5", 0.5),
("A4", 1),
]
play_melody(melody)
def pop_progression():
"""The IVviIV pop progression in C major."""
print("Pop Progression (I-V-vi-IV in C)")
print("=" * 40)
print()
key = Key("C", "major")
chords = key.progression("I", "V", "vi", "IV")
# Play it twice
play_progression(chords * 2)
def blues_in_a():
"""12-bar blues in A."""
print("12-Bar Blues in A")
print("=" * 40)
print()
key = Key("A", "major")
I = key.triad(0)
IV = key.triad(3)
V = key.triad(4)
bars = [I, I, I, I, IV, IV, I, I, V, IV, I, V]
play_progression(bars, beats_each=1.5)
def jazz_ii_v_i():
"""Jazz iiVI turnaround through several keys."""
print("Jazz ii-V-I Turnaround")
print("=" * 40)
print()
for tonic in ["C", "F", "Bb", "Eb"]:
key = Key(tonic, "major")
chords = key.progression("ii", "V", "I")
print(f" Key of {tonic}:")
play_progression(chords, beats_each=1.5)
print()
# ── Main ────────────────────────────────────────────────────────────────
SONGS = {
"1": ("Twinkle Twinkle Little Star", twinkle_twinkle),
"2": ("Ode to Joy", ode_to_joy),
"3": ("Happy Birthday", happy_birthday),
"4": ("Fur Elise (opening)", fur_elise),
"5": ("Pop Progression (I-V-vi-IV)", pop_progression),
"6": ("12-Bar Blues in A", blues_in_a),
"7": ("Jazz ii-V-I Turnaround", jazz_ii_v_i),
}
if __name__ == "__main__":
play_twinkle()
try:
print("PyTheory Song Player")
print("=" * 40)
print()
for key, (name, _) in SONGS.items():
print(f" {key}. {name}")
print()
choice = input("Pick a song (1-7, or 'all'): ").strip()
if choice == "all":
for _, (_, fn) in SONGS.items():
fn()
print()
elif choice in SONGS:
SONGS[choice][1]()
else:
print("Playing all melodies...")
for _, (_, fn) in SONGS.items():
fn()
print()
except KeyboardInterrupt:
print("\n\nBye!")
+49
View File
@@ -0,0 +1,49 @@
"""Compare equal, Pythagorean, and meantone temperaments."""
import math
from pytheory import Tone
a4 = Tone.from_string("A4", system="western")
print("Temperament Comparison")
print("=" * 75)
print()
print(f"{'Note':>5s} {'Equal (Hz)':>12s} {'Pythag (Hz)':>12s} {'Meantone (Hz)':>14s} {'P diff':>8s} {'M diff':>8s}")
print(f"{'' * 5} {'' * 12} {'' * 12} {'' * 14} {'' * 8} {'' * 8}")
for semitones in range(13):
tone = a4 + semitones
equal = tone.pitch(temperament="equal")
pyth = tone.pitch(temperament="pythagorean")
mean = tone.pitch(temperament="meantone")
# Difference in cents (1 cent = 1/100 of a semitone)
pyth_cents = 1200 * math.log2(pyth / equal) if pyth > 0 else 0
mean_cents = 1200 * math.log2(mean / equal) if mean > 0 else 0
print(
f"{tone.name:>5s} {equal:>12.3f} {pyth:>12.3f} {mean:>14.3f}"
f" {pyth_cents:>+7.1f}¢ {mean_cents:>+7.1f}¢"
)
print()
print("Key intervals to listen for:")
print()
intervals = [
(4, "Major 3rd", "Meantone is pure (5:4), equal is sharp, Pythagorean sharper still"),
(7, "Perfect 5th", "Pythagorean is pure (3:2), equal is slightly flat, meantone flatter"),
(6, "Tritone", "The 'devil's interval' — all three temperaments handle it differently"),
]
for semitones, name, note in intervals:
tone = a4 + semitones
equal = tone.pitch(temperament="equal")
pyth = tone.pitch(temperament="pythagorean")
mean = tone.pitch(temperament="meantone")
print(f" {name} ({a4.name}{tone.name}):")
print(f" Equal: {equal:.3f} Hz | Pythagorean: {pyth:.3f} Hz | Meantone: {mean:.3f} Hz")
print(f" {note}")
print()
+677
View File
@@ -0,0 +1,677 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# PyTheory: Music Theory for Humans\n",
"\n",
"A hands-on tutorial exploring music theory with Python.\n",
"\n",
"PyTheory lets you reason about tones, scales, chords, and progressions\n",
"using an intuitive, Pythonic API. Whether you're a musician who codes\n",
"or a coder who plays music, this library gives you the building blocks\n",
"to explore harmony, composition, and world music systems."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 1. Getting Started\n",
"\n",
"Everything begins with a **Tone** -- the fundamental unit of music.\n",
"A tone has a name (like `C`, `F#`, or `Bb`), an optional octave number,\n",
"and a frequency in Hz computed from equal temperament tuning (A4 = 440 Hz)."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"from pytheory import Tone, TonedScale, Key, Chord, Fretboard, CHARTS, Interval\n",
"from pytheory import analyze_progression\n",
"from pytheory.scales import PROGRESSIONS"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Create tones with octave numbers (scientific pitch notation)\n",
"middle_c = Tone.from_string(\"C4\")\n",
"concert_a = Tone.from_string(\"A4\")\n",
"\n",
"print(f\"Middle C: {middle_c} -> {middle_c.frequency:.2f} Hz\")\n",
"print(f\"Concert A: {concert_a} -> {concert_a.frequency:.2f} Hz\")\n",
"print(f\"MIDI note: {middle_c.midi}\")\n",
"print(f\"Is natural? {middle_c.is_natural}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Create tones from frequencies or MIDI numbers\n",
"from_hz = Tone.from_frequency(440.0)\n",
"from_midi = Tone.from_midi(60)\n",
"\n",
"print(f\"440 Hz -> {from_hz}\")\n",
"print(f\"MIDI 60 -> {from_midi}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Explore the harmonic series -- the physics behind consonance\n",
"c3 = Tone.from_string(\"C3\")\n",
"harmonics = c3.overtones(8)\n",
"print(f\"Harmonic series of {c3} ({c3.frequency:.1f} Hz):\")\n",
"for i, hz in enumerate(harmonics, 1):\n",
" print(f\" Harmonic {i}: {hz:.1f} Hz\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 2. Tone Arithmetic\n",
"\n",
"Tones support arithmetic operations. Adding an integer to a tone raises it\n",
"by that many **semitones** (half steps). Subtracting two tones gives the\n",
"semitone distance between them. You can also compare tones by pitch."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"c4 = Tone.from_string(\"C4\")\n",
"\n",
"# Add semitones: C + 4 semitones = E (a major third)\n",
"e4 = c4 + 4\n",
"g4 = c4 + Interval.PERFECT_FIFTH\n",
"print(f\"{c4} + 4 semitones = {e4}\")\n",
"print(f\"{c4} + perfect 5th = {g4}\")\n",
"\n",
"# Subtract to find interval distance\n",
"distance = g4 - c4\n",
"print(f\"\\nDistance from {c4} to {g4}: {distance} semitones\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Name the interval between two tones\n",
"print(f\"{c4} -> {e4}: {c4.interval_to(e4)}\")\n",
"print(f\"{c4} -> {g4}: {c4.interval_to(g4)}\")\n",
"\n",
"c5 = Tone.from_string(\"C5\")\n",
"print(f\"{c4} -> {c5}: {c4.interval_to(c5)}\")\n",
"\n",
"# Compare tones by pitch\n",
"print(f\"\\n{c4} < {g4}? {c4 < g4}\")\n",
"print(f\"{c4} == {c4}? {c4 == c4}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# The circle of fifths -- the backbone of Western harmony\n",
"c = Tone.from_string(\"C4\")\n",
"fifths = c.circle_of_fifths()\n",
"print(\"Circle of fifths from C:\")\n",
"print(\" -> \".join(str(t) for t in fifths))"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 3. Scales and Modes\n",
"\n",
"A **scale** is a collection of tones arranged in ascending order.\n",
"The `TonedScale` class provides access to dozens of scales from a given tonic.\n",
"\n",
"**Modes** are rotations of the same set of intervals. The seven modes of the\n",
"major scale each have a distinct character:\n",
"\n",
"| Mode | Character |\n",
"|------------|--------------------|\n",
"| Ionian | Bright, happy |\n",
"| Dorian | Jazzy, soulful |\n",
"| Phrygian | Spanish, dark |\n",
"| Lydian | Dreamy, floating |\n",
"| Mixolydian | Bluesy, rock |\n",
"| Aeolian | Sad, natural minor |\n",
"| Locrian | Tense, unstable |"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Build a scale from a tonic\n",
"ts = TonedScale(tonic=\"C4\")\n",
"\n",
"# See all available scale names\n",
"print(\"Available scales:\")\n",
"for name in ts.scales:\n",
" print(f\" {name}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Get a specific scale and iterate its tones\n",
"c_major = ts[\"major\"]\n",
"print(f\"C major: {c_major.note_names}\")\n",
"\n",
"c_minor = ts[\"minor\"]\n",
"print(f\"C minor: {c_minor.note_names}\")\n",
"\n",
"# Check if a note belongs to the scale\n",
"print(f\"\\nIs F# in C major? {'F#' in c_major}\")\n",
"print(f\"Is G in C major? {'G' in c_major}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": "from pytheory.scales import Scale\n\n# Transpose a scale\nd_major = c_major.transpose(2)\nprint(f\"D major (C major transposed up 2): {d_major.note_names}\")\n\n# Detect a scale from a set of notes\nresult = Scale.detect(\"A\", \"B\", \"C#\", \"D\", \"E\", \"F#\", \"G#\")\nprint(f\"\\nDetected scale: {result}\")",
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 4. The Key Class\n",
"\n",
"A **Key** is the most convenient entry point for working with harmony.\n",
"It wraps a tonic and mode, giving you instant access to scales, diatonic\n",
"chords, key signatures, and related keys."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"key = Key(\"C\", \"major\")\n",
"\n",
"print(f\"Key: {key}\")\n",
"print(f\"Notes: {key.note_names}\")\n",
"print(f\"Signature: {key.signature}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Diatonic triads -- the seven chords built from the scale\n",
"print(\"Diatonic triads in C major:\")\n",
"for i, name in enumerate(key.chords, 1):\n",
" print(f\" {i}. {name}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Seventh chords add richness and color\n",
"print(\"Diatonic seventh chords in C major:\")\n",
"for i, name in enumerate(key.seventh_chords, 1):\n",
" print(f\" {i}. {name}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Related keys\n",
"print(f\"Relative minor of C major: {key.relative}\")\n",
"print(f\"Parallel minor of C major: {key.parallel}\")\n",
"\n",
"# Key signatures for sharp and flat keys\n",
"for tonic in [\"G\", \"D\", \"F\", \"Bb\"]:\n",
" k = Key(tonic, \"major\")\n",
" sig = k.signature\n",
" print(f\"{k}: {sig['sharps']} sharps, {sig['flats']} flats -> {sig['accidentals']}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 5. Chord Analysis\n",
"\n",
"Chords can be created from note names, intervals, chord symbols, or MIDI.\n",
"PyTheory can identify chord quality, measure tension and consonance,\n",
"and compute optimal voice leading between chords."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Multiple ways to create chords\n",
"c_major_chord = Chord.from_tones(\"C\", \"E\", \"G\")\n",
"g7 = Chord.from_intervals(\"G\", 4, 7, 10)\n",
"am = Chord.from_name(\"Am\")\n",
"\n",
"print(f\"{c_major_chord} (intervals: {c_major_chord.intervals})\")\n",
"print(f\"{g7} (intervals: {g7.intervals})\")\n",
"print(f\"{am} (intervals: {am.intervals})\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Analyze harmonic tension\n",
"# The dominant 7th chord is the most tension-filled chord in tonal music\n",
"print(\"Tension analysis:\")\n",
"for chord in [c_major_chord, am, g7]:\n",
" t = chord.tension\n",
" print(f\" {chord.identify():20s} -> score={t['score']:.2f}, \"\n",
" f\"tritones={t['tritones']}, dominant={t['has_dominant_function']}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Consonance vs dissonance (psychoacoustic measures)\n",
"print(f\"{'Chord':20s} {'Harmony':>10s} {'Dissonance':>12s}\")\n",
"print(\"-\" * 44)\n",
"for chord in [c_major_chord, am, g7]:\n",
" print(f\"{chord.identify():20s} {chord.harmony:10.4f} {chord.dissonance:12.4f}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Voice leading: how individual notes move between chords\n",
"f_major = Chord.from_tones(\"F\", \"A\", \"C\")\n",
"vl = c_major_chord.voice_leading(f_major)\n",
"\n",
"print(f\"Voice leading: {c_major_chord.identify()} -> {f_major.identify()}\")\n",
"for src, dst, movement in vl:\n",
" direction = \"up\" if movement > 0 else \"down\" if movement < 0 else \"stays\"\n",
" print(f\" {src} -> {dst} ({movement:+d} semitones, {direction})\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Inversions rearrange chord voicings\n",
"print(f\"Root position: {[t.full_name for t in c_major_chord.tones]}\")\n",
"print(f\"1st inversion: {[t.full_name for t in c_major_chord.inversion(1).tones]}\")\n",
"print(f\"2nd inversion: {[t.full_name for t in c_major_chord.inversion(2).tones]}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 6. Chord Progressions\n",
"\n",
"Chord progressions are the harmonic backbone of songs. PyTheory supports\n",
"both **Roman numeral** analysis (classical/jazz) and the **Nashville number\n",
"system** (studio shorthand). It also ships with common progressions built in."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"key = Key(\"G\", \"major\")\n",
"\n",
"# Build a progression from Roman numerals\n",
"prog = key.progression(\"I\", \"V\", \"vi\", \"IV\")\n",
"print(\"I - V - vi - IV in G major (the 'four chord song'):\")\n",
"for chord in prog:\n",
" print(f\" {chord.identify()}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Nashville number system -- same thing, Arabic numerals\n",
"nashville = key.nashville(1, 5, 6, 4)\n",
"print(\"Nashville 1-5-6-4 in G major:\")\n",
"for chord in nashville:\n",
" print(f\" {chord.identify()}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Browse the built-in progression library\n",
"print(\"Built-in progressions:\")\n",
"for name, numerals in PROGRESSIONS.items():\n",
" print(f\" {name:25s} -> {' '.join(numerals)}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Analyze an existing chord progression\n",
"chords = [Chord.from_name(\"C\"), Chord.from_name(\"Am\"),\n",
" Chord.from_name(\"F\"), Chord.from_name(\"G\")]\n",
"numerals = analyze_progression(chords, key=\"C\")\n",
"print(\"Progression analysis in C:\")\n",
"for chord, numeral in zip(chords, numerals):\n",
" print(f\" {chord.identify():15s} -> {numeral}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 7. World Music Systems\n",
"\n",
"Music theory extends far beyond Western harmony. PyTheory includes scale\n",
"systems from several traditions:\n",
"\n",
"- **Indian** (raga/thaat) -- 10 parent scales covering all of Hindustani music\n",
"- **Arabic** (maqam) -- modal systems with characteristic augmented seconds\n",
"- **Japanese** -- pentatonic scales used in koto, shamisen, and folk music\n",
"- **Blues** -- the scales that built American popular music\n",
"- **Gamelan** -- Javanese/Balinese tuning systems (12-TET approximations)"
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"from pytheory import SYSTEMS\n",
"\n",
"# Indian thaat system\n",
"indian = TonedScale(tonic=\"C4\", system=SYSTEMS[\"indian\"])\n",
"print(\"Indian thaats from C:\")\n",
"for name in indian.scales:\n",
" scale = indian[name]\n",
" print(f\" {name:12s} -> {scale.note_names}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Arabic maqam -- the Hijaz scale has a distinctive augmented 2nd\n",
"arabic = TonedScale(tonic=\"D4\", system=SYSTEMS[\"arabic\"])\n",
"hijaz = arabic[\"hijaz\"]\n",
"print(f\"Maqam Hijaz from D: {hijaz.note_names}\")\n",
"\n",
"# Japanese hirajoshi -- hauntingly beautiful pentatonic\n",
"japanese = TonedScale(tonic=\"A4\", system=SYSTEMS[\"japanese\"])\n",
"hirajoshi = japanese[\"hirajoshi\"]\n",
"print(f\"Hirajoshi from A: {hirajoshi.note_names}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Blues scales -- the foundation of rock, jazz, and R&B\n",
"blues = TonedScale(tonic=\"A4\", system=SYSTEMS[\"blues\"])\n",
"print(\"Blues scales from A:\")\n",
"for name in blues.scales:\n",
" scale = blues[name]\n",
" print(f\" {name:20s} -> {scale.note_names}\")\n",
"\n",
"# Gamelan -- approximations of non-Western tuning\n",
"gamelan = TonedScale(tonic=\"C4\", system=SYSTEMS[\"gamelan\"])\n",
"slendro = gamelan[\"slendro\"]\n",
"print(f\"\\nGamelan slendro from C: {slendro.note_names}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 8. Guitar and Instruments\n",
"\n",
"The `Fretboard` class models stringed instruments. You can look up\n",
"chord fingerings, render tab diagrams, apply a capo, and visualize\n",
"scale patterns across the neck."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Standard guitar fretboard\n",
"guitar = Fretboard.guitar()\n",
"print(f\"Standard tuning: {guitar}\")\n",
"\n",
"# Look up chord fingerings from the chart\n",
"c_chart = CHARTS[\"western\"][\"C\"]\n",
"print(f\"\\n{c_chart.tab(fretboard=guitar)}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Show several common chord shapes\n",
"for chord_name in [\"G\", \"Am\", \"Em\", \"D\"]:\n",
" chart = CHARTS[\"western\"][chord_name]\n",
" print(chart.tab(fretboard=guitar))\n",
" print()"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Apply a capo -- raises all strings by N semitones\n",
"capo2 = Fretboard.guitar(capo=2)\n",
"print(f\"Capo on fret 2: {capo2}\")\n",
"print(\"Playing 'G shape' with capo 2 = A major voicing\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Scale diagram -- see where notes fall on the neck\n",
"c_major_scale = TonedScale(tonic=\"C4\")[\"major\"]\n",
"diagram = guitar.scale_diagram(c_major_scale, frets=12)\n",
"print(\"C major scale on guitar:\")\n",
"print(diagram)"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 9. Building a Song\n",
"\n",
"Let's put it all together: pick a key, explore its chords, build a\n",
"progression, and analyze the harmonic movement."
]
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Step 1: Choose a key\n",
"song_key = Key(\"E\", \"minor\")\n",
"print(f\"Key: {song_key}\")\n",
"print(f\"Notes: {song_key.note_names}\")\n",
"print(f\"Relative major: {song_key.relative}\")\n",
"print(f\"Signature: {song_key.signature}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Step 2: See what chords are available\n",
"print(\"Diatonic chords in E minor:\")\n",
"for i, name in enumerate(song_key.chords, 1):\n",
" print(f\" {i}. {name}\")\n",
"\n",
"print(\"\\nBorrowed chords from E major:\")\n",
"for name in song_key.borrowed_chords[:4]:\n",
" print(f\" {name}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Step 3: Build a progression\n",
"# i - VI - III - VII is a classic minor key progression\n",
"prog = song_key.progression(\"i\", \"VI\", \"III\", \"VII\")\n",
"\n",
"print(\"Progression: i - VI - III - VII\")\n",
"for chord in prog:\n",
" name = chord.identify()\n",
" numeral = chord.analyze(\"E\", \"minor\")\n",
" t = chord.tension\n",
" print(f\" {name:18s} [{numeral:5s}] tension={t['score']:.2f}\")"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Step 4: Analyze voice leading through the progression\n",
"print(\"Voice leading through the progression:\\n\")\n",
"for i in range(len(prog) - 1):\n",
" src = prog[i]\n",
" dst = prog[i + 1]\n",
" vl = src.voice_leading(dst)\n",
" total = sum(abs(m) for _, _, m in vl)\n",
" print(f\"{src.identify()} -> {dst.identify()} (total movement: {total} semitones)\")\n",
" for s, d, m in vl:\n",
" print(f\" {s} -> {d} ({m:+d})\")\n",
" print()"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Step 5: Show the chords on guitar\n",
"guitar = Fretboard.guitar()\n",
"chord_names = [\"Em\", \"C\", \"G\", \"D\"]\n",
"\n",
"print(\"Guitar charts for the progression:\\n\")\n",
"for name in chord_names:\n",
" chart = CHARTS[\"western\"][name]\n",
" print(chart.tab(fretboard=guitar))\n",
" print()"
],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
"metadata": {},
"source": [
"# Bonus: Detect the key from a set of notes\n",
"detected = Key.detect(\"E\", \"G\", \"A\", \"B\", \"D\")\n",
"print(f\"Key detected from [E, G, A, B, D]: {detected}\")\n",
"\n",
"# Secondary dominant -- adds harmonic color\n",
"v_of_v = song_key.secondary_dominant(5)\n",
"print(f\"\\nSecondary dominant V/V in E minor: {v_of_v.identify()}\")\n",
"print(f\"Tension score: {v_of_v.tension['score']:.2f}\")"
],
"outputs": [],
"execution_count": null
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.12.0"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+68
View File
@@ -0,0 +1,68 @@
"""Explore scales from six musical traditions around the world."""
from pytheory import TonedScale
systems = [
("western", "C4", [
("major", "The foundation of Western tonal music"),
("minor", "Natural minor — dark and introspective"),
("harmonic minor", "Raised 7th — classical, Middle Eastern flavor"),
("dorian", "Jazz, funk, soul (So What, Scarborough Fair)"),
("mixolydian", "Blues, rock (Norwegian Wood, Sweet Home Alabama)"),
("phrygian", "Flamenco, metal (White Rabbit)"),
("lydian", "Dreamy, floating (The Simpsons theme)"),
]),
("indian", "Sa4", [
("bilawal", "Equivalent to Western major scale"),
("bhairav", "Morning raga — devotional, meditative"),
("kafi", "Equivalent to Dorian mode — romantic, earthy"),
("bhairavi", "Equivalent to Phrygian — melancholic, devotional"),
("kalyan", "Equivalent to Lydian — serene, uplifting"),
]),
("arabic", "Do4", [
("ajam", "Equivalent to Western major scale"),
("hijaz", "The quintessential 'Middle Eastern' sound"),
("bayati", "Contemplative, spiritual — most common maqam"),
("rast", "Bright, festive — the 'mother' of maqamat"),
("nahawand", "Equivalent to Western minor — melancholic"),
]),
("japanese", "C4", [
("hirajoshi", "Haunting pentatonic — koto music"),
("in", "Dark pentatonic — court music, Buddhist chant"),
("yo", "Bright pentatonic — folk songs, festival music"),
("iwato", "Sparse, mysterious — zen atmosphere"),
("kumoi", "Gentle pentatonic — lyrical, nostalgic"),
("ritsu", "Elegant heptatonic — gagaku court music"),
]),
("blues", "C4", [
("blues", "The 6-note blues scale with the 'blue note'"),
("minor pentatonic", "The backbone of rock guitar solos"),
("major pentatonic", "Bright, open — country, folk, pop"),
]),
("gamelan", "nem4", [
("slendro", "5-note near-equal division — metallic, shimmering"),
("pelog", "7-note unequal — mysterious, otherworldly"),
("pelog nem", "Pelog mode on nem — the most common mode"),
("pelog barang", "Pelog mode on barang — bright, festive"),
]),
]
for system_name, tonic, scales in systems:
print(f"{'' * 65}")
print(f" {system_name.upper()}")
print(f"{'' * 65}")
ts = TonedScale(tonic=tonic, system=system_name)
for scale_name, description in scales:
try:
scale = ts[scale_name]
notes = " ".join(scale.note_names)
print(f" {scale_name:20s} {notes}")
print(f" {'':20s} {description}")
print()
except (KeyError, IndexError, ValueError):
print(f" {scale_name:20s} (not available)")
print()
print(f"{'' * 65}")
+30 -2
View File
@@ -1,10 +1,25 @@
[project]
name = "pytheory"
version = "0.2.0"
version = "0.7.0"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
authors = [
{ name = "Kenneth Reitz", email = "me@kennethreitz.org" },
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Intended Audience :: Education",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Sound/Audio :: Analysis",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
dependencies = [
"pytuning",
"numeral",
@@ -12,9 +27,22 @@ dependencies = [
"scipy",
]
[project.urls]
Homepage = "https://github.com/kennethreitz/pytheory"
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"]
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["pytheory"]
+20 -7
View File
@@ -1,13 +1,26 @@
from math import ceil, floor
"""PyTheory: Music Theory for Humans."""
from .tones import Tone
__version__ = "0.7.0"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
from .scales import Scale, TonedScale
from .chords import Chord, Fretboard
from .charts import CHARTS, charts_for_fretboard
from .scales import Scale, TonedScale, Key, PROGRESSIONS
from .chords import Chord, Fretboard, analyze_progression
from .charts import CHARTS, Fingering, charts_for_fretboard
try:
from .play import play, Synth
from .play import play, save, Synth
except OSError:
# sounddevice requires PortAudio; gracefully degrade if unavailable
play = None
save = None
Synth = None
# Aliases for discoverability.
Note = Tone
__all__ = [
"Tone", "Note", "Interval", "Scale", "TonedScale", "Key",
"PROGRESSIONS", "Chord", "Fretboard", "Fingering", "analyze_progression",
"System", "SYSTEMS", "CHARTS", "charts_for_fretboard",
"play", "save", "Synth",
]
+301 -16
View File
@@ -21,7 +21,84 @@ TONES = {
("F#", "Gb"),
("G",),
("G#", "Ab"),
]
],
# Indian classical (Hindustani) system.
# Ordered A-based to match Western index positions (Sa = index 3 = C).
"indian": [
("Dha",), # A — shuddha dhaivat
("komal Ni",), # Bb — komal nishad
("Ni",), # B — shuddha nishad
("Sa",), # C — shadja
("komal Re",), # Db — komal rishabh
("Re",), # D — shuddha rishabh
("komal Ga",), # Eb — komal gandhar
("Ga",), # E — shuddha gandhar
("Ma",), # F — shuddha madhyam
("tivra Ma",), # F# — tivra madhyam
("Pa",), # G — pancham
("komal Dha",), # Ab — komal dhaivat
],
# Arabic maqam system — Arabic solfège names.
"arabic": [
("La",), # A
("Sib",), # Bb — Si bemol
("Si",), # B
("Do",), # C
("Reb",), # Db — Re bemol
("Re",), # D
("Mib",), # Eb — Mi bemol
("Mi",), # E
("Fa",), # F
("Fa#",), # F#
("Sol",), # G
("Solb",), # Ab — Sol bemol
],
# Japanese system — uses Western names; scales are the unique part.
"japanese": [
("A",),
("A#", "Bb"),
("B",),
("C",),
("C#", "Db"),
("D",),
("D#", "Eb"),
("E",),
("F",),
("F#", "Gb"),
("G",),
("G#", "Ab"),
],
# Blues/Pentatonic — Western names with blues and pentatonic scales.
"blues": [
("A",),
("A#", "Bb"),
("B",),
("C",),
("C#", "Db"),
("D",),
("D#", "Eb"),
("E",),
("F",),
("F#", "Gb"),
("G",),
("G#", "Ab"),
],
# Javanese gamelan — pelog approximation in 12-TET.
# True gamelan uses non-Western intonation; these are closest 12-TET fits.
"gamelan": [
("nem",), # A — 6
("pi",), # Bb — 7 (barang in some)
("barang",), # B — 7
("ji",), # C — 1
("ro-",), # Db — 2b
("ro",), # D — 2
("lu-",), # Eb — 3b
("lu",), # E — 3
("pat",), # F — 4
("pat+",), # F# — 4#
("mo",), # G — 5
("nem-",), # Ab — 6b
],
}
DEGREES = {
@@ -34,7 +111,53 @@ DEGREES = {
("submediant", ("aeolian", "lydian")),
("leading tone", ("locrian", "mixolydian")),
("octave", ("ionian", "aeolian")),
]
],
"indian": [
("shadja", ()), # Sa — the tonic
("rishabh", ()), # Re — 2nd
("gandhar", ()), # Ga — 3rd
("madhyam", ()), # Ma — 4th
("pancham", ()), # Pa — 5th
("dhaivat", ()), # Dha — 6th
("nishad", ()), # Ni — 7th
("saptak", ()), # Sa — octave
],
"arabic": [
("qarar", ()), # 1st — root
("nawa", ()), # 2nd
("thalth", ()), # 3rd
("arba", ()), # 4th
("khamis", ()), # 5th
("sadis", ()), # 6th
("sabi", ()), # 7th
("jawab", ()), # octave
],
"japanese": [
("ichi", ()), # 1st
("ni", ()), # 2nd
("san", ()), # 3rd
("shi", ()), # 4th
("go", ()), # 5th
("roku", ()), # 6th
],
"blues": [
("tonic", ()),
("supertonic", ()),
("mediant", ()),
("subdominant", ()),
("dominant", ()),
("submediant", ()),
("subtonic", ()),
],
"gamelan": [
("ji", ()), # 1
("ro", ()), # 2
("lu", ()), # 3
("pat", ()), # 4
("mo", ()), # 5
("nem", ()), # 6
("pi", ()), # 7
],
}
SCALES = {
@@ -52,20 +175,182 @@ SCALES = {
# "melodic minor": {"minor": True, "melodic": True, "hemitonic": True},
},
],
# TODO: understand this
# "hexatonic": (
# 6,
# {
# # name, arguments to scale generator.
# "wholetone": {},
# "augmented": {},
# "prometheus": {},
# "blues": {},
# },
# ),
# "pentatonic": (5, {}),
# "tetratonic": (4, {}),
# "monotonic": (1, {"monotonic": {"hemitonic": False}}),
}
}
# Indian scales — the 10 thaats of Hindustani classical music.
# Each thaat defines a parent scale from which ragas are derived.
INDIAN_SCALES = {
12: {
"chromatic": (12, {}),
"thaat": [
7,
{
# Bilawal = Western major / Ionian
"bilawal": {"intervals": (2, 2, 1, 2, 2, 2, 1)},
# Khamaj = Western Mixolydian
"khamaj": {"intervals": (2, 2, 1, 2, 2, 1, 2)},
# Kafi = Western Dorian
"kafi": {"intervals": (2, 1, 2, 2, 2, 1, 2)},
# Asavari = Western natural minor / Aeolian
"asavari": {"intervals": (2, 1, 2, 2, 1, 2, 2)},
# Bhairavi = Western Phrygian
"bhairavi": {"intervals": (1, 2, 2, 2, 1, 2, 2)},
# Kalyan = Western Lydian
"kalyan": {"intervals": (2, 2, 2, 1, 2, 2, 1)},
# Bhairav — unique to Indian music (no Western equivalent)
# Sa re Ga Ma Pa dha Ni
"bhairav": {"intervals": (1, 3, 1, 2, 1, 3, 1)},
# Poorvi — unique to Indian music
# Sa re Ga tivra-Ma Pa dha Ni
"poorvi": {"intervals": (1, 3, 2, 1, 1, 3, 1)},
# Marwa — unique to Indian music
# Sa re Ga tivra-Ma Pa Dha Ni
"marwa": {"intervals": (1, 3, 2, 1, 2, 2, 1)},
# Todi — unique to Indian music
# Sa re komal-Ga tivra-Ma Pa dha Ni
"todi": {"intervals": (1, 2, 3, 1, 1, 3, 1)},
},
],
}
}
# Arabic maqam scales (12-TET approximations).
# True maqam uses quarter-tones; these are the closest 12-tone equivalents.
ARABIC_SCALES = {
12: {
"chromatic": (12, {}),
"maqam": [
7,
{
# Ajam = Western major
"ajam": {"intervals": (2, 2, 1, 2, 2, 2, 1)},
# Nahawand = Western harmonic minor
"nahawand": {"intervals": (2, 1, 2, 2, 1, 3, 1)},
# Kurd = Western Phrygian
"kurd": {"intervals": (1, 2, 2, 2, 1, 2, 2)},
# Hijaz — augmented 2nd between 2nd and 3rd degrees
"hijaz": {"intervals": (1, 3, 1, 2, 1, 2, 2)},
# Nikriz — augmented 2nd between 3rd and 4th
"nikriz": {"intervals": (2, 1, 3, 1, 2, 1, 2)},
# Bayati (12-TET approx) — true bayati has quarter-flat 2nd
"bayati": {"intervals": (1, 2, 2, 2, 1, 2, 2)},
# Rast (12-TET approx) — true rast has quarter-flat 3rd and 7th
"rast": {"intervals": (2, 1, 2, 2, 2, 1, 2)},
# Saba (12-TET approx) — true saba has quarter-flat 2nd
"saba": {"intervals": (1, 2, 1, 3, 1, 2, 2)},
# Sikah (12-TET approx) — true sikah starts on quarter-flat
"sikah": {"intervals": (1, 2, 2, 2, 1, 2, 2)},
# Jiharkah
"jiharkah": {"intervals": (2, 2, 1, 2, 2, 1, 2)},
},
],
}
}
# Japanese pentatonic scales.
JAPANESE_SCALES = {
12: {
"chromatic": (12, {}),
"pentatonic": [
5,
{
# Hirajoshi — the most well-known Japanese scale
# C D Eb G Ab
"hirajoshi": {"intervals": (2, 1, 4, 1, 4)},
# In (Miyako-bushi) — used in koto music
# C Db F G Ab
"in": {"intervals": (1, 4, 2, 1, 4)},
# Yo — folk music scale
# C D F G Bb
"yo": {"intervals": (2, 3, 2, 3, 2)},
# Iwato — dark, dissonant pentatonic
# C Db F Gb Bb
"iwato": {"intervals": (1, 4, 1, 4, 2)},
# Kumoi — similar to minor pentatonic
# C D Eb G A
"kumoi": {"intervals": (2, 1, 4, 2, 3)},
# Insen — modern Japanese scale
# C Db F G Bb
"insen": {"intervals": (1, 4, 2, 3, 2)},
},
],
"heptatonic": [
7,
{
# Ritsu — gagaku court music scale
# C D Eb F G A Bb (= Dorian)
"ritsu": {"intervals": (2, 1, 2, 2, 2, 1, 2)},
# Ryo — gagaku court music scale
# C D E F# G A B (= Lydian)
"ryo": {"intervals": (2, 2, 2, 1, 2, 2, 1)},
},
],
}
}
# Blues and pentatonic scales — foundational to American music.
BLUES_SCALES = {
12: {
"chromatic": (12, {}),
"pentatonic": [
5,
{
# Major pentatonic — C D E G A
"major pentatonic": {"intervals": (2, 2, 3, 2, 3)},
# Minor pentatonic — C Eb F G Bb
"minor pentatonic": {"intervals": (3, 2, 2, 3, 2)},
},
],
"hexatonic": [
6,
{
# Blues scale — C Eb F F# G Bb
"blues": {"intervals": (3, 2, 1, 1, 3, 2)},
# Major blues — C D D# E G A
"major blues": {"intervals": (2, 1, 1, 3, 2, 3)},
},
],
"heptatonic": [
7,
{
# Mixolydian (dominant blues sound) — C D E F G A Bb
"dominant": {"intervals": (2, 2, 1, 2, 2, 1, 2)},
# Dorian (minor blues/jazz) — C D Eb F G A Bb
"minor": {"intervals": (2, 1, 2, 2, 2, 1, 2)},
},
],
}
}
# Javanese gamelan scales — 12-TET approximations.
# True gamelan tuning varies between ensembles and does not conform
# to equal temperament. These approximations capture the melodic
# character of the scales.
GAMELAN_SCALES = {
12: {
"chromatic": (12, {}),
"pentatonic": [
5,
{
# Slendro — roughly equal 5-tone division of the octave
# Approximated as: C D F G Bb
"slendro": {"intervals": (2, 3, 2, 3, 2)},
# Pelog pathet nem — C Db E F G (approx)
"pelog nem": {"intervals": (1, 3, 1, 2, 5)},
# Pelog pathet barang — C Db E F# B (approx)
"pelog barang": {"intervals": (1, 3, 3, 4, 1)},
# Pelog pathet lima — C Db E F Ab (approx)
"pelog lima": {"intervals": (1, 3, 1, 3, 4)},
},
],
"heptatonic": [
7,
{
# Full pelog — all 7 tones: C Db E F G Ab B (approx)
"pelog": {"intervals": (1, 3, 1, 2, 1, 3, 1)},
},
],
}
}
+127 -2
View File
@@ -1,4 +1,5 @@
import itertools
from typing import Optional
from .systems import SYSTEMS
from .tones import Tone
@@ -6,6 +7,106 @@ from .tones import Tone
QUALITIES = ("", "maj", "m", "5", "7", "9", "dim", "m6", "m7", "m9", "maj7", "maj9")
MAX_FRET = 7
class Fingering:
"""A chord fingering labeled with string names.
Provides both index and named access to fret positions, making it
clear which string each position corresponds to.
Example::
>>> f = Fingering(positions=(0, 3, 2, 0, 1, 0),
... string_names=('E', 'A', 'D', 'G', 'B', 'e'))
>>> f
Fingering(E=0, A=3, D=2, G=0, B=1, e=0)
>>> f['A']
3
>>> f[1]
3
"""
def __init__(self, positions: tuple, string_names: tuple[str, ...], *, fretboard=None) -> None:
self.positions = tuple(positions)
self._fretboard = fretboard
# Disambiguate duplicate names: for standard guitar tuning
# (high-to-low), the first occurrence of a duplicate becomes
# lowercase (e.g. high E → 'e') while the last keeps uppercase.
from collections import Counter
name_counts = Counter(string_names)
seen: dict[str, int] = {}
unique_names: list[str] = []
for name in string_names:
seen[name] = seen.get(name, 0) + 1
if name_counts[name] > 1 and seen[name] < name_counts[name]:
unique_names.append(name.lower())
else:
unique_names.append(name)
self.string_names = tuple(unique_names)
self._map = dict(zip(self.string_names, self.positions))
def __repr__(self) -> str:
pairs = ", ".join(
f"{name}={'x' if pos is None else pos}"
for name, pos in zip(self.string_names, self.positions)
)
return f"Fingering({pairs})"
def __getitem__(self, key):
if isinstance(key, int):
return self.positions[key]
return self._map[key]
def __iter__(self):
return iter(self.positions)
def __len__(self):
return len(self.positions)
def __eq__(self, other):
if isinstance(other, Fingering):
return self.positions == other.positions and self.string_names == other.string_names
if isinstance(other, tuple):
return self.positions == other
return NotImplemented
@property
def tones(self):
"""Return the sounding tones for this fingering.
Requires that the Fingering was created with a fretboard reference.
Muted strings (``None``) are excluded.
"""
if self._fretboard is None:
raise ValueError("Cannot resolve tones without a fretboard reference.")
tones = []
for pos, tone in zip(self.positions, self._fretboard.tones):
if pos is not None:
tones.append(tone.add(pos))
return tones
def to_chord(self, fretboard=None) -> "Chord":
"""Apply this fingering to a fretboard, returning a Chord.
Strings with ``None`` positions (muted) are excluded.
If no fretboard is given, uses the one stored at creation time.
"""
from .chords import Chord
fb = fretboard or self._fretboard
if fb is None:
raise ValueError("No fretboard provided.")
tones = []
for pos, tone in zip(self.positions, fb.tones):
if pos is not None:
tones.append(tone.add(pos))
return Chord(tones=tones)
def identify(self) -> Optional[str]:
"""Identify the chord name from this fingering."""
return self.to_chord().identify()
CHARTS = {}
CHARTS["western"] = []
@@ -148,11 +249,35 @@ class NamedChord:
if fingering_score(possible_fingering) == max_score:
yield possible_fingering
string_names = tuple(t.name for t in fretboard.tones)
best_fingerings = tuple([g for g in gen()])
if not multiple:
return self.fix_fingering(best_fingerings[0])
return Fingering(self.fix_fingering(best_fingerings[0]), string_names, fretboard=fretboard)
else:
return tuple([self.fix_fingering(f) for f in best_fingerings])
return tuple([Fingering(self.fix_fingering(f), string_names, fretboard=fretboard) for f in best_fingerings])
def tab(self, *, fretboard):
"""Render this chord as ASCII guitar tablature.
Example::
>>> print(CHARTS["western"]["C"].tab(fretboard=Fretboard.guitar()))
C
e|--0--
B|--1--
G|--0--
D|--2--
A|--3--
E|--0--
"""
fingering = self.fingering(fretboard=fretboard)
string_names = [t.name for t in fretboard.tones]
lines = [self.name]
max_name = max(len(n) for n in string_names)
for i, (name, fret) in enumerate(zip(string_names, fingering)):
fret_str = "x" if fret is None else str(fret)
lines.append(f"{name:>{max_name}}|--{fret_str}--")
return "\n".join(lines)
western_chart = {}
+1236 -77
View File
File diff suppressed because it is too large Load Diff
+215
View File
@@ -0,0 +1,215 @@
"""PyTheory CLI — music theory from the command line."""
from __future__ import annotations
import argparse
import sys
def cmd_tone(args):
from .tones import Tone
tone = Tone.from_string(args.note, system="western")
freq = tone.pitch(temperament=args.temperament)
print(f" Note: {tone.full_name}")
print(f" Frequency: {freq:.2f} Hz ({args.temperament} temperament)")
if args.temperament != "equal":
import math
equal_freq = tone.pitch(temperament="equal")
diff_cents = 1200 * math.log2(freq / equal_freq) if freq > 0 else 0
print(f" Equal temp: {equal_freq:.2f} Hz (diff: {diff_cents:+.1f} cents)")
if tone.midi is not None:
print(f" MIDI: {tone.midi}")
if tone.enharmonic:
print(f" Enharmonic: {tone.enharmonic}")
print(f" Overtones: {', '.join(f'{h:.1f}' for h in tone.overtones(6))}")
def cmd_scale(args):
from .scales import TonedScale
ts = TonedScale(tonic=f"{args.tonic}4", system=args.system)
scale = ts[args.mode]
print(f" {args.tonic} {args.mode}: {' '.join(scale.note_names)}")
print(f" Intervals: {scale.tones[0].full_name}", end="")
for i in range(1, len(scale.tones)):
semitones = abs(scale.tones[i] - scale.tones[i-1])
print(f" -{semitones}- {scale.tones[i].full_name}", end="")
print()
def cmd_chord(args):
from .tones import Tone
from .chords import Chord
tones = [Tone.from_string(f"{n}4", system="western") for n in args.notes]
chord = Chord(tones=tones)
name = chord.identify() or "Unknown"
print(f" Chord: {name}")
print(f" Tones: {' '.join(t.full_name for t in chord.tones)}")
print(f" Intervals: {chord.intervals}")
print(f" Harmony: {chord.harmony:.4f}")
print(f" Dissonance: {chord.dissonance:.4f}")
t = chord.tension
print(f" Tension: {t['score']:.2f} (tritones={t['tritones']})")
def cmd_key(args):
from .scales import Key
key = Key(args.tonic, args.mode)
sig = key.signature
acc = ", ".join(sig["accidentals"]) if sig["accidentals"] else "none"
print(f" Key: {key}")
print(f" Signature: {sig['sharps']} sharps, {sig['flats']} flats ({acc})")
print(f" Scale: {' '.join(key.note_names)}")
print(f" Triads:")
for chord in key.scale.harmonize():
analysis = chord.analyze(args.tonic, args.mode) or "?"
print(f" {analysis:6s} {chord}")
print(f" 7th chords:")
for name in key.seventh_chords:
print(f" {name}")
print(f" Relative: {key.relative}")
print(f" Parallel: {key.parallel}")
def cmd_fingering(args):
from .charts import CHARTS
from .chords import Fretboard
chart = CHARTS.get("western", {})
if args.chord not in chart:
print(f" Unknown chord: {args.chord}")
sys.exit(1)
fb = Fretboard.guitar(capo=args.capo)
print(chart[args.chord].tab(fretboard=fb))
def cmd_progression(args):
from .scales import Key
key = Key(args.tonic, args.mode)
chords = key.progression(*args.numerals)
print(f" Key: {key}")
print(f" Progression: {''.join(args.numerals)}")
print()
for numeral, chord in zip(args.numerals, chords):
print(f" {numeral:6s} {chord}")
def cmd_play(args):
from .tones import Tone
from .chords import Chord
from .play import play, Synth
synth_map = {"sine": Synth.SINE, "saw": Synth.SAW, "triangle": Synth.TRIANGLE}
synth = synth_map[args.synth]
duration = args.duration
# Try chord name first (e.g. "Am", "Cmaj7"), then fall back to individual notes.
if len(args.notes) == 1:
note = args.notes[0]
# Try as chord name first (Am, G7, Cmaj7, etc.)
try:
target = Chord.from_name(note)
name = target.identify() or note
label = f"{name} ({' '.join(t.full_name for t in target.tones)})"
except (ValueError, KeyError):
# Fall back to single tone
target = Tone.from_string(
note if any(c.isdigit() for c in note) else f"{note}4",
system="western")
label = target.full_name
else:
tones = [Tone.from_string(n if any(c.isdigit() for c in n) else f"{n}4",
system="western") for n in args.notes]
target = Chord(tones=tones)
name = target.identify() or "Custom"
label = f"{name} ({' '.join(t.full_name for t in tones)})"
print(f" Playing: {label}")
print(f" Synth: {args.synth}")
print(f" Duration: {duration} ms")
play(target, temperament=args.temperament, synth=synth, t=duration)
def cmd_detect(args):
from .scales import Key
key = Key.detect(*args.notes)
if key:
print(f" Detected key: {key}")
print(f" Scale: {' '.join(key.note_names)}")
else:
print(" Could not detect key")
def main():
parser = argparse.ArgumentParser(
prog="pytheory",
description="Music Theory for Humans — from the command line",
)
sub = parser.add_subparsers(dest="command")
# tone
p = sub.add_parser("tone", help="Look up a tone (e.g. pytheory tone C4)")
p.add_argument("note", help="Note name with octave (e.g. C4, A#3)")
p.add_argument("--temperament", "-t", default="equal",
choices=["equal", "pythagorean", "meantone"],
help="Tuning temperament (default: equal)")
# scale
p = sub.add_parser("scale", help="Show a scale (e.g. pytheory scale C major)")
p.add_argument("tonic", help="Tonic note (e.g. C, G, Sa)")
p.add_argument("mode", help="Scale/mode name (e.g. major, minor, dorian)")
p.add_argument("--system", default="western", help="Musical system (default: western)")
# chord
p = sub.add_parser("chord", help="Identify a chord (e.g. pytheory chord C E G)")
p.add_argument("notes", nargs="+", help="Note names (e.g. C E G)")
# key
p = sub.add_parser("key", help="Explore a key (e.g. pytheory key C major)")
p.add_argument("tonic", help="Tonic note (e.g. C, G)")
p.add_argument("mode", nargs="?", default="major", help="Mode (default: major)")
# fingering
p = sub.add_parser("fingering", help="Guitar fingering (e.g. pytheory fingering Am)")
p.add_argument("chord", help="Chord name (e.g. C, Am, G7)")
p.add_argument("--capo", type=int, default=0, help="Capo fret (default: 0)")
# progression
p = sub.add_parser("progression", help="Build a progression (e.g. pytheory progression C major I V vi IV)")
p.add_argument("tonic", help="Tonic note")
p.add_argument("mode", help="Mode (e.g. major, minor)")
p.add_argument("numerals", nargs="+", help="Roman numerals (e.g. I V vi IV)")
# play
p = sub.add_parser("play", help="Play notes or chords (e.g. pytheory play C E G)")
p.add_argument("notes", nargs="+", help="Note names, with optional octave (e.g. C4, A#3, or just C E G)")
p.add_argument("--synth", "-s", default="sine",
choices=["sine", "saw", "triangle"],
help="Waveform (default: sine)")
p.add_argument("--duration", "-d", type=int, default=1000,
help="Duration in milliseconds (default: 1000)")
p.add_argument("--temperament", "-t", default="equal",
choices=["equal", "pythagorean", "meantone"],
help="Tuning temperament (default: equal)")
# detect
p = sub.add_parser("detect", help="Detect key from notes (e.g. pytheory detect C E G)")
p.add_argument("notes", nargs="+", help="Note names")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(0)
commands = {
"tone": cmd_tone,
"scale": cmd_scale,
"chord": cmd_chord,
"key": cmd_key,
"fingering": cmd_fingering,
"progression": cmd_progression,
"play": cmd_play,
"detect": cmd_detect,
}
commands[args.command](args)
if __name__ == "__main__":
main()
+77 -33
View File
@@ -5,8 +5,8 @@ import sounddevice as sd
from .tones import Tone
SAMPLE_RATE = 44_100
SAMPLE_PEAK = 4_096
SAMPLE_RATE = 44_100 # CD-quality sample rate (Hz)
SAMPLE_PEAK = 4_096 # Peak amplitude for 16-bit integer samples
def sine_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
@@ -20,41 +20,33 @@ def sine_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
return numpy.resize(onecycle, (n_samples,)).astype(numpy.int16)
def sawtooth_wave(hz, peak=SAMPLE_PEAK, rising_ramp_width=1, n_samples=SAMPLE_RATE):
"""Compute N samples of a sine wave with given frequency and peak amplitude.
def sawtooth_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Compute N samples of a sawtooth wave with given frequency and peak amplitude.
Defaults to one second.
rising_ramp_width is the percentage of the ramp spend rising:
.5 is a triangle wave with equal rising and falling times.
"""
t = numpy.linspace(0, 1, int(500 * 440 / hz), endpoint=False)
wave = scipy.signal.sawtooth(2 * numpy.pi * 5 * t, width=rising_ramp_width)
wave = numpy.resize(wave, (n_samples,))
# Sawtooth waves sound very quiet, so multiply peak by 4.
return peak * 6 * wave.astype(numpy.int16)
length = SAMPLE_RATE / float(hz)
omega = numpy.pi * 2 / length
xvalues = numpy.arange(int(length)) * omega
onecycle = scipy.signal.sawtooth(xvalues, width=1)
onecycle = (peak * onecycle).astype(numpy.int16)
return numpy.resize(onecycle, (n_samples,))
def triangle_wave(hz, peak=SAMPLE_PEAK, rising_ramp_width=0.5, n_samples=SAMPLE_RATE):
def triangle_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Compute N samples of a triangle wave with given frequency and peak amplitude.
Defaults to one second.
rising_ramp_width is the percentage of the ramp spend rising:
.5 is a triangle wave with equal rising and falling times.
"""
hz_value = float(hz)
num_samples = int(500 * 440 / hz_value)
t = numpy.linspace(0, 1, num_samples, endpoint=False)
wave = scipy.signal.sawtooth(2 * numpy.pi * 5 * t, width=rising_ramp_width)
wave = numpy.resize(wave, (n_samples,))
# Use same amplitude as sawtooth_wave for testing
return peak * 6 * wave.astype(numpy.int16)
length = SAMPLE_RATE / float(hz)
omega = numpy.pi * 2 / length
xvalues = numpy.arange(int(length)) * omega
onecycle = scipy.signal.sawtooth(xvalues, width=0.5)
onecycle = (peak * onecycle).astype(numpy.int16)
return numpy.resize(onecycle, (n_samples,))
def _play_for(sample_wave, ms):
"""Play the given NumPy array, as a sound, for ms milliseconds."""
# sounddevice expects float32 samples between -1 and 1
"""Play the given NumPy sample array through the speakers."""
normalized_wave = sample_wave.astype(numpy.float32) / SAMPLE_PEAK
# Play the audio and wait
sd.play(normalized_wave, SAMPLE_RATE)
sd.wait()
@@ -65,18 +57,70 @@ class Synth(Enum):
TRIANGLE = triangle_wave
def play(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000):
"""Play a tone or chord."""
def _render(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000):
"""Render a tone or chord to a NumPy sample array.
Args:
tone_or_chord: A :class:`Tone` or :class:`Chord` to render.
temperament: Tuning temperament (``"equal"``, ``"pythagorean"``,
or ``"meantone"``).
synth: Waveform type — ``Synth.SINE``, ``Synth.SAW``, or
``Synth.TRIANGLE``.
t: Duration in milliseconds.
Returns:
A NumPy int16 array of audio samples.
"""
n_samples = int(SAMPLE_RATE * t / 1_000)
if isinstance(tone_or_chord, Tone):
chord = [synth(tone_or_chord.pitch(temperament=temperament))]
waves = [synth(tone_or_chord.pitch(temperament=temperament), n_samples=n_samples)]
else:
chord = [
synth(tone.pitch(temperament=temperament))
waves = [
synth(tone.pitch(temperament=temperament), n_samples=n_samples)
for tone in tone_or_chord.tones
]
_play_for(sum(chord), ms=t)
return sum(waves)
# 69 + 12*np.log2(hz_nonneg/440.)
def play(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000):
"""Play a tone or chord through the speakers.
Args:
tone_or_chord: A :class:`Tone` or :class:`Chord` to play.
temperament: Tuning temperament (``"equal"``, ``"pythagorean"``,
or ``"meantone"``).
synth: Waveform type — ``Synth.SINE``, ``Synth.SAW``, or
``Synth.TRIANGLE``.
t: Duration in milliseconds (default 1000).
Example::
>>> play(Tone.from_string("A4"), t=1_000)
>>> play(Chord.from_name("Am7"), synth=Synth.TRIANGLE, t=2_000)
"""
_play_for(_render(tone_or_chord, temperament=temperament, synth=synth, t=t), ms=t)
def save(tone_or_chord, path, temperament="equal", synth=Synth.SINE, t=1_000):
"""Render a tone or chord and save it as a WAV file.
Args:
tone_or_chord: A :class:`Tone` or :class:`Chord` to render.
path: Output file path (e.g. ``"chord.wav"``).
temperament: Tuning temperament.
synth: Waveform type.
t: Duration in milliseconds (default 1000).
Example::
>>> save(Chord.from_name("C"), "c_major.wav", t=2_000)
"""
import scipy.io.wavfile
samples = _render(tone_or_chord, temperament=temperament, synth=synth, t=t)
normalized = samples.astype(numpy.float32) / SAMPLE_PEAK
# Convert to 16-bit PCM
pcm = (normalized * 32767).astype(numpy.int16)
scipy.io.wavfile.write(path, SAMPLE_RATE, pcm)
View File
+533 -19
View File
@@ -1,11 +1,25 @@
from __future__ import annotations
from typing import Optional, Union
import numeral
from .systems import SYSTEMS
from .systems import SYSTEMS, System
from .tones import Tone
class Scale:
def __init__(self, *, tones, degrees=None, system='western'):
def __init__(self, *, tones: tuple[Tone, ...], degrees: Optional[tuple[str, ...]] = None, system: Union[str, System] = 'western') -> None:
"""Initialize a Scale from a sequence of Tones.
Args:
tones: The tones that make up the scale.
degrees: Optional names for each scale degree (must match length of *tones*).
system: A tone system name or :class:`System` instance.
Raises:
ValueError: If *degrees* is provided but its length differs from *tones*.
"""
self.tones = tones
self.degrees = degrees
@@ -21,14 +35,18 @@ class Scale:
raise ValueError("The number of tones and degrees must be equal!")
@property
def system(self):
def system(self) -> Optional[System]:
"""Return the tone system for this scale.
Resolves a system name to a :class:`System` object on first access.
"""
if self._system:
return self._system
if self.system_name:
return SYSTEMS[self.system_name]
def __repr__(self):
def __repr__(self) -> str:
r = []
for (i, tone) in enumerate(self.tones):
degree = numeral.int2roman(i + 1, only_ascii=True)
@@ -38,22 +56,25 @@ class Scale:
return f"<Scale {r}>"
def __iter__(self):
"""Iterate over the tones in this scale."""
return iter(self.tones)
def __len__(self):
def __len__(self) -> int:
"""Return the number of tones in this scale (including the octave)."""
return len(self.tones)
def __contains__(self, item):
def __contains__(self, item: Union[str, Tone]) -> bool:
"""Check whether a tone or note name belongs to this scale."""
if isinstance(item, str):
return any(item == t.name for t in self.tones)
return item in self.tones
@property
def note_names(self):
def note_names(self) -> list[str]:
"""List of note names in this scale."""
return [t.name for t in self.tones]
def chord(self, *degrees):
def chord(self, *degrees: int) -> Chord:
"""Build a Chord from scale degrees (0-indexed).
Wraps around if degrees exceed the scale length, transposing
@@ -75,15 +96,146 @@ class Scale:
result.append(tone)
return Chord(tones=result)
def triad(self, root=0):
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
scale's interval pattern.
Example::
>>> c_major = TonedScale(tonic="C4")["major"]
>>> d_major = c_major.transpose(2)
>>> d_major.note_names
['D', 'E', 'F#', 'G', 'A', 'B', 'C#', 'D']
"""
from .chords import Chord
new_tones = tuple(t.add(semitones) for t in self.tones)
return Scale(tones=new_tones)
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 degree(self, item, major=None, minor=False):
# TODO: cleanup degrees.
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: str) -> list[Chord]:
"""Build a chord progression from Roman numeral strings.
Accepts Roman numerals like ``"I"``, ``"IV"``, ``"V"``,
``"ii"``, ``"vi"``. Lowercase = minor triad, uppercase = major
triad. Add ``"7"`` suffix for seventh chords.
Example::
>>> scale.progression("I", "IV", "V", "I")
[<Chord (C,E,G)>, <Chord (F,A,C)>, <Chord (G,B,D)>, <Chord (C,E,G)>]
"""
import numeral as numeral_mod
chords = []
for num in numerals:
is_seventh = num.endswith("7")
clean = num.rstrip("7")
degree = numeral_mod.roman2int(clean.upper()) - 1
if is_seventh:
chords.append(self.seventh(degree))
else:
chords.append(self.triad(degree))
return chords
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>`_
uses Arabic numerals instead of Roman numerals.
It's the standard chart system in Nashville recording studios.
Numbers 1-7 build diatonic triads. Suffix ``"7"`` for seventh
chords, ``"m"`` to force minor.
Example::
>>> scale.nashville(1, 4, 5, 1)
[<Chord C major>, <Chord F major>, <Chord G major>, <Chord C major>]
"""
from .chords import Chord
chords = []
for num in numbers:
s = str(num)
is_seventh = s.endswith("7")
clean = s.rstrip("7m")
degree = int(clean) - 1
if is_seventh:
chords.append(self.seventh(degree))
else:
chords.append(self.triad(degree))
return chords
@staticmethod
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
match as a ``(tonic, scale_name, match_count)`` tuple.
Example::
>>> Scale.detect("C", "D", "E", "F", "G", "A", "B")
('C', 'major', 7)
>>> Scale.detect("C", "D", "Eb", "F", "G", "Ab", "Bb")
('C', 'minor', 7)
"""
if not note_names:
return None
notes = set(note_names)
best = None
chromatic = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
scale_names = ["major", "minor", "harmonic minor",
"dorian", "phrygian", "lydian", "mixolydian",
"aeolian", "locrian"]
for tonic in chromatic:
ts = TonedScale(tonic=f"{tonic}4")
for scale_name in ts.scales:
try:
scale = ts[scale_name]
scale_notes = set(scale.note_names)
match = len(notes & scale_notes)
score = (match, 1 if scale_name == "major" else 0)
if best is None or score > best[0]:
best = (score, tonic, scale_name, match)
except (KeyError, ValueError):
continue
if best:
return (best[1], best[2], best[3])
return None
def harmonize(self) -> list[Chord]:
"""Build diatonic triads on every scale degree.
Returns a list of Chords — one triad for each degree of the
scale. In a major scale this produces: I, ii, iii, IV, V, vi, vii°.
Example::
>>> [c.identify() for c in TonedScale(tonic="C4")["major"].harmonize()]
['C major', 'D minor', 'E minor', 'F major', 'G major', 'A minor', 'B diminished']
"""
unique = len(self.tones) - 1
return [self.triad(i) for i in range(unique)]
def degree(self, item: Union[str, int, slice], major: Optional[bool] = None, minor: bool = False) -> Optional[Union[Tone, tuple[Tone, ...]]]:
# Ensure that both major and minor aren't passed.
if all((major, minor)):
@@ -115,43 +267,404 @@ 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)
return result
PROGRESSIONS = {
# Rock / Pop / Folk
"I-IV-V-I": ("I", "IV", "V", "I"),
"I-V-vi-IV": ("I", "V", "vi", "IV"),
"I-vi-IV-V": ("I", "vi", "IV", "V"),
"I-IV-vi-V": ("I", "IV", "vi", "V"),
"vi-IV-I-V": ("vi", "IV", "I", "V"),
# Blues
"12-bar blues": ("I", "I", "I", "I", "IV", "IV", "I", "I", "V", "IV", "I", "V"),
# Jazz
"ii-V-I": ("ii", "V7", "I"),
"I-vi-ii-V": ("I", "vi", "ii", "V"), # rhythm changes A section
"iii-vi-ii-V": ("iii", "vi", "ii", "V"), # jazz turnaround
# Classical / Film
"i-bVI-bIII-bVII": ("i", "VI", "III", "VII"),
"Pachelbel": ("I", "V", "vi", "iii", "IV", "I", "IV", "V"),
# Flamenco / Spanish
"Andalusian": ("i", "VII", "VI", "V"),
# Modal
"Dorian vamp": ("i", "IV"),
"Mixolydian vamp": ("I", "VII"),
}
"""Common chord progressions as Roman numeral tuples.
Use with :meth:`Scale.progression` or :meth:`Key.progression`::
Key("C", "major").progression(*PROGRESSIONS["I-V-vi-IV"])
"""
class Key:
"""A musical key — a convenient entry point for scales and harmony.
A Key represents a tonic note and a mode. It provides quick access
to the scale, diatonic chords, and common progressions.
Example::
>>> key = Key("C", "major")
>>> key.scale.note_names
['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
>>> key.chords
['C major', 'D minor', 'E minor', 'F major', ...]
>>> key.progression("I", "V", "vi", "IV")
[<Chord (C,E,G)>, <Chord (G,B,D)>, ...]
"""
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):
system = SYSTEMS[system]
self.tonic_name = tonic
self.mode = mode
self._system = system
self._toned_scale = TonedScale(tonic=f"{tonic}4", system=system)
self._scale = self._toned_scale[mode]
@classmethod
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
whose scale contains the most of the given notes.
Example::
>>> Key.detect("C", "D", "E", "F", "G", "A", "B")
<Key C major>
>>> Key.detect("A", "B", "C", "D", "E", "F", "G")
<Key C major>
>>> Key.detect("A", "C", "E")
<Key C major>
Returns:
The best-matching Key, or None if no notes given.
"""
if not note_names:
return None
notes = set(note_names)
best_key = None
best_score = (-1, 0)
chromatic = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
for tonic in chromatic:
for mode in ("major", "minor"):
try:
k = cls(tonic, mode)
scale_notes = set(k.note_names)
match = len(notes & scale_notes)
# Tiebreak: prefer major over minor
score = (match, 1 if mode == "major" else 0)
if score > best_score:
best_score = score
best_key = k
except (KeyError, ValueError):
continue
return best_key
def __repr__(self) -> str:
return f"<Key {self.tonic_name} {self.mode}>"
def __str__(self) -> str:
return f"{self.tonic_name} {self.mode}"
@property
def scale(self) -> Scale:
"""The scale for this key."""
return self._scale
@property
def note_names(self) -> list[str]:
"""Note names in this key's scale."""
return self._scale.note_names
@property
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) -> 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: int) -> Chord:
"""Build a diatonic triad on the given degree (0-indexed)."""
return self._scale.triad(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: str) -> list[Chord]:
"""Build a chord progression from Roman numerals.
Example::
>>> Key("G", "major").progression("I", "IV", "V7", "I")
"""
return self._scale.progression(*numerals)
def nashville(self, *numbers: Union[int, str]) -> list[Chord]:
"""Build a chord progression using Nashville numbers.
Example::
>>> Key("G", "major").nashville(1, 4, 5, 1)
"""
return self._scale.nashville(*numbers)
def secondary_dominant(self, degree: int) -> Chord:
"""Build a secondary dominant (V/x) for the given scale degree.
A secondary dominant is the dominant chord of a non-tonic
degree. For example, in C major, V/V is D major (the V chord
of G). Secondary dominants create momentary tonicizations
that add color and forward motion.
Common secondary dominants:
- V/V (e.g. D7 in C major) — approaches the dominant
- V/ii (e.g. A7 in C major) — approaches the supertonic
- V/vi (e.g. E7 in C major) — approaches the relative minor
Args:
degree: Scale degree to target (1-indexed). ``5`` means
"build the V of the 5th degree."
Returns:
A dominant 7th Chord that resolves to the given degree.
Example::
>>> Key("C", "major").secondary_dominant(5) # V/V = D7
<Chord D dominant 7th>
"""
target = self._scale.tones[degree - 1]
# Build a dominant 7th a perfect 5th above the target
from .chords import Chord
root = target.add(7)
return Chord(tones=[root, root.add(4), root.add(7), root.add(10)])
@classmethod
def all_keys(cls) -> list[Key]:
"""Return all 24 major and minor keys.
Returns:
A list of Key objects for all 12 major and 12 minor keys.
Example::
>>> for k in Key.all_keys():
... print(k)
"""
chromatic = ["C", "C#", "D", "D#", "E", "F",
"F#", "G", "G#", "A", "A#", "B"]
keys = []
for tonic in chromatic:
keys.append(cls(tonic, "major"))
keys.append(cls(tonic, "minor"))
return keys
@property
def 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).
If this is a minor key, returns the relative major (bIII).
"""
if self.mode == "major":
# Relative minor starts on the 6th degree
minor_tonic = self._scale.tones[5].name
return Key(minor_tonic, "minor")
elif self.mode in ("minor", "aeolian"):
# Relative major starts on the 3rd degree
major_tonic = self._scale.tones[2].name
return Key(major_tonic, "major")
return None
@property
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")
elif self.mode in ("minor", "aeolian"):
return Key(self.tonic_name, "major")
return None
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
if not isinstance(tonic, Tone):
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
return None
@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:
@@ -169,4 +682,5 @@ class TonedScale:
scales[scale] = Scale(tones=tuple(working_scale))
self._cached_scales = scales
return scales
+31 -4
View File
@@ -1,4 +1,8 @@
from ._statics import TEMPERAMENTS, TONES, DEGREES, SCALES, SYSTEMS
from ._statics import (
TEMPERAMENTS, TONES, DEGREES, SCALES,
INDIAN_SCALES, ARABIC_SCALES, JAPANESE_SCALES,
BLUES_SCALES, GAMELAN_SCALES, SYSTEMS,
)
class System:
@@ -20,6 +24,16 @@ class System:
from . import Tone
return tuple([Tone.from_tuple(tone) for tone in self.tone_names])
def resolve_name(self, name: str) -> str | None:
"""Resolve a note name (including flats) to the canonical name.
Returns the primary name if found, or None if not recognized.
"""
for names in self.tone_names:
if name in names:
return names[0]
return None
@property
def scales(self):
@@ -55,6 +69,7 @@ class System:
*,
tones=7,
semitones=12,
intervals=None,
major=False,
minor=False,
hemitonic=False, # Contains semitones.
@@ -63,7 +78,13 @@ class System:
offset=None,
):
"""Generates the primary scale for a given number of semitones/tones."""
# TODO: Support minor, support harmonic, support melodic.
# Direct interval pattern — bypass generation logic.
if intervals is not None:
scale = list(intervals)
if offset:
scale = scale[offset:] + scale[:offset]
return {"intervals": scale, "hemitonic": 1 in scale, "meta": {}}
# Sanity check.
if major and minor:
@@ -94,7 +115,6 @@ class System:
yield step
else:
for i in range(tones):
# TODO: figure out how to make this work with monotonic.
yield 1
scale = [
@@ -119,4 +139,11 @@ class System:
def __repr__(self):
return f"<System semitones={self.semitones!r}>"
SYSTEMS = {"western": System(tone_names=TONES["western"], degrees=DEGREES["western"])}
SYSTEMS = {
"western": System(tone_names=TONES["western"], degrees=DEGREES["western"]),
"indian": System(tone_names=TONES["indian"], degrees=DEGREES["indian"], scales=INDIAN_SCALES[12]),
"arabic": System(tone_names=TONES["arabic"], degrees=DEGREES["arabic"], scales=ARABIC_SCALES[12]),
"japanese": System(tone_names=TONES["japanese"], degrees=DEGREES["japanese"], scales=JAPANESE_SCALES[12]),
"blues": System(tone_names=TONES["blues"], degrees=DEGREES["blues"], scales=BLUES_SCALES[12]),
"gamelan": System(tone_names=TONES["gamelan"], degrees=DEGREES["gamelan"], scales=GAMELAN_SCALES[12]),
}
+380 -34
View File
@@ -1,9 +1,47 @@
from __future__ import annotations
from typing import Optional, Union
from ._statics import REFERENCE_A, TEMPERAMENTS
class Interval:
"""Named constants for common musical intervals (in semitones)."""
UNISON = 0
MINOR_SECOND = 1
MAJOR_SECOND = 2
MINOR_THIRD = 3
MAJOR_THIRD = 4
PERFECT_FOURTH = 5
TRITONE = 6
PERFECT_FIFTH = 7
MINOR_SIXTH = 8
MAJOR_SIXTH = 9
MINOR_SEVENTH = 10
MAJOR_SEVENTH = 11
OCTAVE = 12
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 = []
@@ -21,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
@@ -30,11 +69,16 @@ class Tone:
self._system = system
@property
def exists(self):
return self.name in self.system.tones
def exists(self) -> bool:
"""True if this tone's name is found in the associated system."""
return self.system.resolve_name(self.name) is not None
@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:
@@ -45,25 +89,80 @@ class Tone:
return self.system
@property
def full_name(self):
if self.octave:
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
def __repr__(self):
@property
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) -> bool:
"""True if this tone has a sharp (#)."""
return "#" in self.name
@property
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 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.
Natural notes (C, D, E, F, G, A, B) have no enharmonic.
Example::
>>> Tone.from_string("C#4").enharmonic
'Db'
"""
if self.alt_names:
return self.alt_names[0] if isinstance(self.alt_names, (list, tuple)) else self.alt_names
# Check the system for alt names
try:
for tone in self.system.tones:
if tone.name == self.name and tone.alt_names:
return tone.alt_names[0]
except (AttributeError, TypeError):
pass
return None
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)
@@ -79,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):
@@ -114,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:
@@ -132,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:
@@ -141,18 +258,94 @@ class Tone:
return tone
@classmethod
def from_index(klass, i, *, octave, system):
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).
Example::
>>> Tone.from_frequency(440)
<Tone A4>
>>> Tone.from_frequency(261.63)
<Tone C4>
"""
import math
if hz <= 0:
raise ValueError("Frequency must be positive")
# Semitones from A4
semitones_from_a4 = 12 * math.log2(hz / REFERENCE_A)
semitones = round(semitones_from_a4)
# A4 is index 0 in the Western system, octave 4
# Convert to absolute position from C0
c_index = 3
a4_from_c0 = ((0 - c_index) % 12) + (4 * 12) # = 57
abs_pos = a4_from_c0 + semitones
octave = abs_pos // 12
relative = abs_pos % 12
index = (relative + c_index) % 12
if isinstance(system, str):
from .systems import SYSTEMS
system = SYSTEMS[system]
return klass.from_index(index, octave=octave, system=system)
@classmethod
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).
Example::
>>> Tone.from_midi(60)
<Tone C4>
>>> Tone.from_midi(69)
<Tone A4>
"""
c_index = 3
adjusted = note_number - 12 # MIDI C0=12
octave = adjusted // 12
relative = adjusted % 12
index = (relative + c_index) % 12
if isinstance(system, str):
from .systems import SYSTEMS
system = SYSTEMS[system]
return klass.from_index(index, octave=octave, system=system)
@classmethod
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.
Resolves enharmonic names (e.g. 'Db''C#') before lookup.
Raises:
ValueError: If no system is associated with this tone or
the name is not found.
"""
try:
return self.system.tones.index(self.name)
canonical = self.system.resolve_name(self.name)
if canonical is None:
raise ValueError(f"Tone {self.name!r} not found in system")
return self.system.tones.index(canonical)
except AttributeError:
raise ValueError("Tone index cannot be referenced without a system!")
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
@@ -182,33 +375,186 @@ 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 = {
0: "unison", 1: "minor 2nd", 2: "major 2nd", 3: "minor 3rd",
4: "major 3rd", 5: "perfect 4th", 6: "tritone", 7: "perfect 5th",
8: "minor 6th", 9: "major 6th", 10: "minor 7th", 11: "major 7th",
12: "octave",
}
def interval_to(self, other: Tone) -> str:
"""Name the interval between this tone and another.
Returns a string like ``"perfect 5th"``, ``"major 3rd"``, or
``"octave"``. For intervals larger than an octave, returns
the compound form (e.g. ``"minor 2nd + 1 octave"``).
Example::
>>> C4.interval_to(G4)
'perfect 5th'
>>> C4.interval_to(C5)
'octave'
"""
semitones = abs(self - other)
octaves = semitones // 12
remainder = semitones % 12
name = self._INTERVAL_NAMES.get(remainder, f"{remainder} semitones")
if octaves == 0:
return name
if remainder == 0:
if octaves == 1:
return "octave"
return f"{octaves} octaves"
if octaves == 1:
return f"{name} + 1 octave"
return f"{name} + {octaves} octaves"
@property
def frequency(self):
"""The frequency of this tone in Hz (equal temperament, A4=440)."""
return self.pitch()
def midi(self) -> Optional[int]:
"""MIDI note number (C4 = 60, A4 = 69).
The MIDI standard assigns integer note numbers from 0127.
Middle C (C4) is 60, and each semitone increments by 1.
Returns:
int: the MIDI note number, or None if no octave is set.
"""
if self.octave is None:
return None
c_index = 3
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: int) -> Tone:
"""Return a new Tone transposed by the given number of semitones.
Alias for ``tone + semitones`` / ``tone - semitones``. Positive
values transpose up, negative values transpose down.
"""
return self.add(semitones)
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
steps you return to the starting tone. The circle of fifths
is the backbone of Western harmony — it determines key
signatures, chord relationships, and modulation paths.
Clockwise = add sharps: C → G → D → A → E → B → F# → ...
Counter-clockwise = add flats (see ``circle_of_fourths``).
Returns:
A list of 12 Tones.
"""
tones: list[Tone] = []
t = self
for _ in range(12):
tones.append(t)
t = t.add(7)
return tones
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
reverse direction of the circle of fifths.
Clockwise = add flats: C → F → Bb → Eb → Ab → ...
Returns:
A list of 12 Tones.
"""
tones: list[Tone] = []
t = self
for _ in range(12):
tones.append(t)
t = t.add(5)
return tones
@property
def frequency(self) -> float:
"""The frequency of this tone in Hz (equal temperament, A4=440).
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.
When a string or air column vibrates, it produces not just the
fundamental frequency but also integer multiples: 2f, 3f, 4f...
The intervals between consecutive harmonics form the basis of
Western harmony::
Harmonic Ratio Interval from fundamental
1 1:1 Unison (the fundamental)
2 2:1 Octave
3 3:1 Octave + perfect 5th
4 4:1 Two octaves
5 5:1 Two octaves + major 3rd
6 6:1 Two octaves + perfect 5th
7 7:1 Two octaves + minor 7th (slightly flat)
8 8:1 Three octaves
The reason a perfect fifth sounds consonant is that the 3rd
harmonic of the lower note aligns with the 2nd harmonic of the
upper note (when the upper note is a fifth above). More shared
harmonics = more consonance.
Args:
n: Number of harmonics to return (default 8).
Returns:
List of frequencies in Hz.
"""
f = self.pitch()
return [f * i for i in range(1, n + 1)]
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:
raise ValueError("Pitches can only be computed with an associated system!")
pitch_scale = TEMPERAMENTS[temperament](tones)
octave = self.octave or 4
octave = self.octave if self.octave is not None else 4
# C is at index 3; convert to semitones from C0 for both
# this note and the reference A4.
+2118 -13
View File
File diff suppressed because it is too large Load Diff
Generated
+475 -5
View File
@@ -2,10 +2,38 @@ version = 1
revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.11'",
"python_full_version >= '3.12'",
"python_full_version == '3.11.*'",
"python_full_version < '3.11'",
]
[[package]]
name = "alabaster"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" },
]
[[package]]
name = "babel"
version = "2.18.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
@@ -88,6 +116,111 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/8c/2c56124c6dc53a774d435f985b5973bc592f42d437be58c0c92d65ae7296/charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", size = 298751, upload-time = "2026-03-15T18:50:00.003Z" },
{ url = "https://files.pythonhosted.org/packages/86/2a/2a7db6b314b966a3bcad8c731c0719c60b931b931de7ae9f34b2839289ee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", size = 200027, upload-time = "2026-03-15T18:50:01.702Z" },
{ url = "https://files.pythonhosted.org/packages/68/f2/0fe775c74ae25e2a3b07b01538fc162737b3e3f795bada3bc26f4d4d495c/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", size = 220741, upload-time = "2026-03-15T18:50:03.194Z" },
{ url = "https://files.pythonhosted.org/packages/10/98/8085596e41f00b27dd6aa1e68413d1ddda7e605f34dd546833c61fddd709/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", size = 215802, upload-time = "2026-03-15T18:50:05.859Z" },
{ url = "https://files.pythonhosted.org/packages/fd/ce/865e4e09b041bad659d682bbd98b47fb490b8e124f9398c9448065f64fee/charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", size = 207908, upload-time = "2026-03-15T18:50:07.676Z" },
{ url = "https://files.pythonhosted.org/packages/a8/54/8c757f1f7349262898c2f169e0d562b39dcb977503f18fdf0814e923db78/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", size = 194357, upload-time = "2026-03-15T18:50:09.327Z" },
{ url = "https://files.pythonhosted.org/packages/6f/29/e88f2fac9218907fc7a70722b393d1bbe8334c61fe9c46640dba349b6e66/charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", size = 205610, upload-time = "2026-03-15T18:50:10.732Z" },
{ url = "https://files.pythonhosted.org/packages/4c/c5/21d7bb0cb415287178450171d130bed9d664211fdd59731ed2c34267b07d/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", size = 203512, upload-time = "2026-03-15T18:50:12.535Z" },
{ url = "https://files.pythonhosted.org/packages/a4/be/ce52f3c7fdb35cc987ad38a53ebcef52eec498f4fb6c66ecfe62cfe57ba2/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", size = 195398, upload-time = "2026-03-15T18:50:14.236Z" },
{ url = "https://files.pythonhosted.org/packages/81/a0/3ab5dd39d4859a3555e5dadfc8a9fa7f8352f8c183d1a65c90264517da0e/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", size = 221772, upload-time = "2026-03-15T18:50:15.581Z" },
{ url = "https://files.pythonhosted.org/packages/04/6e/6a4e41a97ba6b2fa87f849c41e4d229449a586be85053c4d90135fe82d26/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", size = 205759, upload-time = "2026-03-15T18:50:17.047Z" },
{ url = "https://files.pythonhosted.org/packages/db/3b/34a712a5ee64a6957bf355b01dc17b12de457638d436fdb05d01e463cd1c/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", size = 216938, upload-time = "2026-03-15T18:50:18.44Z" },
{ url = "https://files.pythonhosted.org/packages/cb/05/5bd1e12da9ab18790af05c61aafd01a60f489778179b621ac2a305243c62/charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", size = 210138, upload-time = "2026-03-15T18:50:19.852Z" },
{ url = "https://files.pythonhosted.org/packages/bd/8e/3cb9e2d998ff6b21c0a1860343cb7b83eba9cdb66b91410e18fc4969d6ab/charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", size = 144137, upload-time = "2026-03-15T18:50:21.505Z" },
{ url = "https://files.pythonhosted.org/packages/d8/8f/78f5489ffadb0db3eb7aff53d31c24531d33eb545f0c6f6567c25f49a5ff/charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", size = 154244, upload-time = "2026-03-15T18:50:22.81Z" },
{ url = "https://files.pythonhosted.org/packages/e4/74/e472659dffb0cadb2f411282d2d76c60da1fc94076d7fffed4ae8a93ec01/charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", size = 143312, upload-time = "2026-03-15T18:50:24.074Z" },
{ url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" },
{ url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" },
{ url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" },
{ url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" },
{ url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" },
{ url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" },
{ url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" },
{ url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" },
{ url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" },
{ url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" },
{ url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" },
{ url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" },
{ url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" },
{ url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" },
{ url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" },
{ url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" },
{ url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" },
{ url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" },
{ url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" },
{ url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" },
{ url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" },
{ url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" },
{ url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" },
{ url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" },
{ url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" },
{ url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" },
{ url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" },
{ url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" },
{ url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
{ url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
{ url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" },
{ url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" },
{ url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" },
{ url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" },
{ url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" },
{ url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" },
{ url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" },
{ url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" },
{ url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" },
{ url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" },
{ url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" },
{ url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" },
{ url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" },
{ url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
{ url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
{ url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
{ url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
{ url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
{ url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
{ url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
{ url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
{ url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
{ url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
{ url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
{ url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
{ url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
{ url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
{ url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
{ url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
{ url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
{ url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
{ url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
{ url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
{ url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
{ url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
{ url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
{ url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
{ url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
{ url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
{ url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
{ url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
{ url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
{ url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
{ url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
{ url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -97,6 +230,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "docutils"
version = "0.21.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.11'",
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" },
]
[[package]]
name = "docutils"
version = "0.22.4"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.12'",
"python_full_version == '3.11.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
@@ -109,6 +267,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "imagesize"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -118,6 +294,103 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
{ url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
{ url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
{ url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
{ url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
{ url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
{ url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mpmath"
version = "1.3.0"
@@ -205,7 +478,8 @@ name = "numpy"
version = "2.4.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.11'",
"python_full_version >= '3.12'",
"python_full_version == '3.11.*'",
]
sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" }
wheels = [
@@ -338,8 +612,8 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.2.0"
source = { virtual = "." }
version = "0.4.1"
source = { editable = "." }
dependencies = [
{ name = "numeral" },
{ name = "pytuning" },
@@ -352,6 +626,11 @@ dependencies = [
dev = [
{ name = "pytest" },
]
docs = [
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
{ name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
[package.metadata]
requires-dist = [
@@ -363,6 +642,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [{ name = "pytest" }]
docs = [{ name = "sphinx" }]
[[package]]
name = "pytuning"
@@ -377,6 +657,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/26/59/e2c2fc91688f788587fb387ef6120c9a1ad3a8b88771fba9fc6a9c9a969d/PyTuning-0.7.3-py3-none-any.whl", hash = "sha256:db0b1231c012c1cf6a3c73aa7d791b4cff79a72f2ec6535f159c873fe302214b", size = 108174, upload-time = "2023-09-02T21:11:00.657Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "roman-numerals"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" },
]
[[package]]
name = "scipy"
version = "1.15.3"
@@ -441,7 +745,8 @@ name = "scipy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.11'",
"python_full_version >= '3.12'",
"python_full_version == '3.11.*'",
]
dependencies = [
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
@@ -510,6 +815,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
]
[[package]]
name = "snowballstemmer"
version = "3.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" },
]
[[package]]
name = "sounddevice"
version = "0.5.5"
@@ -526,6 +840,153 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" },
]
[[package]]
name = "sphinx"
version = "8.1.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "alabaster", marker = "python_full_version < '3.11'" },
{ name = "babel", marker = "python_full_version < '3.11'" },
{ name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
{ name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "imagesize", marker = "python_full_version < '3.11'" },
{ name = "jinja2", marker = "python_full_version < '3.11'" },
{ name = "packaging", marker = "python_full_version < '3.11'" },
{ name = "pygments", marker = "python_full_version < '3.11'" },
{ name = "requests", marker = "python_full_version < '3.11'" },
{ name = "snowballstemmer", marker = "python_full_version < '3.11'" },
{ name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" },
{ name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" },
{ name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" },
{ name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" },
{ name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" },
{ name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" },
]
[[package]]
name = "sphinx"
version = "9.0.4"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version == '3.11.*'",
]
dependencies = [
{ name = "alabaster", marker = "python_full_version == '3.11.*'" },
{ name = "babel", marker = "python_full_version == '3.11.*'" },
{ name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" },
{ name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
{ name = "imagesize", marker = "python_full_version == '3.11.*'" },
{ name = "jinja2", marker = "python_full_version == '3.11.*'" },
{ name = "packaging", marker = "python_full_version == '3.11.*'" },
{ name = "pygments", marker = "python_full_version == '3.11.*'" },
{ name = "requests", marker = "python_full_version == '3.11.*'" },
{ name = "roman-numerals", marker = "python_full_version == '3.11.*'" },
{ name = "snowballstemmer", marker = "python_full_version == '3.11.*'" },
{ name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" },
{ name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" },
{ name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" },
{ name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" },
{ name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" },
{ name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" },
]
[[package]]
name = "sphinx"
version = "9.1.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.12'",
]
dependencies = [
{ name = "alabaster", marker = "python_full_version >= '3.12'" },
{ name = "babel", marker = "python_full_version >= '3.12'" },
{ name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" },
{ name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "imagesize", marker = "python_full_version >= '3.12'" },
{ name = "jinja2", marker = "python_full_version >= '3.12'" },
{ name = "packaging", marker = "python_full_version >= '3.12'" },
{ name = "pygments", marker = "python_full_version >= '3.12'" },
{ name = "requests", marker = "python_full_version >= '3.12'" },
{ name = "roman-numerals", marker = "python_full_version >= '3.12'" },
{ name = "snowballstemmer", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" },
{ name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" },
]
[[package]]
name = "sphinxcontrib-applehelp"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" },
]
[[package]]
name = "sphinxcontrib-devhelp"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" },
]
[[package]]
name = "sphinxcontrib-htmlhelp"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" },
]
[[package]]
name = "sphinxcontrib-jsmath"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" },
]
[[package]]
name = "sphinxcontrib-qthelp"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" },
]
[[package]]
name = "sphinxcontrib-serializinghtml"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" },
]
[[package]]
name = "sympy"
version = "1.14.0"
@@ -600,3 +1061,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]