mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f02df15b8 | |||
| a2740b8d57 |
@@ -2,6 +2,24 @@
|
||||
|
||||
All notable changes to PyTheory are documented here.
|
||||
|
||||
## 0.38.1
|
||||
|
||||
- **Dynamic curves** — `Part.crescendo()`, `Part.decrescendo()`,
|
||||
`Part.swell()`, and `Part.dynamics()` for velocity ramps and custom
|
||||
curves across a sequence of notes
|
||||
|
||||
## 0.38.0
|
||||
|
||||
- **Articulations** — `staccato`, `legato`, `marcato`, `tenuto`, `accent`,
|
||||
`fermata` via `articulation=` on `Part.add()` and `Part.hold()`
|
||||
- **`Part.hit()`** — place individual drum sounds in a Part's note stream
|
||||
with articulation, velocity, and effects support
|
||||
- **5 new djembe patterns** — dununba, tiriba, yankadi, djansa, mendiani
|
||||
- **3 new djembe fills** — djembe call, djembe roll, djembe break (30 fills total)
|
||||
- **Cross-choke drum damping** — striking one sound fades out related sounds
|
||||
(djembe, hi-hats, cajón, doumbek)
|
||||
- **Improved djembe slap** — dry goatskin pop instead of snare-like noise
|
||||
|
||||
## 0.37.0
|
||||
|
||||
- **5 new djembe patterns** — dununba, tiriba, yankadi, djansa, mendiani
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pytheory"
|
||||
version = "0.37.0"
|
||||
version = "0.38.1"
|
||||
description = "Music Theory for Humans"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""PyTheory: Music Theory for Humans."""
|
||||
|
||||
__version__ = "0.37.0"
|
||||
__version__ = "0.38.1"
|
||||
|
||||
from .tones import Tone, Interval
|
||||
from .systems import System, SYSTEMS, TET
|
||||
|
||||
+49
-4
@@ -4145,10 +4145,48 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
|
||||
start += _rnd.randint(-max_offset, max_offset)
|
||||
start = max(0, start)
|
||||
dur_ms = note.beats * 60_000 / bpm
|
||||
# Articulation: adjust duration and velocity
|
||||
art = getattr(note, 'articulation', '')
|
||||
art_vel_mult = 1.0
|
||||
art_attack_mult = 1.0 # multiplier for envelope attack
|
||||
if art == 'staccato':
|
||||
dur_ms *= 0.4 # short and bouncy
|
||||
elif art == 'legato':
|
||||
dur_ms *= 1.15 # slight overlap into next note
|
||||
elif art == 'marcato':
|
||||
art_vel_mult = 1.25 # heavier
|
||||
art_attack_mult = 0.3 # sharper attack
|
||||
elif art == 'tenuto':
|
||||
art_attack_mult = 1.8 # softer attack, full duration
|
||||
elif art == 'accent':
|
||||
art_vel_mult = 1.2
|
||||
elif art == 'fermata':
|
||||
dur_ms *= 1.5 # held longer
|
||||
n_samples = int(SAMPLE_RATE * dur_ms / 1000)
|
||||
if start + n_samples > total_samples:
|
||||
n_samples = total_samples - start
|
||||
if n_samples > 0 and start >= 0:
|
||||
# Drum hit via Part.hit() — use drum synth directly
|
||||
from .rhythm import _DrumTone
|
||||
if isinstance(note.tone, _DrumTone):
|
||||
drum_wave = _render_drum_hit(note.tone.sound.value, n_samples)
|
||||
mixed = drum_wave.astype(numpy.float32)
|
||||
# Staccato fade-out for drums
|
||||
if art == 'staccato':
|
||||
fade_len = min(int(SAMPLE_RATE * 0.01), len(mixed))
|
||||
if fade_len > 0:
|
||||
mixed[-fade_len:] *= numpy.linspace(1.0, 0.0, fade_len).astype(numpy.float32)
|
||||
vel = getattr(note, 'velocity', 100)
|
||||
vel = min(127, int(vel * art_vel_mult))
|
||||
if humanize > 0.0:
|
||||
vel_jitter = int(humanize * 15)
|
||||
vel = max(1, min(127, vel + _rnd.randint(-vel_jitter, vel_jitter)))
|
||||
vel_scale = vel / 127.0
|
||||
end = min(start + len(mixed), total_samples)
|
||||
buf[start:end] += mixed[:end - start] * volume * vel_scale
|
||||
if not getattr(note, '_hold', False):
|
||||
beat_pos += note.beats
|
||||
continue
|
||||
# Get pitches
|
||||
if hasattr(note.tone, 'tones'):
|
||||
pitches = [t.pitch(temperament=temperament, reference_pitch=reference_pitch) for t in note.tone.tones]
|
||||
@@ -4249,11 +4287,18 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
|
||||
if noise_mix > 0:
|
||||
noise = numpy.random.uniform(-1, 1, n_samples).astype(numpy.float32)
|
||||
mixed = mixed * (1.0 - noise_mix * 0.5) + noise * noise_mix * 0.5
|
||||
# Amplitude envelope
|
||||
if a > 0 or d > 0 or s < 1.0 or r > 0:
|
||||
mixed = _apply_envelope(mixed, a, d, s, r)
|
||||
# Per-note velocity
|
||||
# Amplitude envelope (articulation may adjust attack)
|
||||
art_a = a * art_attack_mult
|
||||
if art_a > 0 or d > 0 or s < 1.0 or r > 0:
|
||||
mixed = _apply_envelope(mixed, art_a, d, s, r)
|
||||
# Staccato: apply a quick fade-out at the end
|
||||
if art == 'staccato':
|
||||
fade_len = min(int(SAMPLE_RATE * 0.01), len(mixed))
|
||||
if fade_len > 0:
|
||||
mixed[-fade_len:] *= numpy.linspace(1.0, 0.0, fade_len).astype(numpy.float32)
|
||||
# Per-note velocity (articulation may boost)
|
||||
vel = getattr(note, 'velocity', 100)
|
||||
vel = min(127, int(vel * art_vel_mult))
|
||||
if humanize > 0.0:
|
||||
vel_jitter = int(humanize * 15)
|
||||
vel = max(1, min(127, vel + _rnd.randint(-vel_jitter, vel_jitter)))
|
||||
|
||||
+182
-4
@@ -449,6 +449,7 @@ class Note:
|
||||
bend: float = 0.0
|
||||
bend_type: str = "smooth" # "smooth" (log), "linear", "late"
|
||||
lyric: str = "" # syllable for vocal synth
|
||||
articulation: str = "" # "", "staccato", "legato", "marcato", "tenuto", "accent", "fermata"
|
||||
_hold: bool = False # if True, don't advance beat position
|
||||
|
||||
@property
|
||||
@@ -565,6 +566,17 @@ class DrumSound(Enum):
|
||||
METAL_HAT = 107 # tight, short, precise
|
||||
|
||||
|
||||
class _DrumTone:
|
||||
"""Wrapper so a DrumSound can be placed in a Part's note list."""
|
||||
__slots__ = ('sound',)
|
||||
|
||||
def __init__(self, sound: DrumSound):
|
||||
self.sound = sound
|
||||
|
||||
def pitch(self, **kwargs):
|
||||
return -self.sound.value
|
||||
|
||||
|
||||
class _Hit:
|
||||
"""A single drum hit at a specific position in a pattern."""
|
||||
__slots__ = ("sound", "position", "velocity")
|
||||
@@ -2624,7 +2636,8 @@ class Part:
|
||||
self._automation: list[tuple[float, dict]] = [] # (beat, {param: value})
|
||||
|
||||
def add(self, tone_or_string, duration=Duration.QUARTER, *, velocity: int = 100,
|
||||
bend: float = 0.0, bend_type: str = "smooth", lyric: str = "") -> "Part":
|
||||
bend: float = 0.0, bend_type: str = "smooth", lyric: str = "",
|
||||
articulation: str = "") -> "Part":
|
||||
"""Add a note. Accepts Tone/Chord objects or note strings like ``"E5"``.
|
||||
|
||||
Duration can be a ``Duration`` enum or a raw float (beats).
|
||||
@@ -2632,6 +2645,10 @@ class Part:
|
||||
Bend specifies a pitch bend in semitones over the note duration
|
||||
(e.g. ``bend=2`` bends up a whole step, ``bend=-1`` bends down
|
||||
a half step). Used for guitar bends, sitar meends, slides.
|
||||
Articulation changes how the note is played: ``"staccato"`` (short,
|
||||
~40% duration), ``"legato"`` (overlaps next note), ``"marcato"``
|
||||
(heavy accent), ``"tenuto"`` (full duration, soft attack),
|
||||
``"accent"`` (velocity bump), ``"fermata"`` (held ~50% longer).
|
||||
|
||||
Returns self for chaining.
|
||||
"""
|
||||
@@ -2642,11 +2659,13 @@ class Part:
|
||||
duration = _RawDuration(duration)
|
||||
self.notes.append(Note(tone=tone_or_string, duration=duration,
|
||||
velocity=velocity, bend=bend,
|
||||
bend_type=bend_type, lyric=lyric))
|
||||
bend_type=bend_type, lyric=lyric,
|
||||
articulation=articulation))
|
||||
return self
|
||||
|
||||
def hold(self, tone_or_string, duration=Duration.QUARTER, *, velocity: int = 100,
|
||||
bend: float = 0.0, bend_type: str = "smooth", lyric: str = "") -> "Part":
|
||||
bend: float = 0.0, bend_type: str = "smooth", lyric: str = "",
|
||||
articulation: str = "") -> "Part":
|
||||
"""Add a note without advancing the beat position.
|
||||
|
||||
The note plays at the current position but the next note
|
||||
@@ -2671,9 +2690,168 @@ class Part:
|
||||
duration = _RawDuration(duration)
|
||||
self.notes.append(Note(tone=tone_or_string, duration=duration,
|
||||
velocity=velocity, bend=bend,
|
||||
bend_type=bend_type, lyric=lyric, _hold=True))
|
||||
bend_type=bend_type, lyric=lyric,
|
||||
articulation=articulation, _hold=True))
|
||||
return self
|
||||
|
||||
def hit(self, sound, duration=Duration.EIGHTH, *, velocity: int = 100,
|
||||
articulation: str = "") -> "Part":
|
||||
"""Add a drum hit to this part.
|
||||
|
||||
Places a drum sound into the note stream so it goes through the
|
||||
normal renderer — meaning articulations, humanize, and effects
|
||||
all work on individual hits.
|
||||
|
||||
Args:
|
||||
sound: A :class:`DrumSound` enum member (e.g. ``DrumSound.KICK``).
|
||||
duration: How long the hit occupies in the timeline (default 8th note).
|
||||
velocity: Hit loudness 1-127.
|
||||
articulation: ``"accent"``, ``"staccato"``, ``"marcato"``, etc.
|
||||
|
||||
Example::
|
||||
|
||||
>>> drums = score.part("kit", synth="sine")
|
||||
>>> drums.hit(DrumSound.KICK, Duration.QUARTER, articulation="accent")
|
||||
>>> drums.hit(DrumSound.CLOSED_HAT, Duration.EIGHTH)
|
||||
"""
|
||||
if isinstance(duration, (int, float)):
|
||||
duration = _RawDuration(duration)
|
||||
self.notes.append(Note(tone=_DrumTone(sound), duration=duration,
|
||||
velocity=velocity, articulation=articulation))
|
||||
return self
|
||||
|
||||
def crescendo(self, notes, duration=Duration.QUARTER, *,
|
||||
start_vel: int = 40, end_vel: int = 110,
|
||||
articulation: str = "") -> "Part":
|
||||
"""Add notes with velocity ramping up (getting louder).
|
||||
|
||||
Args:
|
||||
notes: List of note strings (e.g. ``["C4", "D4", "E4"]``).
|
||||
duration: Duration for each note.
|
||||
start_vel: Starting velocity (quiet).
|
||||
end_vel: Ending velocity (loud).
|
||||
articulation: Optional articulation for all notes.
|
||||
|
||||
Example::
|
||||
|
||||
>>> piano.crescendo(["C4","D4","E4","F4","G4"], Duration.QUARTER,
|
||||
... start_vel=40, end_vel=110)
|
||||
"""
|
||||
return self.dynamics(notes, duration, velocities=(start_vel, end_vel),
|
||||
articulation=articulation)
|
||||
|
||||
def decrescendo(self, notes, duration=Duration.QUARTER, *,
|
||||
start_vel: int = 110, end_vel: int = 40,
|
||||
articulation: str = "") -> "Part":
|
||||
"""Add notes with velocity ramping down (getting quieter).
|
||||
|
||||
Args:
|
||||
notes: List of note strings.
|
||||
duration: Duration for each note.
|
||||
start_vel: Starting velocity (loud).
|
||||
end_vel: Ending velocity (quiet).
|
||||
articulation: Optional articulation for all notes.
|
||||
|
||||
Example::
|
||||
|
||||
>>> piano.decrescendo(["G4","F4","E4","D4","C4"], Duration.QUARTER,
|
||||
... start_vel=110, end_vel=40)
|
||||
"""
|
||||
return self.dynamics(notes, duration, velocities=(start_vel, end_vel),
|
||||
articulation=articulation)
|
||||
|
||||
def dynamics(self, notes, duration=Duration.QUARTER, *,
|
||||
velocities=None, articulation: str = "") -> "Part":
|
||||
"""Add notes with a velocity curve.
|
||||
|
||||
Args:
|
||||
notes: List of note strings or Tone/Chord objects.
|
||||
duration: Duration for each note (or list of durations).
|
||||
velocities: Velocity curve — either a ``(start, end)`` tuple
|
||||
for a linear ramp, or a list of ints (one per note).
|
||||
articulation: Optional articulation for all notes (or list).
|
||||
|
||||
Example::
|
||||
|
||||
>>> # Linear ramp
|
||||
>>> piano.dynamics(["C4","E4","G4","C5"], Duration.QUARTER,
|
||||
... velocities=(50, 120))
|
||||
>>> # Custom curve (swell and fade)
|
||||
>>> piano.dynamics(["C4","D4","E4","F4","G4","F4","E4","D4"],
|
||||
... Duration.EIGHTH,
|
||||
... velocities=[50, 70, 90, 110, 110, 90, 70, 50])
|
||||
"""
|
||||
n = len(notes)
|
||||
if n == 0:
|
||||
return self
|
||||
|
||||
# Resolve velocities
|
||||
if velocities is None:
|
||||
vels = [100] * n
|
||||
elif isinstance(velocities, (tuple, list)) and len(velocities) == 2 and isinstance(velocities[0], (int, float)):
|
||||
# (start, end) tuple — linear ramp
|
||||
start_v, end_v = velocities
|
||||
if n == 1:
|
||||
vels = [int(start_v)]
|
||||
else:
|
||||
vels = [int(start_v + (end_v - start_v) * i / (n - 1))
|
||||
for i in range(n)]
|
||||
else:
|
||||
vels = list(velocities)
|
||||
|
||||
# Resolve durations
|
||||
if isinstance(duration, (list, tuple)):
|
||||
durs = list(duration)
|
||||
else:
|
||||
durs = [duration] * n
|
||||
|
||||
# Resolve articulations
|
||||
if isinstance(articulation, (list, tuple)):
|
||||
arts = list(articulation)
|
||||
else:
|
||||
arts = [articulation] * n
|
||||
|
||||
for note, vel, dur, art in zip(notes, vels, durs, arts):
|
||||
vel = max(1, min(127, vel))
|
||||
self.add(note, dur, velocity=vel, articulation=art)
|
||||
|
||||
return self
|
||||
|
||||
def swell(self, notes, duration=Duration.QUARTER, *,
|
||||
low_vel: int = 40, peak_vel: int = 110,
|
||||
articulation: str = "") -> "Part":
|
||||
"""Add notes that swell up then fade back down (< > shape).
|
||||
|
||||
The velocity ramps up to the midpoint then back down,
|
||||
creating the classic orchestral swell.
|
||||
|
||||
Args:
|
||||
notes: List of note strings.
|
||||
duration: Duration for each note.
|
||||
low_vel: Velocity at start and end.
|
||||
peak_vel: Velocity at the peak (midpoint).
|
||||
articulation: Optional articulation.
|
||||
|
||||
Example::
|
||||
|
||||
>>> strings.swell(["C4","D4","E4","F4","G4","F4","E4","D4"],
|
||||
... Duration.QUARTER, low_vel=40, peak_vel=110)
|
||||
"""
|
||||
n = len(notes)
|
||||
if n <= 2:
|
||||
return self.dynamics(notes, duration, velocities=[peak_vel] * n,
|
||||
articulation=articulation)
|
||||
mid = n // 2
|
||||
vels = []
|
||||
for i in range(n):
|
||||
if i <= mid:
|
||||
v = low_vel + (peak_vel - low_vel) * i / mid
|
||||
else:
|
||||
v = peak_vel - (peak_vel - low_vel) * (i - mid) / (n - 1 - mid)
|
||||
vels.append(int(v))
|
||||
return self.dynamics(notes, duration, velocities=vels,
|
||||
articulation=articulation)
|
||||
|
||||
def set(self, **params) -> "Part":
|
||||
"""Change effect parameters at the current beat position.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user