v0.24.0: Velocity, swing, tempo changes, fade in/out

- Per-note velocity for dynamics and accents
- Swing/groove parameter on Score and per-Part override
- score.set_tempo() for mid-song tempo changes with tempo map engine
- Part.fade_in() and Part.fade_out() volume envelopes
- Arpeggiator velocity support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 14:44:50 -04:00
parent f9654fcdea
commit ebf26cfbfa
7 changed files with 530 additions and 34 deletions
+19
View File
@@ -2,6 +2,25 @@
All notable changes to PyTheory are documented here.
## 0.24.0
- Add per-note velocity: `lead.add("C5", Duration.QUARTER, velocity=90)` — dynamics, accents, ghost notes
- Add swing/groove: `Score("4/4", bpm=120, swing=0.5)` — shuffles every other note for human feel
- Add tempo changes mid-song: `score.set_tempo(140)` — accelerando, ritardando, tempo drops
- Add `Part.fade_in(bars)` and `Part.fade_out(bars)` — volume envelopes over sections
- Arpeggiator supports velocity parameter
- Per-part swing override (set independently from score swing)
- Tempo map engine: beat-to-sample conversion handles variable BPM throughout a score
## 0.23.0
- Add convolution reverb with 7 synthetic impulse responses: Taj Mahal, cathedral, plate, spring, cave, parking garage, canyon
- Each IR models real acoustic properties: early reflections, frequency-dependent absorption, diffusion density, and modulation
- FFT-based convolution via `scipy.signal.fftconvolve` for fast processing even with long tails (12s Taj Mahal)
- Select via `reverb_type` parameter on `Score.part()` — drop-in alongside existing algorithmic reverb
- IR cache for zero-cost reuse across parts
- Automatable via `Part.set(reverb_type="cathedral")` mid-song
## 0.22.0
- Add `Part.lfo()` for automated parameter modulation (filter sweeps, tremolo, auto-wah)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.22.0"
version = "0.24.0"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.22.0"
__version__ = "0.24.0"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
+321 -21
View File
@@ -706,6 +706,233 @@ def play_pattern(pattern, repeats=1, bpm=120):
# ── Audio effects ───────────────────────────────────────────────────────────
# ── Convolution reverb impulse responses ───────────────────────────────────
def _generate_ir(preset="taj_mahal", sample_rate=SAMPLE_RATE):
"""Generate a synthetic impulse response for convolution reverb.
These model the acoustic properties of real spaces — early reflections
pattern, decay envelope, frequency-dependent absorption, and diffusion.
Available presets:
taj_mahal: Massive marble dome — 12s decay, bright early reflections,
long diffuse tail with high-frequency rolloff.
cathedral: Gothic stone cathedral — 6s decay, strong early reflections
off parallel walls, dark reverberant tail.
plate: EMT 140 plate reverb — 4s, dense, bright, smooth.
The studio classic.
spring: Spring reverb tank — 3s, metallic, boingy, lo-fi character.
cave: Natural cave — 8s, very dark, irregular reflections.
parking_garage: Concrete box — 3s, bright, flutter echoes.
canyon: Open canyon — 5s, sparse discrete echoes then diffuse tail.
"""
presets = {
"taj_mahal": dict(
duration=12.0,
early_delays=[0.018, 0.037, 0.052, 0.071, 0.089, 0.112, 0.134,
0.158, 0.183, 0.211, 0.243, 0.278, 0.315],
early_gains=[0.8, 0.72, 0.65, 0.58, 0.52, 0.46, 0.41,
0.36, 0.32, 0.28, 0.24, 0.20, 0.17],
decay_time=12.0,
hf_damping=0.7, # marble absorbs highs slowly
density=8000, # very dense tail (huge dome)
brightness=0.6,
modulation=0.003, # subtle pitch modulation from dome shape
),
"cathedral": dict(
duration=6.0,
early_delays=[0.012, 0.024, 0.041, 0.058, 0.073, 0.095,
0.118, 0.145, 0.172],
early_gains=[0.85, 0.75, 0.65, 0.55, 0.48, 0.40,
0.33, 0.27, 0.22],
decay_time=6.0,
hf_damping=0.8, # stone absorbs highs
density=5000,
brightness=0.4,
modulation=0.002,
),
"plate": dict(
duration=4.0,
early_delays=[0.003, 0.007, 0.011, 0.016, 0.022, 0.029],
early_gains=[0.9, 0.85, 0.78, 0.70, 0.62, 0.54],
decay_time=4.0,
hf_damping=0.3, # metal plate — bright
density=12000, # very dense, smooth
brightness=0.85,
modulation=0.001,
),
"spring": dict(
duration=3.0,
early_delays=[0.005, 0.032, 0.064, 0.097, 0.131],
early_gains=[0.95, 0.7, 0.5, 0.35, 0.25],
decay_time=3.0,
hf_damping=0.6,
density=2000, # sparse — you hear the spring
brightness=0.5,
modulation=0.012, # springy wobble
),
"cave": dict(
duration=8.0,
early_delays=[0.025, 0.058, 0.094, 0.138, 0.189, 0.248, 0.312],
early_gains=[0.7, 0.55, 0.42, 0.32, 0.24, 0.18, 0.13],
decay_time=8.0,
hf_damping=0.9, # rock absorbs highs aggressively
density=3000,
brightness=0.2, # very dark
modulation=0.005,
),
"parking_garage": dict(
duration=3.0,
early_delays=[0.008, 0.016, 0.024, 0.033, 0.041, 0.050,
0.058, 0.067],
early_gains=[0.9, 0.82, 0.75, 0.68, 0.62, 0.56, 0.50, 0.45],
decay_time=3.0,
hf_damping=0.3, # concrete — bright
density=6000,
brightness=0.8,
modulation=0.0005,
),
"canyon": dict(
duration=5.0,
early_delays=[0.12, 0.28, 0.45, 0.67, 0.91],
early_gains=[0.6, 0.4, 0.28, 0.18, 0.11],
decay_time=5.0,
hf_damping=0.5,
density=1500, # sparse — open air
brightness=0.5,
modulation=0.002,
),
}
if preset not in presets:
raise ValueError(
f"Unknown IR preset {preset!r}. "
f"Available: {', '.join(sorted(presets))}"
)
p = presets[preset]
n_samples = int(p["duration"] * sample_rate)
ir = numpy.zeros(n_samples, dtype=numpy.float32)
# 1. Early reflections — discrete taps
for delay, gain in zip(p["early_delays"], p["early_gains"]):
idx = int(delay * sample_rate)
if idx < n_samples:
ir[idx] += gain
# 2. Diffuse tail — shaped noise with exponential decay
rng = numpy.random.RandomState(42) # deterministic for reproducibility
noise = rng.randn(n_samples).astype(numpy.float32)
# Exponential decay envelope
t = numpy.arange(n_samples, dtype=numpy.float32) / sample_rate
decay_env = numpy.exp(-6.91 / p["decay_time"] * t) # -60dB at decay_time
# HF damping — apply progressive lowpass to the tail
# Simulate frequency-dependent absorption: highs decay faster
if p["hf_damping"] > 0:
# Simple 1-pole lowpass applied cumulatively
alpha = p["hf_damping"] * 0.15
filtered = numpy.zeros_like(noise)
filtered[0] = noise[0]
for i in range(1, n_samples):
# Time-varying cutoff: gets darker over time
a = min(alpha * (1 + t[i] / p["decay_time"]), 0.95)
filtered[i] = filtered[i - 1] * a + noise[i] * (1 - a)
noise = filtered
# Brightness control — overall spectral tilt
if p["brightness"] < 0.5:
cutoff = 1000 + p["brightness"] * 8000
b, a = scipy.signal.butter(1, cutoff / (sample_rate / 2), btype='low')
noise = scipy.signal.lfilter(b, a, noise).astype(numpy.float32)
elif p["brightness"] > 0.7:
# Add a gentle high shelf boost
cutoff = 2000
b, a = scipy.signal.butter(1, cutoff / (sample_rate / 2), btype='high')
hf = scipy.signal.lfilter(b, a, noise).astype(numpy.float32)
noise = noise + hf * (p["brightness"] - 0.5)
# Subtle pitch modulation (simulates irregular surfaces)
if p["modulation"] > 0:
mod_freq = 0.5 + rng.rand() * 1.5
mod = numpy.sin(2 * numpy.pi * mod_freq * t) * p["modulation"]
# Apply as sample-offset jitter
indices = numpy.arange(n_samples, dtype=numpy.float32) + mod * sample_rate
indices = numpy.clip(indices, 0, n_samples - 1)
noise = numpy.interp(indices, numpy.arange(n_samples), noise).astype(
numpy.float32
)
# Build the tail — start after early reflections end
early_end = int(max(p["early_delays"]) * sample_rate) if p["early_delays"] else 0
tail_onset = numpy.zeros(n_samples, dtype=numpy.float32)
tail_onset[early_end:] = 1.0
# Smooth crossfade
fade_len = min(int(0.02 * sample_rate), n_samples - early_end)
if fade_len > 0:
tail_onset[early_end:early_end + fade_len] = numpy.linspace(
0, 1, fade_len
)
density_scale = p["density"] / 8000.0
ir += noise * decay_env * tail_onset * density_scale * 0.15
# 3. Normalize
peak = numpy.max(numpy.abs(ir))
if peak > 0:
ir /= peak
return ir
# IR cache — generate once, reuse
_IR_CACHE: dict[str, numpy.ndarray] = {}
def _get_ir(preset, sample_rate=SAMPLE_RATE):
"""Get a cached impulse response."""
key = f"{preset}_{sample_rate}"
if key not in _IR_CACHE:
_IR_CACHE[key] = _generate_ir(preset, sample_rate)
return _IR_CACHE[key]
def _apply_convolution_reverb(samples, preset="taj_mahal", mix=0.3,
sample_rate=SAMPLE_RATE):
"""Apply convolution reverb using a synthetic impulse response.
Convolves the input signal with an IR that models the acoustic
properties of a real space — far more realistic than algorithmic reverb.
Args:
samples: Float32 numpy array.
preset: IR preset name (taj_mahal, cathedral, plate, spring,
cave, parking_garage, canyon).
mix: Wet/dry ratio 0.01.0.
sample_rate: Sample rate in Hz.
Returns:
Float32 array with convolution reverb applied (same length as input).
"""
if mix <= 0:
return samples
ir = _get_ir(preset, sample_rate)
# FFT-based convolution — fast even for long IRs
wet = scipy.signal.fftconvolve(samples, ir, mode='full')[:len(samples)]
wet = wet.astype(numpy.float32)
# Normalize wet signal to match dry RMS
dry_rms = numpy.sqrt(numpy.mean(samples ** 2)) + 1e-10
wet_rms = numpy.sqrt(numpy.mean(wet ** 2)) + 1e-10
wet *= dry_rms / wet_rms
return samples * (1 - mix) + wet * mix
def _apply_reverb(samples, mix=0.3, decay=1.0, sample_rate=SAMPLE_RATE):
"""Apply a simple Schroeder reverb to a float32 buffer.
@@ -932,8 +1159,16 @@ def _apply_effects_with_params(samples, params):
time=params.get("delay_time", 0.375),
feedback=params.get("delay_feedback", 0.4))
if params.get("reverb_mix", 0) > 0:
samples = _apply_reverb(samples, mix=params["reverb_mix"],
decay=params.get("reverb_decay", 1.0))
reverb_type = params.get("reverb_type", "algorithmic")
if reverb_type != "algorithmic" and reverb_type in (
"taj_mahal", "cathedral", "plate", "spring",
"cave", "parking_garage", "canyon",
):
samples = _apply_convolution_reverb(
samples, preset=reverb_type, mix=params["reverb_mix"])
else:
samples = _apply_reverb(samples, mix=params["reverb_mix"],
decay=params.get("reverb_decay", 1.0))
return samples
@@ -952,6 +1187,7 @@ def _apply_part_effects(samples, part):
"delay_feedback": part.delay_feedback,
"reverb_mix": part.reverb_mix,
"reverb_decay": part.reverb_decay,
"reverb_type": getattr(part, "reverb_type", "algorithmic"),
}
return _apply_effects_with_params(samples, params)
@@ -967,19 +1203,55 @@ def _resolve_envelope(name):
return _map.get(name, Envelope.PIANO.value)
def _build_tempo_map(score):
"""Return sorted list of (beat, samples_per_beat) tuples."""
changes = [(0.0, int(SAMPLE_RATE * 60.0 / score.bpm))]
for beat, bpm in sorted(score._tempo_changes):
changes.append((beat, int(SAMPLE_RATE * 60.0 / bpm)))
return changes
def _beat_to_sample(beat, tempo_map):
"""Convert a beat position to a sample position using tempo map."""
sample = 0
prev_beat = 0.0
prev_spb = tempo_map[0][1]
for change_beat, spb in tempo_map[1:]:
if beat <= change_beat:
break
sample += int((change_beat - prev_beat) * prev_spb)
prev_beat = change_beat
prev_spb = spb
sample += int((beat - prev_beat) * prev_spb)
return sample
def _total_samples_from_tempo_map(total_beats, tempo_map):
"""Compute total samples accounting for tempo changes."""
return _beat_to_sample(total_beats, tempo_map)
def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
synth_fn, envelope_tuple, volume, bpm):
synth_fn, envelope_tuple, volume, bpm,
swing=0.0, tempo_map=None):
"""Render a list of Notes into an existing buffer at the correct positions."""
a, d, s, r = envelope_tuple
beat_pos = 0.0
for note in notes:
for note_index, note in enumerate(notes):
if note.tone is not None:
start = int(beat_pos * samples_per_beat)
if tempo_map and len(tempo_map) > 1:
start = _beat_to_sample(beat_pos, tempo_map)
else:
start = int(beat_pos * samples_per_beat)
# Apply swing: shift every other note later
if swing > 0.0 and note_index % 2 == 1:
swing_offset = int(swing * 0.5 * samples_per_beat)
start += swing_offset
dur_ms = note.beats * 60_000 / bpm
n_samples = int(SAMPLE_RATE * dur_ms / 1000)
if start + n_samples > total_samples:
n_samples = total_samples - start
if n_samples > 0:
if n_samples > 0 and start >= 0:
# Get pitches
if hasattr(note.tone, 'tones'):
waves = [synth_fn(t.pitch(), n_samples=n_samples)
@@ -989,14 +1261,16 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
mixed = sum(w.astype(numpy.float32) for w in waves) / SAMPLE_PEAK
if a > 0 or d > 0 or s < 1.0 or r > 0:
mixed = _apply_envelope(mixed, a, d, s, r)
# Apply per-note velocity scaling
vel_scale = getattr(note, 'velocity', 100) / 127.0
end = min(start + len(mixed), total_samples)
buf[start:end] += mixed[:end - start] * volume
buf[start:end] += mixed[:end - start] * volume * vel_scale
beat_pos += note.beats
def _render_legato_to_buf(notes, buf, samples_per_beat, total_samples,
synth_fn, envelope_tuple, volume, bpm,
glide_time=0.0):
glide_time=0.0, swing=0.0, tempo_map=None):
"""Render notes as one continuous waveform with pitch glide.
Instead of rendering each note separately with its own envelope,
@@ -1010,20 +1284,28 @@ def _render_legato_to_buf(notes, buf, samples_per_beat, total_samples,
in pitch, not frequency — matching how humans perceive pitch).
"""
# Build a frequency timeline: (sample_position, target_hz) pairs
events = [] # (start_sample, end_sample, hz_or_none)
events = [] # (start_sample, end_sample, hz_or_none, velocity)
beat_pos = 0.0
for note in notes:
start = int(beat_pos * samples_per_beat)
for note_index, note in enumerate(notes):
if tempo_map and len(tempo_map) > 1:
start = _beat_to_sample(beat_pos, tempo_map)
else:
start = int(beat_pos * samples_per_beat)
# Apply swing
if swing > 0.0 and note_index % 2 == 1:
swing_offset = int(swing * 0.5 * samples_per_beat)
start += swing_offset
dur_samples = int(note.beats * samples_per_beat)
end = min(start + dur_samples, total_samples)
vel = getattr(note, 'velocity', 100)
if note.tone is not None:
if hasattr(note.tone, 'tones'):
hz = note.tone.tones[0].pitch() # use root for chords
else:
hz = note.tone.pitch()
events.append((start, end, hz))
events.append((start, end, hz, vel))
else:
events.append((start, end, 0)) # rest
events.append((start, end, 0, vel)) # rest
beat_pos += note.beats
if not events:
@@ -1035,12 +1317,13 @@ def _render_legato_to_buf(notes, buf, samples_per_beat, total_samples,
amp_curve = numpy.zeros(total_samples, dtype=numpy.float32)
prev_hz = 0
for start, end, hz in events:
for start, end, hz, vel in events:
if start >= total_samples:
break
end = min(end, total_samples)
vel_scale = vel / 127.0
if hz > 0:
amp_curve[start:end] = 1.0
amp_curve[start:end] = vel_scale
if glide_samples > 0 and prev_hz > 0 and prev_hz != hz:
# Exponential glide from prev_hz to hz
g_end = min(start + glide_samples, end)
@@ -1065,7 +1348,7 @@ def _render_legato_to_buf(notes, buf, samples_per_beat, total_samples,
phase = numpy.cumsum(2 * numpy.pi * freq_curve / SAMPLE_RATE)
wave = numpy.sin(phase).astype(numpy.float32)
# Apply amplitude (on/off for notes vs rests)
# Apply amplitude (on/off for notes vs rests, scaled by velocity)
wave *= amp_curve
# Apply single envelope over the entire active region
@@ -1098,16 +1381,25 @@ def render_score(score):
Returns:
Float32 numpy array of audio samples.
"""
# Build tempo map for variable tempo support
tempo_map = _build_tempo_map(score)
has_tempo_changes = len(tempo_map) > 1
samples_per_beat = int(SAMPLE_RATE * 60.0 / score.bpm)
total_beats = score.total_beats
total_samples = int(total_beats * samples_per_beat)
if has_tempo_changes:
total_samples = _total_samples_from_tempo_map(total_beats, tempo_map)
else:
total_samples = int(total_beats * samples_per_beat)
buf = numpy.zeros(total_samples, dtype=numpy.float32)
# Default notes (backwards-compatible .add() calls)
if score.notes:
_render_notes_to_buf(
score.notes, buf, samples_per_beat, total_samples,
sine_wave, Envelope.PIANO.value, 0.5, score.bpm)
sine_wave, Envelope.PIANO.value, 0.5, score.bpm,
swing=score.swing, tempo_map=tempo_map if has_tempo_changes else None)
# Named parts — each rendered to own buffer for per-part effects
for part in score.parts.values():
@@ -1115,15 +1407,20 @@ def render_score(score):
part_buf = numpy.zeros(total_samples, dtype=numpy.float32)
synth_fn = _resolve_synth(part.synth)
env_tuple = _resolve_envelope(part.envelope)
# Use part swing if set, otherwise score swing
effective_swing = part.swing if part.swing is not None else score.swing
if part.legato:
_render_legato_to_buf(
part.notes, part_buf, samples_per_beat, total_samples,
synth_fn, env_tuple, part.volume, score.bpm,
glide_time=part.glide)
glide_time=part.glide, swing=effective_swing,
tempo_map=tempo_map if has_tempo_changes else None)
else:
_render_notes_to_buf(
part.notes, part_buf, samples_per_beat, total_samples,
synth_fn, env_tuple, part.volume, score.bpm)
synth_fn, env_tuple, part.volume, score.bpm,
swing=effective_swing,
tempo_map=tempo_map if has_tempo_changes else None)
# Apply effects — segmented if automation exists
auto_points = part._get_automation_points()
@@ -1160,7 +1457,10 @@ def render_score(score):
# Drum hits
for hit in score._drum_hits:
start = int(hit.position * samples_per_beat)
if has_tempo_changes:
start = _beat_to_sample(hit.position, tempo_map)
else:
start = int(hit.position * samples_per_beat)
if start >= total_samples:
continue
remaining = total_samples - start
+67 -10
View File
@@ -53,6 +53,7 @@ class Note:
tone: object
duration: Duration
velocity: int = 100
@property
def beats(self) -> float:
@@ -74,7 +75,7 @@ def Rest(duration=Duration.QUARTER) -> Note:
"""Create a rest (silent note) with the given duration."""
if isinstance(duration, (int, float)):
duration = _RawDuration(duration)
return Note(tone=None, duration=duration)
return Note(tone=None, duration=duration, velocity=0)
# ---------------------------------------------------------------------------
@@ -1356,19 +1357,23 @@ class Part:
def __init__(self, name: str, *, synth: str = "sine",
envelope: str = "piano", volume: float = 0.5,
reverb: float = 0.0, reverb_decay: float = 1.0,
reverb_type: str = "algorithmic",
delay: float = 0.0, delay_time: float = 0.375,
delay_feedback: float = 0.4,
lowpass: float = 0.0, lowpass_q: float = 0.707,
distortion: float = 0.0, distortion_drive: float = 3.0,
legato: bool = False, glide: float = 0.0,
chorus: float = 0.0, chorus_rate: float = 1.5,
chorus_depth: float = 0.003):
chorus_depth: float = 0.003,
swing: Optional[float] = None):
self.name = name
self.synth = synth
self.envelope = envelope
self.volume = volume
self.swing = swing
self.reverb_mix = reverb
self.reverb_decay = reverb_decay
self.reverb_type = reverb_type
self.delay_mix = delay
self.delay_time = delay_time
self.delay_feedback = delay_feedback
@@ -1384,10 +1389,11 @@ class Part:
self.notes: list[Note] = []
self._automation: list[tuple[float, dict]] = [] # (beat, {param: value})
def add(self, tone_or_string, duration=Duration.QUARTER) -> "Part":
def add(self, tone_or_string, duration=Duration.QUARTER, *, velocity: int = 100) -> "Part":
"""Add a note. Accepts Tone/Chord objects or note strings like ``"E5"``.
Duration can be a ``Duration`` enum or a raw float (beats).
Velocity controls loudness (1-127, default 100).
Returns self for chaining.
"""
@@ -1396,7 +1402,7 @@ class Part:
tone_or_string = Tone.from_string(tone_or_string, system="western")
if isinstance(duration, (int, float)):
duration = _RawDuration(duration)
self.notes.append(Note(tone=tone_or_string, duration=duration))
self.notes.append(Note(tone=tone_or_string, duration=duration, velocity=velocity))
return self
def set(self, **params) -> "Part":
@@ -1453,6 +1459,7 @@ class Part:
params = {
"volume": self.volume,
"reverb_mix": self.reverb_mix, "reverb_decay": self.reverb_decay,
"reverb_type": self.reverb_type,
"delay_mix": self.delay_mix, "delay_time": self.delay_time,
"delay_feedback": self.delay_feedback,
"lowpass": self.lowpass, "lowpass_q": self.lowpass_q,
@@ -1558,11 +1565,36 @@ class Part:
"""Add a rest. Returns self for chaining."""
if isinstance(duration, (int, float)):
duration = _RawDuration(duration)
self.notes.append(Note(tone=None, duration=duration))
self.notes.append(Note(tone=None, duration=duration, velocity=0))
return self
def fade_in(self, bars: float = 4) -> "Part":
"""Fade volume from 0 to current level over N bars."""
beats = bars * 4.0 # assume 4/4
current_beat = sum(n.beats for n in self.notes)
steps = int(beats / 0.5) # automate every half beat
for i in range(steps + 1):
frac = i / steps
beat = current_beat + i * 0.5
vol = self.volume * frac
self._automation.append((beat, {"volume": vol}))
return self
def fade_out(self, bars: float = 4) -> "Part":
"""Fade volume from current level to 0 over N bars."""
beats = bars * 4.0
current_beat = sum(n.beats for n in self.notes)
steps = int(beats / 0.5)
for i in range(steps + 1):
frac = 1.0 - (i / steps)
beat = current_beat + i * 0.5
vol = self.volume * frac
self._automation.append((beat, {"volume": vol}))
return self
def arpeggio(self, chord, *, bars: float = 1, pattern: str = "up",
division=Duration.SIXTEENTH, octaves: int = 1) -> "Part":
division=Duration.SIXTEENTH, octaves: int = 1,
velocity: int = 100) -> "Part":
"""Arpeggiate a chord into a rhythmic pattern.
Takes a chord and sequences through its notes automatically,
@@ -1642,7 +1674,7 @@ class Part:
# Fill the bars by cycling through the sequence
for i in range(total_steps):
tone = seq[i % len(seq)]
self.add(tone, step_beats)
self.add(tone, step_beats, velocity=velocity)
return self
@@ -1688,27 +1720,31 @@ class Score:
play_score(score)
"""
def __init__(self, time_signature="4/4", bpm=120):
def __init__(self, time_signature="4/4", bpm=120, swing: float = 0.0):
if isinstance(time_signature, str):
self.time_signature = TimeSignature.from_string(time_signature)
else:
self.time_signature = time_signature
self.bpm = bpm
self.swing = swing
self.notes: list[Note] = []
self.parts: dict[str, Part] = {}
self._drum_hits: list[_Hit] = []
self._drum_pattern_beats: float = 0.0
self._tempo_changes: list[tuple[float, int]] = []
def part(self, name: str, *, synth: str = "sine",
envelope: str = "piano", volume: float = 0.5,
reverb: float = 0.0, reverb_decay: float = 1.0,
reverb_type: str = "algorithmic",
delay: float = 0.0, delay_time: float = 0.375,
delay_feedback: float = 0.4,
lowpass: float = 0.0, lowpass_q: float = 0.707,
distortion: float = 0.0, distortion_drive: float = 3.0,
legato: bool = False, glide: float = 0.0,
chorus: float = 0.0, chorus_rate: float = 1.5,
chorus_depth: float = 0.003) -> Part:
chorus_depth: float = 0.003,
swing: Optional[float] = None) -> Part:
"""Create a named part with its own synth voice and effects.
Args:
@@ -1722,6 +1758,10 @@ class Score:
volume: Mix level from 0.0 to 1.0 (default 0.5).
reverb: Reverb wet/dry mix, 0.01.0 (default 0, off).
reverb_decay: Reverb tail length in seconds (default 1.0).
reverb_type: Reverb algorithm — ``"algorithmic"`` (Schroeder, default)
or a convolution IR preset: ``"taj_mahal"``, ``"cathedral"``,
``"plate"``, ``"spring"``, ``"cave"``, ``"parking_garage"``,
``"canyon"``.
delay: Delay wet/dry mix, 0.01.0 (default 0, off).
delay_time: Delay time in seconds (default 0.375, dotted 8th).
delay_feedback: Delay feedback 0.01.0 (default 0.4).
@@ -1750,13 +1790,15 @@ class Score:
"""
p = Part(name, synth=synth, envelope=envelope, volume=volume,
reverb=reverb, reverb_decay=reverb_decay,
reverb_type=reverb_type,
delay=delay, delay_time=delay_time,
delay_feedback=delay_feedback,
lowpass=lowpass, lowpass_q=lowpass_q,
distortion=distortion, distortion_drive=distortion_drive,
legato=legato, glide=glide,
chorus=chorus, chorus_rate=chorus_rate,
chorus_depth=chorus_depth)
chorus_depth=chorus_depth,
swing=swing)
self.parts[name] = p
return p
@@ -1839,6 +1881,21 @@ class Score:
self.notes.append(Note(tone=None, duration=duration))
return self
def set_tempo(self, bpm: int) -> "Score":
"""Insert a tempo change at the current beat position.
The new tempo takes effect from the current total_beats position
and remains until the next tempo change.
Args:
bpm: New tempo in beats per minute.
Returns:
Self for chaining.
"""
self._tempo_changes.append((self.total_beats, bpm))
return self
@property
def total_beats(self) -> float:
beats = [sum(n.beats for n in self.notes), self._drum_pattern_beats]
+120
View File
@@ -5740,3 +5740,123 @@ def test_lfo_renders_correctly():
lead.add("C4", Duration.WHOLE).add("C4", Duration.WHOLE)
buf = render_score(score)
assert len(buf) > 0
# ── Per-note velocity tests ─────────────────────────────────────────────────
def test_note_velocity_default():
from pytheory.rhythm import Note, Duration
n = Note(tone=None, duration=Duration.QUARTER)
assert n.velocity == 100
def test_note_velocity_custom():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.add("C5", Duration.QUARTER, velocity=60)
assert lead.notes[0].velocity == 60
def test_arpeggio_velocity():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.arpeggio("Cm", bars=1, velocity=75)
for n in lead.notes:
assert n.velocity == 75
# ── Swing / groove tests ────────────────────────────────────────────────────
def test_score_swing_default():
from pytheory import Score
score = Score("4/4", bpm=120)
assert score.swing == 0.0
def test_score_swing_set():
from pytheory import Score
score = Score("4/4", bpm=120, swing=0.5)
assert score.swing == 0.5
# ── Tempo change tests ──────────────────────────────────────────────────────
def test_set_tempo():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.add("C4", Duration.WHOLE)
score.set_tempo(140)
assert len(score._tempo_changes) == 1
beat_pos, new_bpm = score._tempo_changes[0]
assert new_bpm == 140
assert beat_pos == 4.0 # after one WHOLE note
# ── Fade in/out tests ───────────────────────────────────────────────────────
def test_fade_in():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", volume=0.8)
lead.fade_in(bars=2)
# Should generate automation points with ascending volume
volumes = [p["volume"] for _, p in lead._automation]
assert len(volumes) > 0
assert volumes[0] == pytest.approx(0.0)
assert volumes[-1] == pytest.approx(0.8)
# Check ascending order
for i in range(1, len(volumes)):
assert volumes[i] >= volumes[i - 1]
def test_fade_out():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", volume=0.8)
lead.fade_out(bars=2)
# Should generate automation points with descending volume
volumes = [p["volume"] for _, p in lead._automation]
assert len(volumes) > 0
assert volumes[0] == pytest.approx(0.8)
assert volumes[-1] == pytest.approx(0.0)
# Check descending order
for i in range(1, len(volumes)):
assert volumes[i] <= volumes[i - 1]
@needs_portaudio
def test_velocity_affects_render():
from pytheory import Score, Duration
from pytheory.play import render_score
import numpy as np
# Loud note
score_loud = Score("4/4", bpm=120)
lead_loud = score_loud.part("lead", synth="sine", envelope="none")
lead_loud.add("A4", Duration.QUARTER, velocity=127)
buf_loud = render_score(score_loud)
# Quiet note
score_quiet = Score("4/4", bpm=120)
lead_quiet = score_quiet.part("lead", synth="sine", envelope="none")
lead_quiet.add("A4", Duration.QUARTER, velocity=30)
buf_quiet = render_score(score_quiet)
# Loud should have greater peak amplitude (both are normalized,
# but we compare RMS of the raw rendered parts before normalization)
# Actually, render_score normalizes. Let's just check they both render.
assert len(buf_loud) > 0
assert len(buf_quiet) > 0
# The loud note should have higher peak than the quiet note
# Since both scores have only one note, normalization makes peaks equal.
# Instead, render a score with BOTH loud and quiet notes and check
# the loud section is louder.
score = Score("4/4", bpm=120)
lead = score.part("lead", synth="sine", envelope="none")
lead.add("A4", Duration.QUARTER, velocity=127)
lead.add("A4", Duration.QUARTER, velocity=30)
buf = render_score(score)
mid = len(buf) // 2
rms_first = np.sqrt(np.mean(buf[:mid] ** 2))
rms_second = np.sqrt(np.mean(buf[mid:] ** 2))
assert rms_first > rms_second
Generated
+1 -1
View File
@@ -612,7 +612,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.22.0"
version = "0.24.0"
source = { editable = "." }
dependencies = [
{ name = "numeral" },