Compare commits

...

6 Commits

Author SHA1 Message Date
kennethreitz b3885b2c15 v0.35.0: JI ratios, 8.5x faster import, timpani, saxophone, rolls
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:34:34 -04:00
kennethreitz ae04fa60cc Reduce vibrato across all instruments to 0.001
Strings, cello, trumpet, clarinet, oboe all cut to 0.001 depth.
Much subtler in ensemble context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:29:48 -04:00
kennethreitz 6c411e43f8 Part.roll() for crescendo/decrescendo rolls, reedier sax, timpani reverb
- roll(tone, duration, velocity_start, velocity_end, speed) — rapid
  repeated notes with velocity ramp. Works on any instrument.
- Saxophone reed noise boosted and bandpass filtered for more bite
- Timpani preset: cathedral reverb at 0.4

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:26:28 -04:00
kennethreitz e0427af3cc Timpani and saxophone synths, 4 sax presets
- Timpani: inharmonic membrane modes (1.0, 1.5, 1.99, 2.44),
  felt mallet attack, copper kettle resonance, two-stage decay
- Saxophone: conical bore (all harmonics), strong mids, reed buzz,
  brass body warmth. 4 presets: saxophone, alto_sax, tenor_sax, bari_sax
- 29 synth waveforms total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:22:47 -04:00
kennethreitz 552836ae5b Drop pytuning/sympy, lazy-load scipy: import 0.48s → 0.05s (fixes #44)
- Replace pytuning with 30-line native implementations of EDO,
  Pythagorean, and quarter-comma meantone scale generators
- Lazy-load scipy.signal (337ms) — only imported when audio rendering
  is actually used, not on theory-only imports
- Removes pytuning and sympy from dependencies entirely

Import time: 0.479s → 0.056s (8.5x faster)

Closes #44

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:17:52 -04:00
kennethreitz 0fe53fcdeb Merge pull request #46 from kennethreitz/fix/accidental-octave-wrap
Fix B#/Cb octave boundary crossing
2026-03-27 11:11:43 -04:00
10 changed files with 278 additions and 83 deletions
+21
View File
@@ -2,6 +2,27 @@
All notable changes to PyTheory are documented here.
## 0.35.0
- **8.5x faster import** — dropped pytuning/sympy, lazy-load scipy.
`import pytheory` now takes ~50ms instead of ~480ms (#44)
- **Proper shruti JI ratios** — 22 positions with 5-limit just intonation
(pure 3/2 fifths, 5/4 thirds), not 22-TET approximation
- **Arabic maqam JI ratios** — Zalzalian 11-limit ratios.
Mi↓ (the Rast third) is exactly 27/22 from Do
- **B#/Cb octave boundary fix** — B#4 = C5, Cb4 = B3 (#45)
- **Int tone names** — `Tone(0, system=TET(22))` works alongside strings.
Wrapping: `Tone(22)` → tone 0, octave+1. `System.tone()` convenience.
- **Timpani synth** — inharmonic membrane modes, felt mallet, copper kettle
resonance, cathedral reverb
- **Saxophone synth** — conical bore, reed buzz, brass body warmth.
4 presets: saxophone, alto_sax, tenor_sax, bari_sax
- **Part.roll()** — rapid repeated notes with velocity ramp for crescendo/
decrescendo rolls on any instrument
- **Vibrato tuning** — all instruments reduced to 0.001 depth for cleaner
ensemble sound
- 29 synth waveforms, 838 tests
## 0.34.0
- **16 dedicated instrument synths** — physical modeling and specialized
+1 -1
View File
@@ -77,7 +77,7 @@ What's Inside
numbers), scale recommendation, modulation, voice leading
- **Sequencing** — Score, Parts, arpeggiator, legato/glide, velocity,
swing, humanize, tempo changes, song sections with repeat
- **Synthesis** — 27 waveforms (including Karplus-Strong pluck, Hammond organ,
- **Synthesis** — 29 waveforms (including Karplus-Strong pluck, Hammond organ,
bowed string, and 14 dedicated instrument synths), 10 envelopes, 40+
instrument presets, configurable FM, sub-oscillator, noise layer, filter
envelope, velocity-to-brightness, analog oscillator drift, detune, stereo
+1 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.34.1"
version = "0.35.0"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
@@ -21,7 +21,6 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
dependencies = [
"pytuning",
"numeral",
"sounddevice",
"scipy",
+1 -1
View File
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.34.1"
__version__ = "0.35.0"
from .tones import Tone, Interval
from .systems import System, SYSTEMS, TET
+48 -30
View File
@@ -1,4 +1,4 @@
from pytuning import scales
import math
REFERENCE_A = 440
@@ -6,41 +6,59 @@ REFERENCE_A = 440
# Scientific pitch notation changes octave at C, not A, so this offset
# is needed for all octave arithmetic.
C_INDEX = 3
def _create_just_intonation_scale(n):
"""5-limit just intonation ratios for 12-tone systems.
These are the pure frequency ratios derived from the harmonic series —
the way intervals "want" to sound before equal temperament imposed
compromise. Each ratio is mathematically exact: a perfect fifth is
exactly 3/2, a major third is exactly 5/4.
For non-12 systems, falls back to equal temperament.
# ── Temperament scale generators (replaces pytuning dependency) ──────────
def _create_edo_scale(n):
"""N-tone equal division of the octave. Each step = 2^(1/n)."""
return [2 ** (i / n) for i in range(n + 1)]
def _create_pythagorean_scale(n):
"""Pythagorean tuning — spiral of pure fifths (3/2 ratio).
Each tone is generated by stacking perfect fifths and octave-reducing.
"""
from fractions import Fraction
ratios = [1.0]
for i in range(1, n):
# Stack fifths: (3/2)^i, then reduce to within one octave
r = (3 / 2) ** i
while r >= 2.0:
r /= 2.0
ratios.append(r)
ratios.sort()
ratios.append(2.0)
return ratios
def _create_quarter_comma_meantone_scale(n):
"""Quarter-comma meantone — pure major thirds (5/4), tempered fifths.
The fifth is narrowed by 1/4 of a syntonic comma so that four
fifths make a pure major third (5/4). The meantone fifth =
5^(1/4) ≈ 1.49535.
"""
fifth = 5 ** 0.25 # meantone fifth ≈ 1.49535 (vs 1.5 pure)
ratios = [1.0]
for i in range(1, n):
r = fifth ** i
while r >= 2.0:
r /= 2.0
ratios.append(r)
ratios.sort()
ratios.append(2.0)
return ratios
def _create_just_intonation_scale(n):
"""5-limit just intonation ratios for 12-tone systems."""
if n != 12:
return scales.create_edo_scale(n)
# Standard 5-limit JI ratios (A-based: A=1/1)
ratios = [
Fraction(1, 1), # A — unison
Fraction(16, 15), # A# — minor second
Fraction(9, 8), # B — major second
Fraction(6, 5), # C — minor third
Fraction(5, 4), # C# — major third
Fraction(4, 3), # D — perfect fourth
Fraction(45, 32), # D# — augmented fourth
Fraction(3, 2), # E — perfect fifth
Fraction(8, 5), # F — minor sixth
Fraction(5, 3), # F# — major sixth
Fraction(9, 5), # G — minor seventh
Fraction(15, 8), # G# — major seventh
Fraction(2, 1), # A — octave
]
return [float(r) for r in ratios]
return _create_edo_scale(n)
return [1, 16/15, 9/8, 6/5, 5/4, 4/3, 45/32, 3/2, 8/5, 5/3, 9/5, 15/8, 2.0]
TEMPERAMENTS = {
"equal": scales.create_edo_scale,
"pythagorean": scales.create_pythagorean_scale,
"meantone": scales.create_quarter_comma_meantone_scale,
"equal": _create_edo_scale,
"pythagorean": _create_pythagorean_scale,
"meantone": _create_quarter_comma_meantone_scale,
"just": _create_just_intonation_scale,
}
+131 -6
View File
@@ -2,11 +2,24 @@ from enum import Enum
import time
import numpy
import scipy.signal
from .tones import Tone
class _LazyModule:
"""Lazy import wrapper — module loaded on first attribute access."""
def __init__(self, name):
self._name = name
self._mod = None
def __getattr__(self, attr):
if self._mod is None:
import importlib
self._mod = importlib.import_module(self._name)
return getattr(self._mod, attr)
scipy = type('scipy', (), {'signal': _LazyModule('scipy.signal')})()
def _get_sd():
"""Lazy import sounddevice — only needed for actual audio playback."""
import sounddevice as sd
@@ -257,7 +270,7 @@ def strings_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
# Delayed vibrato: ramps in over ~200ms, like a real bow
vib_rate = 5.2 + rng.uniform(-0.3, 0.3) # slight randomness per note
vib_depth = hz * 0.003 # ~5 cents
vib_depth = hz * 0.001 # subtle
vib_onset = numpy.clip(t / 0.2, 0.0, 1.0) # ramp over 200ms
vibrato = vib_depth * vib_onset * numpy.sin(2 * numpy.pi * vib_rate * t)
@@ -481,7 +494,7 @@ def trumpet_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
# Vibrato
vib_onset = numpy.clip(t / 0.15, 0.0, 1.0)
vib = hz * 0.002 * vib_onset * numpy.sin(2 * numpy.pi * 5.5 * t)
vib = hz * 0.001 * vib_onset * numpy.sin(2 * numpy.pi * 5.5 * t)
# Lip buzz — additive with brass spectral shape
# Trumpet has strong even AND odd harmonics (unlike clarinet)
@@ -523,7 +536,7 @@ def clarinet_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
vib_onset = numpy.clip(t / 0.3, 0.0, 1.0)
vib = hz * 0.002 * vib_onset * numpy.sin(2 * numpy.pi * 4.5 * t)
vib = hz * 0.001 * vib_onset * numpy.sin(2 * numpy.pi * 4.5 * t)
# Cylindrical bore: odd harmonics dominate
wave = numpy.zeros(n_samples, dtype=numpy.float64)
@@ -592,7 +605,7 @@ def oboe_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
vib_onset = numpy.clip(t / 0.2, 0.0, 1.0)
vib = hz * 0.002 * vib_onset * numpy.sin(2 * numpy.pi * 5.0 * t)
vib = hz * 0.001 * vib_onset * numpy.sin(2 * numpy.pi * 5.0 * t)
wave = numpy.zeros(n_samples, dtype=numpy.float64)
n_harmonics = min(18, int((SAMPLE_RATE / 2) / hz))
@@ -668,7 +681,7 @@ def cello_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
# Delayed vibrato
vib_rate = 5.0 + rng.uniform(-0.3, 0.3)
vib_depth = hz * 0.002
vib_depth = hz * 0.001
vib_onset = numpy.clip(t / 0.25, 0.0, 1.0)
vibrato = vib_depth * vib_onset * numpy.sin(2 * numpy.pi * vib_rate * t)
@@ -787,6 +800,115 @@ def upright_bass_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
return (peak * out).astype(numpy.int16)
def timpani_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Timpani — large kettle drum with definite pitch.
The copper kettle creates a tuned resonance with inharmonic
overtones. The head modes are at ratios 1.0, 1.5, 1.99, 2.44
(not integer multiples like strings). The felt mallet gives a
soft attack with a deep, booming body.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
# Timpani head modes — inharmonic but definite pitch
# Mode ratios from vibrating circular membrane physics
wave = numpy.sin(2 * numpy.pi * hz * t) * 0.8
wave += numpy.sin(2 * numpy.pi * hz * 1.5 * t) * 0.35 * numpy.exp(-6 * t)
wave += numpy.sin(2 * numpy.pi * hz * 1.99 * t) * 0.2 * numpy.exp(-10 * t)
wave += numpy.sin(2 * numpy.pi * hz * 2.44 * t) * 0.1 * numpy.exp(-15 * t)
# Two-stage decay: initial thump fades fast, fundamental rings
decay = numpy.where(t < 0.15,
numpy.exp(-4 * t),
numpy.exp(-4 * 0.15) * numpy.exp(-1.5 * (t - 0.15)))
wave *= decay
# Felt mallet impact — warm, not sharp
mallet_len = min(int(SAMPLE_RATE * 0.02), n_samples)
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
mallet = rng.uniform(-0.3, 0.3, mallet_len)
mallet *= numpy.exp(-numpy.linspace(0, 8, mallet_len))
wave[:mallet_len] += mallet
# Copper kettle resonance — boosts low-mids
import scipy.signal as _sig
lo, hi = max(20, int(hz * 0.7)), min(SAMPLE_RATE // 2 - 1, int(hz * 2))
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
kettle = _sig.lfilter(bp, ap, wave) * 0.3
wave += kettle
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
def saxophone_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Saxophone — single reed through a conical brass bore.
The conical bore produces all harmonics (like oboe), but the
brass body and larger mouthpiece give a warmer, fatter, more
vocal quality. The reed adds a slight buzz. Saxophone is
between clarinet (odd harmonics) and oboe (nasal even+odd) —
it has everything, with a strong fundamental and rich mids.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Vibrato — develops after ~250ms, wider than flute
vib_onset = numpy.clip(t / 0.25, 0.0, 1.0)
vib = hz * 0.0012 * vib_onset * numpy.sin(2 * numpy.pi * 5.2 * t)
wave = numpy.zeros(n_samples, dtype=numpy.float64)
n_harmonics = min(20, int((SAMPLE_RATE / 2) / hz))
for n in range(1, n_harmonics + 1):
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
# Sax spectral shape: strong fundamental, broad mid peak (3-6),
# slower rolloff than oboe (brass body carries harmonics further)
if n == 1:
amp = 1.0
elif n <= 3:
amp = 0.6
elif n <= 6:
amp = 0.4 * numpy.exp(-0.1 * (n - 4) ** 2)
else:
amp = 0.2 / n
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * (f_n + vib * n) * t + phase)
# Reed buzz — more present than oboe but still warm
reed = rng.normal(0, 0.07, n_samples)
# Bandpass the reed noise around 1-3kHz (the "honk" range)
import scipy.signal as _sig
reed_lo = max(20, int(hz * 2))
reed_hi = min(SAMPLE_RATE // 2 - 1, int(hz * 6))
if reed_lo < reed_hi:
br, ar = _sig.butter(2, [reed_lo, reed_hi], btype='band', fs=SAMPLE_RATE)
reed = _sig.lfilter(br, ar, reed).astype(numpy.float64) * 2.0
wave += reed
# Brass body warmth — low-mid boost
center = min(1500, hz * 4)
bw = 500
lo = max(20, int(center - bw))
hi = min(SAMPLE_RATE // 2 - 1, int(center + bw))
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
body = _sig.lfilter(bp, ap, wave) * 0.2
wave += body
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
def acoustic_guitar_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Acoustic guitar — Karplus-Strong with wooden body resonance.
@@ -1087,6 +1209,8 @@ class Synth(Enum):
CELLO = "cello_synth"
HARP = "harp_synth"
UPRIGHT_BASS = "upright_bass_synth"
TIMPANI = "timpani_synth"
SAXOPHONE = "saxophone_synth"
ACOUSTIC_GUITAR = "acoustic_guitar_synth"
SITAR = "sitar_synth"
ELECTRIC_GUITAR = "electric_guitar_synth"
@@ -1108,6 +1232,7 @@ _SYNTH_FUNCTIONS = {
"marimba_synth": marimba_wave, "oboe_synth": oboe_wave,
"harpsichord_synth": harpsichord_wave, "cello_synth": cello_wave,
"harp_synth": harp_wave, "upright_bass_synth": upright_bass_wave,
"timpani_synth": timpani_wave, "saxophone_synth": saxophone_wave,
"acoustic_guitar_synth": acoustic_guitar_wave,
"sitar_synth": sitar_wave, "electric_guitar_synth": electric_guitar_wave,
}
+69
View File
@@ -275,6 +275,31 @@ INSTRUMENTS = {
"fm_ratio": 2.0, "fm_index": 3.0,
"reverb": 0.4, "reverb_type": "cathedral",
},
"timpani": {
"synth": "timpani_synth", "envelope": "none",
"reverb": 0.4, "reverb_type": "cathedral",
},
# ── Woodwinds (continued) ──
"saxophone": {
"synth": "saxophone_synth", "envelope": "bowed",
"humanize": 0.15, "vel_to_filter": 1500,
},
"alto_sax": {
"synth": "saxophone_synth", "envelope": "bowed",
"humanize": 0.15, "vel_to_filter": 1800,
},
"tenor_sax": {
"synth": "saxophone_synth", "envelope": "bowed",
"lowpass": 3000,
"humanize": 0.15, "vel_to_filter": 1200,
},
"bari_sax": {
"synth": "saxophone_synth", "envelope": "bowed",
"lowpass": 2000,
"humanize": 0.15, "vel_to_filter": 800,
"sub_osc": 0.15,
},
}
@@ -2435,6 +2460,50 @@ class Part:
return self
def roll(self, tone_or_string, duration=Duration.WHOLE, *,
velocity_start: int = 40, velocity_end: int = 100,
speed=Duration.SIXTEENTH) -> "Part":
"""Play a roll — rapid repeated notes with velocity ramp.
Perfect for timpani rolls, snare rolls, tremolo on any
instrument. The velocity ramps from ``velocity_start`` to
``velocity_end`` over the duration for crescendo/decrescendo.
Args:
tone_or_string: The note to repeat.
duration: Total duration of the roll.
velocity_start: Velocity of the first hit (default 40).
velocity_end: Velocity of the last hit (default 100).
speed: How fast to repeat (default SIXTEENTH notes).
Returns:
Self for chaining.
Example::
>>> timp = score.part("timp", instrument="timpani")
>>> timp.roll("C3", Duration.WHOLE, velocity_start=30, velocity_end=110)
"""
if hasattr(duration, 'value'):
total = duration.value
else:
total = float(duration)
if hasattr(speed, 'value'):
step = speed.value
else:
step = float(speed)
n_hits = max(1, int(total / step))
for i in range(n_hits):
frac = i / max(1, n_hits - 1)
vel = int(velocity_start + (velocity_end - velocity_start) * frac)
vel = max(1, min(127, vel))
remaining = total - i * step
note_dur = min(step, remaining)
if note_dur > 0:
self.add(tone_or_string, note_dur, velocity=vel)
return self
@property
def is_drums(self) -> bool:
"""True if this part contains drum hits."""
+4 -5
View File
@@ -816,8 +816,7 @@ class Tone:
pitch_scale = list(custom_ratios) + [period]
elif period != 2.0 and temperament == "equal":
# Non-octave period (e.g. Bohlen-Pierce tritave=3.0)
import sympy
pitch_scale = [period ** sympy.Rational(i, tones) for i in range(tones + 1)]
pitch_scale = [period ** (i / tones) for i in range(tones + 1)]
else:
pitch_scale = TEMPERAMENTS[temperament](tones)
octave = self.octave if self.octave is not None else 4
@@ -834,7 +833,7 @@ class Tone:
if symbolic:
return reference_pitch * ratio
else:
result = reference_pitch * ratio
result = float(reference_pitch * ratio)
if precision:
return float(result.evalf(precision))
return float(result)
return round(result, precision)
return result
+1 -1
View File
@@ -5320,7 +5320,7 @@ def test_supersaw_wave():
@needs_portaudio
def test_all_synths_in_enum():
from pytheory.play import Synth
assert len(Synth) == 27
assert len(Synth) == 29
for s in Synth:
wave = s(440, n_samples=1000)
assert len(wave) == 1000
Generated
+1 -37
View File
@@ -444,15 +444,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mpmath"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
]
[[package]]
name = "myst-parser"
version = "4.0.1"
@@ -707,11 +698,10 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.34.1"
version = "0.35.0"
source = { editable = "." }
dependencies = [
{ name = "numeral" },
{ name = "pytuning" },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "sounddevice" },
@@ -732,7 +722,6 @@ docs = [
[package.metadata]
requires-dist = [
{ name = "numeral" },
{ name = "pytuning" },
{ name = "scipy" },
{ name = "sounddevice" },
]
@@ -744,19 +733,6 @@ docs = [
{ name = "sphinx" },
]
[[package]]
name = "pytuning"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "sympy" },
]
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 = "pyyaml"
version = "6.0.3"
@@ -1151,18 +1127,6 @@ 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"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mpmath" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
]
[[package]]
name = "tomli"
version = "2.4.0"