Compare commits

...

5 Commits

Author SHA1 Message Date
kennethreitz 6efa4f18ce v0.39.0: Articulations, ramp(), drop numeral, djembe expansion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:51:29 -04:00
kennethreitz 06fc4cabb7 Drop numeral dependency, inline Roman numeral helpers
Replace the numeral package with ~30 lines of int2roman()/roman2int()
in _statics.py. Reduces supply chain surface. Fixes #47.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:48:22 -04:00
kennethreitz d3a93c18b3 Add song #31: Acid Tabla (303 + tabla fusion)
Showcases ramp(), articulations, Part.hit(), filter automation,
and cross-genre fusion. 303 acid bass with tabla entering at the
peak and riding through the outro.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:26:20 -04:00
kennethreitz 0e10359236 v0.38.2: Part.ramp() for smooth parameter automation
Smoothly sweep any parameter (lowpass, reverb, distortion, etc.)
from current value to target with linear, ease_in, ease_out, or
ease_in_out interpolation curves.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:12:32 -04:00
kennethreitz df00c3436d Docs: articulations, dynamic curves, Part.hit(), Duration arithmetic
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:24:21 -04:00
10 changed files with 414 additions and 23 deletions
+23
View File
@@ -2,6 +2,29 @@
All notable changes to PyTheory are documented here.
## 0.39.0
- **Dropped `numeral` dependency** — Roman numeral helpers inlined,
reducing supply chain surface (#47)
- **`Part.ramp()`** — smooth parameter automation with 4 interpolation
curves (linear, ease_in, ease_out, ease_in_out)
- **Articulations** — staccato, legato, marcato, tenuto, accent, fermata
- **Dynamic curves** — crescendo(), decrescendo(), swell(), dynamics()
- **`Part.hit()`** — individual drum sounds with articulation support
- **Cross-choke drum damping** — djembe, hi-hats, cajón, doumbek
- **5 new djembe patterns** + 3 djembe fills (30 fills total)
- **6 new drum fills** — 3 cajón, 3 metal
- **Duration arithmetic** — multiply, divide, add
- **Improved djembe slap** synthesis
- Song #31: Acid Tabla
## 0.38.2
- **`Part.ramp()`** — smooth parameter automation from current value to
target over a duration. Works for lowpass, reverb, distortion, chorus,
delay, volume, and any `.set()` parameter. Four interpolation curves:
linear, ease_in, ease_out, ease_in_out.
## 0.38.1
- **Dynamic curves** — `Part.crescendo()`, `Part.decrescendo()`,
+152
View File
@@ -47,6 +47,18 @@ A ``Duration`` represents a note length in beats (quarter note = 1 beat):
>>> Duration.TRIPLET_QUARTER.value
0.6666666666666666
Duration supports arithmetic — multiply, divide, and add to create
compound durations:
.. code-block:: pycon
>>> Duration.WHOLE * 2
8.0
>>> Duration.HALF + Duration.QUARTER
3.0
>>> Duration.WHOLE / 2
2.0
Time Signatures
---------------
@@ -399,6 +411,102 @@ The arpeggiator also accepts velocity:
lead.arpeggio("Am", bars=2, pattern="up", velocity=80)
Articulations
-------------
Articulations change *how* a note is played — its attack, duration, and
weight. A staccato note is short and bouncy. A marcato note hits hard.
A legato note melts into the next one. This is the difference between
a melody that sounds like a MIDI file and one that sounds like a
musician played it.
Pass ``articulation=`` to ``Part.add()``:
.. code-block:: python
piano.add("C4", Duration.QUARTER, articulation="staccato") # short, bouncy
piano.add("D4", Duration.QUARTER, articulation="legato") # smooth, overlaps
piano.add("E4", Duration.QUARTER, articulation="marcato") # heavy accent
piano.add("F4", Duration.QUARTER, articulation="tenuto") # held, soft attack
piano.add("G4", Duration.QUARTER, articulation="accent") # louder
piano.add("C5", Duration.HALF, articulation="fermata") # held longer
What each articulation does:
- **staccato** — plays ~40% of the note duration with a quick fade-out. Short and detached.
- **legato** — extends ~15% into the next note. Smooth and connected.
- **marcato** — 25% velocity boost + sharper attack. Heavy and accented.
- **tenuto** — full duration with a softer attack ramp. Held and deliberate.
- **accent** — 20% velocity boost, no duration change.
- **fermata** — stretches the note 50% longer.
Articulations work on ``Part.hold()`` and ``Part.hit()`` too.
Dynamic Curves
--------------
Real music breathes — phrases get louder, get quieter, swell and
recede. Dynamic curves let you shape the velocity across a sequence
of notes instead of setting each one manually.
.. code-block:: python
# Crescendo: quiet to loud
piano.crescendo(["C4","D4","E4","F4","G4","A4","B4","C5"],
Duration.QUARTER, start_vel=30, end_vel=110)
# Decrescendo: loud to quiet
piano.decrescendo(["C5","B4","A4","G4","F4","E4","D4","C4"],
Duration.QUARTER, start_vel=110, end_vel=30)
# Swell: up then back down (orchestral < > shape)
strings.swell(["C4","D4","E4","F4","G4","F4","E4","D4"],
Duration.QUARTER, low_vel=35, peak_vel=110)
# Custom curve: explicit velocity per note
piano.dynamics(["C4","E4","G4","C5"], Duration.QUARTER,
velocities=[50, 80, 110, 90])
Four methods:
- **crescendo()** — linear velocity ramp from ``start_vel`` to ``end_vel``.
- **decrescendo()** — same thing, but typically loud to quiet.
- **swell()** — ramps up to the midpoint, then back down. The classic
orchestral crescendo-decrescendo.
- **dynamics()** — the general form. Pass a ``(start, end)`` tuple for
a linear ramp, or a list of velocities for a custom curve.
All four accept ``articulation=`` to combine dynamics with articulations:
.. code-block:: python
# Staccato crescendo — bouncy notes getting louder
piano.crescendo(["C4","E4","G4","C5","E5","G5","C6","E6"],
Duration.EIGHTH, start_vel=40, end_vel=110,
articulation="staccato")
Part.hit() — Manual Drum Placement
-----------------------------------
The pattern system is great for grooves, but sometimes you want to
place individual drum hits with full control — articulations, effects,
and all. ``Part.hit()`` puts a drum sound into a Part's note stream:
.. code-block:: python
from pytheory import DrumSound
kit = score.part("kit", synth="sine", volume=0.7)
kit.hit(DrumSound.KICK, Duration.QUARTER, articulation="accent")
kit.hit(DrumSound.CLOSED_HAT, Duration.EIGHTH, velocity=60)
kit.hit(DrumSound.SNARE, Duration.EIGHTH, articulation="marcato")
Because hits go through the normal Part renderer, they get humanize,
effects, and articulations for free. Use this for custom beats that
don't fit a preset pattern, or for one-shot accent hits layered on
top of a pattern.
Swing and Groove
----------------
@@ -478,6 +586,50 @@ integrate naturally with the rest of the automation system:
pad.rest(Duration.WHOLE)
pad.rest(Duration.WHOLE)
Parameter Ramps
---------------
Fades only control volume. ``Part.ramp()`` smoothly sweeps *any*
parameter from its current value to a target — filters, reverb,
distortion, chorus, delay, anything ``.set()`` accepts. This is how
you build filter sweeps, gradual effect sends, and EDM buildups.
.. code-block:: python
lead = score.part("lead", synth="saw", lowpass=200, lowpass_q=3.0)
# Open the filter over 8 bars
lead.ramp(over=Duration.WHOLE * 8, lowpass=8000)
# Ramp multiple params at once
pad.ramp(over=Duration.WHOLE * 4, reverb=0.5, chorus=0.3)
# Close the filter with distortion fading in
lead.ramp(over=Duration.WHOLE * 4, lowpass=400, distortion=0.5)
Four interpolation curves:
- **linear** — constant rate of change (default).
- **ease_in** — starts slow, accelerates. Good for buildups.
- **ease_out** — starts fast, decelerates. Good for releases.
- **ease_in_out** — slow at both ends. Smooth and natural.
.. code-block:: python
# EDM buildup: slow start, accelerating filter sweep
lead.ramp(over=Duration.WHOLE * 8, curve="ease_in", lowpass=8000)
# Smooth reverb wash fading in and settling
pad.ramp(over=Duration.WHOLE * 4, curve="ease_in_out", reverb=0.6)
``ramp()`` generates automation points every quarter-beat by default.
Set ``resolution=0.125`` for smoother curves (every 32nd note), or
``resolution=1.0`` for lighter automation (every beat).
Combine with ``lfo()`` for cyclic modulation and ``ramp()`` for
one-shot sweeps — together they cover the full range of parameter
automation.
Humanize
--------
+118 -1
View File
@@ -2324,6 +2324,122 @@ def sitar_drone():
play_song(score, "Sitar Drone — Raga Bhairav (22-Shruti JI, hold() polyphony)")
def acid_tabla():
"""Acid Tabla — 303 filter automation meets Indian percussion."""
score = Score("4/4", bpm=132)
# ── House drums ──
score.drums("house", repeats=20, fill="house", fill_every=8)
score.set_drum_effects(volume=0.45)
# ── 303 acid bass ──
acid = score.part("acid", synth="saw", volume=0.75,
legato=True, glide=0.035,
distortion=0.35, distortion_drive=4.5,
saturation=0.15, humanize=0.05)
# Intro (4 bars): filter closed, high resonance
acid.set(lowpass=600, lowpass_q=12.0)
for _ in range(4):
for n in ["C3","C3","C2","C3","Eb3","C2","G2","C3"]:
acid.add(n, Duration.EIGHTH)
# Build (4 bars): filter opens
acid.ramp(over=Duration.WHOLE * 4, curve="ease_in", lowpass=4500)
for _ in range(4):
for n in ["C2","G2","C3","Eb3","C2","Bb2","G2","C3"]:
acid.add(n, Duration.EIGHTH)
# Peak (4 bars): wide open, wilder pattern
acid.set(lowpass=7000, lowpass_q=7.0)
for _ in range(2):
for n in ["C2","C3","Eb3","G3","C2","Bb2","G2","Eb3"]:
acid.add(n, Duration.EIGHTH)
for _ in range(2):
for n in ["C2","Eb3","C3","G3","Bb2","C3","G2","C2"]:
acid.add(n, Duration.EIGHTH)
# Tabla section (4 bars): filter pulls back
acid.set(lowpass=3000, lowpass_q=5.0)
for _ in range(4):
for n in ["C2","G2","C3","C2","Eb2","G2","Bb2","C2"]:
acid.add(n, Duration.EIGHTH)
# Outro (4 bars): filter closes
acid.ramp(over=Duration.WHOLE * 4, curve="ease_out", lowpass=400, lowpass_q=15.0)
for _ in range(4):
for n in ["C3","G2","C2","C3","C2","G2","Eb2","C2"]:
acid.add(n, Duration.EIGHTH)
# ── Tabla: enters bar 9, rides through to the end ──
tabla = score.part("tabla", synth="sine", volume=0.55, reverb=0.15)
# 8 bars rest
for _ in range(64):
tabla.rest(Duration.EIGHTH)
# Bars 9-12: keherwa groove
for _ in range(4):
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=100, articulation="accent")
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=55)
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=50)
tabla.hit(DrumSound.TABLA_NA, Duration.EIGHTH, velocity=88)
tabla.hit(DrumSound.TABLA_TIN, Duration.EIGHTH, velocity=82)
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=52)
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=95, articulation="accent")
tabla.hit(DrumSound.TABLA_GE, Duration.EIGHTH, velocity=78)
# Bars 13-14: busier with 16ths
for _ in range(2):
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=105, articulation="marcato")
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=48)
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=52)
tabla.hit(DrumSound.TABLA_NA, Duration.EIGHTH, velocity=90)
tabla.hit(DrumSound.TABLA_TIN, Duration.EIGHTH, velocity=85)
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=48)
tabla.hit(DrumSound.TABLA_NA, Duration.SIXTEENTH, velocity=58)
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=100, articulation="accent")
tabla.hit(DrumSound.TABLA_GE_BEND, Duration.EIGHTH, velocity=88)
# Bars 15-16: tihai crescendo ending
for vel in [85, 90, 95]:
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=vel, articulation="accent")
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=int(vel * 0.6))
tabla.hit(DrumSound.TABLA_NA, Duration.SIXTEENTH, velocity=int(vel * 0.75))
for vel in [100, 105, 110]:
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=vel, articulation="marcato")
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=int(vel * 0.55))
tabla.hit(DrumSound.TABLA_NA, Duration.SIXTEENTH, velocity=int(vel * 0.7))
tabla.hit(DrumSound.TABLA_DHA, Duration.QUARTER, velocity=127, articulation="fermata")
tabla.hit(DrumSound.TABLA_GE_BEND, Duration.QUARTER, velocity=110)
tabla.rest(Duration.HALF)
# Bars 17-20: tabla continues through outro, lighter
for _ in range(4):
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=85, articulation="accent")
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=45)
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=42)
tabla.hit(DrumSound.TABLA_NA, Duration.EIGHTH, velocity=75)
tabla.hit(DrumSound.TABLA_TIN, Duration.EIGHTH, velocity=70)
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=42)
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=80)
tabla.hit(DrumSound.TABLA_GE, Duration.EIGHTH, velocity=65)
# ── Pad: enters at peak, fades during outro ──
pad = score.part("pad", synth="supersaw", envelope="pad", volume=0.0,
reverb=0.4, chorus=0.2, detune=10, lowpass=2500)
for _ in range(32):
pad.rest(Duration.QUARTER)
pad.ramp(over=Duration.WHOLE * 2, volume=0.18)
for sym in ["Cm", "Ab", "Eb", "Bb"] * 3:
pad.add(Chord.from_symbol(sym), Duration.WHOLE)
pad.ramp(over=Duration.WHOLE * 2, curve="ease_out", volume=0.0)
for sym in ["Cm", "Cm"]:
pad.add(Chord.from_symbol(sym), Duration.WHOLE)
play_song(score, "Acid Tabla — 303 filter automation + tabla (ramp, articulations, Part.hit)")
SONGS = {
"1": ("Bossa Nova in A minor", bossa_nova_girl),
"2": ("Bebop in Bb major", bebop_in_bb),
@@ -2355,6 +2471,7 @@ SONGS = {
"28": ("Descent (Generative — different every time)", descent),
"29": ("Pop Rock (I-V-vi-IV)", pop_rock),
"30": ("Sitar Drone (Bhairav, hold() polyphony)", sitar_drone),
"31": ("Acid Tabla (303 + tabla, ramp, articulations)", acid_tabla),
}
if __name__ == "__main__":
@@ -2368,7 +2485,7 @@ if __name__ == "__main__":
print(f" {key:>2}. {name}")
print()
choice = input(" Pick a song (1-30, or 'all'): ").strip()
choice = input(" Pick a song (1-31, or 'all'): ").strip()
print()
if choice == "all":
+1 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.38.1"
version = "0.39.0"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
@@ -21,7 +21,6 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
dependencies = [
"numeral",
"sounddevice",
"scipy",
]
+1 -1
View File
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.38.1"
__version__ = "0.39.0"
from .tones import Tone, Interval
from .systems import System, SYSTEMS, TET
+37
View File
@@ -2,6 +2,43 @@ import math
REFERENCE_A = 440
# ── Roman numeral helpers (replaces `numeral` package) ───────────────────
_ROMAN_MAP = [
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
(100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
(10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
]
_ROMAN_VALUES = {
"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000,
}
def int2roman(n: int) -> str:
"""Convert an integer to an uppercase Roman numeral string."""
result = []
for value, numeral in _ROMAN_MAP:
while n >= value:
result.append(numeral)
n -= value
return "".join(result)
def roman2int(s: str) -> int:
"""Convert a Roman numeral string (case-insensitive) to an integer."""
s = s.upper()
total = 0
prev = 0
for ch in reversed(s):
val = _ROMAN_VALUES.get(ch, 0)
if val < prev:
total -= val
else:
total += val
prev = val
return total
# Index of C in the Western tone list (A=0, A#=1, B=2, C=3, ...).
# Scientific pitch notation changes octave at C, not A, so this offset
# is needed for all octave arithmetic.
+2 -2
View File
@@ -849,7 +849,7 @@ class Chord:
>>> Chord([D4, F4, A4]).analyze("C")
'ii'
"""
import numeral as numeral_mod
from ._statics import int2roman
from .scales import TonedScale
from .systems import SYSTEMS
from .tones import Tone
@@ -874,7 +874,7 @@ class Chord:
scale_names = [t.name for t in scale.tones[:-1]]
def _build_numeral(root, quality, degree_idx, prefix=""):
numeral_str = numeral_mod.int2roman(degree_idx + 1, only_ascii=True)
numeral_str = int2roman(degree_idx + 1)
suffix = ""
if "minor" in quality:
numeral_str = numeral_str.lower()
+73
View File
@@ -2935,6 +2935,79 @@ class Part:
points = sorted(set(beat for beat, _ in self._automation))
return points
def ramp(self, over: float = 4.0, resolution: float = 0.25,
curve: str = "linear", **params) -> "Part":
"""Smoothly ramp parameters from their current values to new targets.
Generates interpolated automation points — like turning a knob
gradually instead of jumping to a new position. Works for any
parameter that ``.set()`` accepts.
Args:
over: Duration of the ramp in beats (default 4.0 = 1 bar).
Use ``Duration.WHOLE * 4`` for a 4-bar ramp, etc.
resolution: How often to insert points, in beats (default 0.25).
Lower = smoother but more points.
curve: Interpolation shape — ``"linear"`` (default),
``"ease_in"`` (slow start, fast end),
``"ease_out"`` (fast start, slow end),
``"ease_in_out"`` (slow start and end).
**params: Target values for any parameter. The ramp starts
from the parameter's current value at this beat position.
Returns:
Self for chaining.
Example::
>>> lead = score.part("lead", synth="saw", lowpass=200)
>>> # Open the filter over 4 bars
>>> lead.ramp(over=Duration.WHOLE * 4, lowpass=8000)
>>> # Fade reverb in over 2 bars
>>> pad.ramp(over=Duration.WHOLE * 2, reverb=0.5)
>>> # Multiple params at once with easing
>>> lead.ramp(over=8.0, curve="ease_in", lowpass=6000, distortion=0.4)
"""
current_beat = sum(n.beats for n in self.notes)
# Map param names to internal names
param_map = {
"reverb": "reverb_mix", "delay": "delay_mix",
"distortion": "distortion_mix", "chorus": "chorus_mix",
"phaser": "phaser_mix",
}
# Get current values for each param
current_params = self._get_params_at(current_beat)
ramps = {}
for param, target in params.items():
internal = param_map.get(param, param)
start = current_params.get(internal, getattr(self, internal, 0.0))
ramps[internal] = (float(start), float(target))
# Generate interpolated points
beat = 0.0
while beat <= over:
t = beat / over if over > 0 else 1.0
t = max(0.0, min(1.0, t))
# Apply curve
if curve == "ease_in":
t = t * t
elif curve == "ease_out":
t = 1.0 - (1.0 - t) ** 2
elif curve == "ease_in_out":
t = 3 * t * t - 2 * t * t * t
point = {}
for internal, (start, end) in ramps.items():
point[internal] = start + (end - start) * t
self._automation.append((current_beat + beat, point))
beat += resolution
return self
def lfo(self, param: str, *, rate: float = 0.5, min: float = 0.0,
max: float = 1.0, bars: float = 4, shape: str = "sine",
resolution: float = 0.25) -> "Part":
+6 -6
View File
@@ -2,8 +2,6 @@ from __future__ import annotations
from typing import Optional, Union
import numeral
from .systems import SYSTEMS, System
from .tones import Tone
@@ -49,7 +47,8 @@ class Scale:
def __repr__(self) -> str:
r = []
for (i, tone) in enumerate(self.tones):
degree = numeral.int2roman(i + 1, only_ascii=True)
from ._statics import int2roman
degree = int2roman(i + 1)
r += [f"{degree}={tone.full_name}"]
r = " ".join(r)
@@ -200,7 +199,7 @@ class Scale:
>>> 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
from ._statics import roman2int
chords = []
for num in numerals:
is_seventh = num.endswith("7")
@@ -213,7 +212,7 @@ class Scale:
elif clean.startswith("#") and len(clean) > 1:
clean = clean[1:]
flat_offset = 1 # one semitone up
degree = numeral_mod.roman2int(clean.upper()) - 1
degree = roman2int(clean.upper()) - 1
if is_seventh:
chord = self.seventh(degree)
else:
@@ -406,7 +405,8 @@ class Scale:
if isinstance(item, str):
degrees = []
for (i, tone) in enumerate(self.tones):
degrees.append(numeral.int2roman(i + 1, only_ascii=True))
from ._statics import int2roman
degrees.append(int2roman(i + 1))
if item in degrees:
item = degrees.index(item)
Generated
+1 -11
View File
@@ -486,14 +486,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" },
]
[[package]]
name = "numeral"
version = "0.1.0.17"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/0d/ac6a186e169fcbdfea316f78fb5e34981bcf8d5c1d7cc8b6581f597e1e4c/numeral-0.1.0.17-py2.py3-none-any.whl", hash = "sha256:7dff0c1efb9b3655c9c1dc93b4666993741b15abcac0dc01dcb96b21cc20f6ae", size = 22066, upload-time = "2020-04-12T08:24:59.129Z" },
]
[[package]]
name = "numpy"
version = "2.2.6"
@@ -698,10 +690,9 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.38.1"
version = "0.39.0"
source = { editable = "." }
dependencies = [
{ name = "numeral" },
{ 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" },
@@ -721,7 +712,6 @@ docs = [
[package.metadata]
requires-dist = [
{ name = "numeral" },
{ name = "scipy" },
{ name = "sounddevice" },
]