mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
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:
@@ -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
@@ -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,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
@@ -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.0–1.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
@@ -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.0–1.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.0–1.0 (default 0, off).
|
||||
delay_time: Delay time in seconds (default 0.375, dotted 8th).
|
||||
delay_feedback: Delay feedback 0.0–1.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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user