mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 931ec905c3 | |||
| 799ffbdac9 | |||
| b29b33524f | |||
| 25f25c1f23 | |||
| 3f1d632285 | |||
| 1938037458 |
@@ -2,6 +2,21 @@
|
||||
|
||||
All notable changes to PyTheory are documented here.
|
||||
|
||||
## 0.30.0
|
||||
|
||||
- Drums are a real Part — same effects pipeline as any voice
|
||||
- `score.drums("rock", split=True)` splits kit into kick/snare/hats/toms/cymbals/percussion Parts
|
||||
- Each split Part gets independent effects (reverb on snare, LP on hats, etc.)
|
||||
- `set_drum_effects()` applies to all drum Parts (split or not)
|
||||
- Sidechain triggers on kick only — hats and snare don't duck the pad
|
||||
- MIDI import via `Score.from_midi(path)`
|
||||
|
||||
## 0.29.3
|
||||
|
||||
- Drums are now a real Part — same effects pipeline as any other voice, zero code duplication
|
||||
- `score.parts["drums"]` is a standard Part with reverb, delay, lowpass, etc.
|
||||
- `set_drum_effects()` is sugar over the Part's attributes
|
||||
|
||||
## 0.29.2
|
||||
|
||||
- Add `score.set_drum_effects()` — reverb, delay, lowpass, distortion, chorus on the drum bus
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Kenneth Reitz
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -29,6 +29,50 @@ Score:
|
||||
|
||||
The default is 0.15 — just enough to feel alive without sounding loose.
|
||||
|
||||
Drums Are Parts
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
Drums are a real Part — the same as any melodic voice. You can set
|
||||
effects on them the same way:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
score.drums("rock", repeats=4)
|
||||
score.parts["drums"].reverb_mix = 0.2
|
||||
score.parts["drums"].reverb_type = "plate"
|
||||
|
||||
Or use the shorthand:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
score.set_drum_effects(reverb=0.2, reverb_type="plate", lowpass=8000)
|
||||
|
||||
Split Drums
|
||||
~~~~~~~~~~~
|
||||
|
||||
For maximum control, split the kit into separate Parts — kick, snare,
|
||||
hats, toms, cymbals, and percussion — each with independent effects:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
score.drums("rock", repeats=4, split=True)
|
||||
|
||||
# Now each group is its own Part
|
||||
score.parts["snare"].reverb_mix = 0.3
|
||||
score.parts["snare"].reverb_type = "plate"
|
||||
score.parts["hats"].lowpass = 7000
|
||||
score.parts["kick"] # dry, no effects
|
||||
|
||||
# set_drum_effects still works — applies to all drum Parts
|
||||
score.set_drum_effects(reverb=0.1)
|
||||
|
||||
This is how real studios work — the snare gets its own reverb send,
|
||||
the hats get their own EQ, the kick stays dry and punchy. Now you
|
||||
can do the same thing in Python.
|
||||
|
||||
Sidechain compression triggers on kick hits only — hi-hats and snares
|
||||
don't duck the pad.
|
||||
|
||||
Every drum sound is stereo-panned like a real kit — kick and snare
|
||||
center, hi-hat right, crash left, toms spread across the field,
|
||||
percussion instruments placed naturally. Put on headphones and you'll
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pytheory"
|
||||
version = "0.29.0"
|
||||
version = "0.30.0"
|
||||
description = "Music Theory for Humans"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""PyTheory: Music Theory for Humans."""
|
||||
|
||||
__version__ = "0.29.0"
|
||||
__version__ = "0.30.0"
|
||||
|
||||
from .tones import Tone, Interval
|
||||
from .systems import System, SYSTEMS
|
||||
@@ -8,7 +8,7 @@ from .scales import TonedScale, Key, PROGRESSIONS
|
||||
from .chords import Chord, Fretboard, analyze_progression
|
||||
from .charts import CHARTS, Fingering, charts_for_fretboard
|
||||
|
||||
from .rhythm import Duration, TimeSignature, Rest, Score, Part, Section, DrumSound, Pattern
|
||||
from .rhythm import Duration, TimeSignature, Rest, Score, Part, Section, DrumSound, Pattern, INSTRUMENTS
|
||||
from .rhythm import Note as RhythmNote # rhythm.Note (tone + duration pairing)
|
||||
|
||||
from .play import (play, save, save_midi, play_progression, play_pattern,
|
||||
@@ -25,5 +25,5 @@ __all__ = [
|
||||
"play", "save", "save_midi", "play_progression", "play_pattern",
|
||||
"play_score", "Synth", "Envelope",
|
||||
"Duration", "TimeSignature", "RhythmNote", "Rest", "Score", "Part",
|
||||
"DrumSound", "Pattern", "Section",
|
||||
"DrumSound", "Pattern", "Section", "INSTRUMENTS",
|
||||
]
|
||||
|
||||
+141
-42
@@ -190,6 +190,97 @@ def pwm_fast_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
return pwm_wave(hz, peak, n_samples, lfo_rate=3.0)
|
||||
|
||||
|
||||
def pluck_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""Karplus-Strong plucked string synthesis.
|
||||
|
||||
A burst of noise is fed into a short delay line with feedback —
|
||||
the delay length determines the pitch, and the feedback filter
|
||||
determines the decay. This is how every physical modeling synth
|
||||
since 1983 does plucked strings. It sounds genuinely like a real
|
||||
guitar, harp, or koto — not a synth approximation.
|
||||
|
||||
The algorithm: fill a buffer with random noise the length of one
|
||||
period, then repeatedly average adjacent samples. The averaging
|
||||
acts as a lowpass filter, gradually removing high harmonics —
|
||||
exactly what a real vibrating string does as energy dissipates.
|
||||
"""
|
||||
period = int(SAMPLE_RATE / hz)
|
||||
if period < 2:
|
||||
period = 2
|
||||
# Initial noise burst — the "pluck"
|
||||
buf = numpy.random.uniform(-1.0, 1.0, period).astype(numpy.float64)
|
||||
out = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
for i in range(n_samples):
|
||||
out[i] = buf[i % period]
|
||||
# Averaging filter: smooth adjacent samples (Karplus-Strong)
|
||||
buf[i % period] = 0.5 * (buf[i % period] + buf[(i + 1) % period]) * 0.998
|
||||
return (peak * out).astype(numpy.int16)
|
||||
|
||||
|
||||
def organ_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""Hammond organ — additive synthesis with drawbar harmonics.
|
||||
|
||||
A real Hammond B3 has 9 drawbars that mix sine waves at different
|
||||
harmonics. This models the classic "full" registration with all
|
||||
drawbars pulled: fundamental, 2nd, 3rd, 4th, 5th, 6th, and 8th
|
||||
harmonics at musical levels.
|
||||
|
||||
The result is warm, rich, and unmistakably organ — somewhere
|
||||
between a sine wave and a square wave, with that characteristic
|
||||
hollow roundness.
|
||||
"""
|
||||
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||||
# Drawbar levels (inspired by 888800000 — full even harmonics)
|
||||
wave = (numpy.sin(2 * numpy.pi * hz * t) * 1.0 + # 16' fundamental
|
||||
numpy.sin(2 * numpy.pi * hz * 2 * t) * 0.8 + # 8'
|
||||
numpy.sin(2 * numpy.pi * hz * 3 * t) * 0.6 + # 5 1/3'
|
||||
numpy.sin(2 * numpy.pi * hz * 4 * t) * 0.5 + # 4'
|
||||
numpy.sin(2 * numpy.pi * hz * 5 * t) * 0.3 + # 2 2/3'
|
||||
numpy.sin(2 * numpy.pi * hz * 6 * t) * 0.25 + # 2'
|
||||
numpy.sin(2 * numpy.pi * hz * 8 * t) * 0.15) # 1 3/5'
|
||||
wave /= 3.5 # normalize
|
||||
return (peak * wave).astype(numpy.int16)
|
||||
|
||||
|
||||
def strings_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""String ensemble — filtered saw with body resonance formants.
|
||||
|
||||
Goes beyond raw sawtooth by modeling the resonant body of a
|
||||
stringed instrument. Two formant peaks (at ~500 Hz and ~1500 Hz)
|
||||
shape the spectrum the way a violin or cello body does — boosting
|
||||
certain frequencies and cutting others.
|
||||
|
||||
The result is warmer and more "wooden" than a raw saw wave,
|
||||
with the characteristic nasal quality of bowed strings.
|
||||
"""
|
||||
# Base: sawtooth (all harmonics, like a bowed string)
|
||||
length = SAMPLE_RATE / float(hz)
|
||||
omega = numpy.pi * 2 / length
|
||||
xvalues = numpy.arange(int(length)) * omega
|
||||
onecycle = scipy.signal.sawtooth(xvalues, width=1)
|
||||
wave = numpy.resize(onecycle, (n_samples,)).astype(numpy.float64)
|
||||
|
||||
# Body resonance formants — two bandpass peaks
|
||||
# Formant 1: ~500 Hz (body resonance)
|
||||
f1 = 500
|
||||
bw1 = 200
|
||||
b1, a1 = scipy.signal.butter(2, [max(20, f1 - bw1), f1 + bw1],
|
||||
btype='band', fs=SAMPLE_RATE)
|
||||
formant1 = scipy.signal.lfilter(b1, a1, wave)
|
||||
|
||||
# Formant 2: ~1500 Hz (bridge/top plate)
|
||||
f2 = 1500
|
||||
bw2 = 400
|
||||
b2, a2 = scipy.signal.butter(2, [f2 - bw2, f2 + bw2],
|
||||
btype='band', fs=SAMPLE_RATE)
|
||||
formant2 = scipy.signal.lfilter(b2, a2, wave)
|
||||
|
||||
# Mix: original (attenuated) + formants
|
||||
mixed = wave * 0.3 + formant1 * 0.4 + formant2 * 0.3
|
||||
|
||||
return (peak * mixed).astype(numpy.int16)
|
||||
|
||||
|
||||
def _apply_envelope(samples, attack, decay, sustain, release, sample_rate=SAMPLE_RATE):
|
||||
"""Apply an ADSR amplitude envelope to a sample array.
|
||||
|
||||
@@ -291,6 +382,9 @@ class Synth(Enum):
|
||||
SUPERSAW = "supersaw"
|
||||
PWM_SLOW = "pwm_slow"
|
||||
PWM_FAST = "pwm_fast"
|
||||
PLUCK = "pluck_synth"
|
||||
ORGAN = "organ_synth"
|
||||
STRINGS = "strings_synth"
|
||||
|
||||
def __call__(self, hz, **kwargs):
|
||||
"""Make Synth members callable — dispatches to the wave function."""
|
||||
@@ -302,6 +396,8 @@ _SYNTH_FUNCTIONS = {
|
||||
"square": square_wave, "pulse": pulse_wave, "fm": fm_wave,
|
||||
"noise": noise_wave, "supersaw": supersaw_wave,
|
||||
"pwm_slow": pwm_slow_wave, "pwm_fast": pwm_fast_wave,
|
||||
"pluck_synth": pluck_wave, "organ_synth": organ_wave,
|
||||
"strings_synth": strings_wave,
|
||||
}
|
||||
|
||||
|
||||
@@ -1952,54 +2048,57 @@ def render_score(score):
|
||||
DrumSound.MARACAS.value: 0.3,
|
||||
}
|
||||
|
||||
# Drum hits — render to mono sidechain trigger + stereo output
|
||||
# Render all drum Parts (may be one "drums" or split into kick/snare/hats/etc.)
|
||||
import random as _drum_rnd
|
||||
drum_buf = numpy.zeros(total_samples, dtype=numpy.float32) # mono for sidechain
|
||||
drum_buf = numpy.zeros(total_samples, dtype=numpy.float32) # mono for sidechain (kick only)
|
||||
drum_stereo = numpy.zeros((total_samples, 2), dtype=numpy.float32)
|
||||
drum_swing = score.swing
|
||||
drum_humanize = getattr(score, '_drum_humanize', 0.3) # subtle by default
|
||||
for hit in score._drum_hits:
|
||||
pos = hit.position
|
||||
if drum_swing > 0:
|
||||
beat_frac = pos % 1.0
|
||||
if 0.1 < beat_frac < 0.9:
|
||||
pos += drum_swing * 0.15
|
||||
if has_tempo_changes:
|
||||
start = _beat_to_sample(pos, tempo_map)
|
||||
else:
|
||||
start = int(pos * samples_per_beat)
|
||||
# Humanize: random timing jitter + velocity variation
|
||||
if drum_humanize > 0:
|
||||
max_offset = int(drum_humanize * 0.03 * samples_per_beat)
|
||||
start += _drum_rnd.randint(-max_offset, max_offset)
|
||||
start = max(0, start)
|
||||
if start >= total_samples or start < 0:
|
||||
continue
|
||||
remaining = total_samples - start
|
||||
hit_len = min(int(SAMPLE_RATE * 0.5), remaining)
|
||||
wave = _render_drum_hit(hit.sound.value, hit_len)
|
||||
vel = hit.velocity
|
||||
if drum_humanize > 0:
|
||||
vel_jitter = int(drum_humanize * 10)
|
||||
vel = max(1, min(127, vel + _drum_rnd.randint(-vel_jitter, vel_jitter)))
|
||||
vel_scale = vel / 127.0
|
||||
mono_hit = wave * vel_scale * 0.7
|
||||
# Mono sidechain trigger (always center)
|
||||
drum_buf[start:start + hit_len] += mono_hit
|
||||
# Stereo panned output
|
||||
pan = _DRUM_PAN.get(hit.sound.value, 0.0)
|
||||
panned = _pan_to_stereo(mono_hit, pan)
|
||||
drum_stereo[start:start + hit_len] += panned
|
||||
drum_humanize = getattr(score, '_drum_humanize', 0.15)
|
||||
|
||||
# Apply drum Part effects through the same pipeline as any other Part
|
||||
drums_part = score.parts.get("drums")
|
||||
if drums_part:
|
||||
has_drum_fx = (drums_part.lowpass > 0 or drums_part.delay_mix > 0
|
||||
or drums_part.reverb_mix > 0 or drums_part.distortion_mix > 0
|
||||
or drums_part.chorus_mix > 0)
|
||||
drum_parts = [p for p in score.parts.values() if p.is_drums]
|
||||
for drum_part in drum_parts:
|
||||
part_stereo = numpy.zeros((total_samples, 2), dtype=numpy.float32)
|
||||
for hit in drum_part._drum_hits:
|
||||
pos = hit.position
|
||||
if drum_swing > 0:
|
||||
beat_frac = pos % 1.0
|
||||
if 0.1 < beat_frac < 0.9:
|
||||
pos += drum_swing * 0.15
|
||||
if has_tempo_changes:
|
||||
start = _beat_to_sample(pos, tempo_map)
|
||||
else:
|
||||
start = int(pos * samples_per_beat)
|
||||
if drum_humanize > 0:
|
||||
max_offset = int(drum_humanize * 0.03 * samples_per_beat)
|
||||
start += _drum_rnd.randint(-max_offset, max_offset)
|
||||
start = max(0, start)
|
||||
if start >= total_samples or start < 0:
|
||||
continue
|
||||
remaining = total_samples - start
|
||||
hit_len = min(int(SAMPLE_RATE * 0.5), remaining)
|
||||
wave = _render_drum_hit(hit.sound.value, hit_len)
|
||||
vel = hit.velocity
|
||||
if drum_humanize > 0:
|
||||
vel_jitter = int(drum_humanize * 10)
|
||||
vel = max(1, min(127, vel + _drum_rnd.randint(-vel_jitter, vel_jitter)))
|
||||
vel_scale = vel / 127.0
|
||||
mono_hit = wave * vel_scale * 0.7
|
||||
# Sidechain trigger — kick only
|
||||
if hit.sound.value == DrumSound.KICK.value:
|
||||
drum_buf[start:start + hit_len] += mono_hit
|
||||
# Stereo panned output for this drum Part
|
||||
pan = _DRUM_PAN.get(hit.sound.value, 0.0)
|
||||
panned = _pan_to_stereo(mono_hit, pan)
|
||||
part_stereo[start:start + hit_len] += panned
|
||||
|
||||
# Apply this drum Part's effects
|
||||
has_drum_fx = (drum_part.lowpass > 0 or drum_part.delay_mix > 0
|
||||
or drum_part.reverb_mix > 0 or drum_part.distortion_mix > 0
|
||||
or drum_part.chorus_mix > 0)
|
||||
if has_drum_fx:
|
||||
for ch in range(2):
|
||||
drum_stereo[:, ch] = _apply_part_effects(drum_stereo[:, ch], drums_part)
|
||||
part_stereo[:, ch] = _apply_part_effects(part_stereo[:, ch], drum_part)
|
||||
drum_stereo += part_stereo
|
||||
|
||||
# Apply sidechain compression to parts that request it
|
||||
for part, part_buf in _pending_sidechain:
|
||||
|
||||
+367
-60
@@ -7,6 +7,217 @@ from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── Instrument presets ────────────────────────────────────────────────────────
|
||||
# Predefined combinations of synth, envelope, effects, and parameters that
|
||||
# approximate real instruments. Used by ``Score.part(instrument=...)``.
|
||||
|
||||
INSTRUMENTS = {
|
||||
# ── Keys ──
|
||||
"piano": {
|
||||
"synth": "fm", "envelope": "piano",
|
||||
"detune": 5, "chorus": 0.1, "chorus_rate": 0.3,
|
||||
"lowpass": 6000,
|
||||
},
|
||||
"electric_piano": { # Rhodes/Wurlitzer
|
||||
"synth": "fm", "envelope": "piano",
|
||||
"detune": 6, "chorus": 0.2, "chorus_rate": 1.0,
|
||||
"lowpass": 4000,
|
||||
},
|
||||
"organ": {
|
||||
"synth": "organ_synth", "envelope": "organ",
|
||||
"chorus": 0.2, "chorus_rate": 5.5,
|
||||
"lowpass": 5000,
|
||||
},
|
||||
"harpsichord": {
|
||||
"synth": "pluck_synth", "envelope": "none",
|
||||
"lowpass": 3500,
|
||||
},
|
||||
"celesta": {
|
||||
"synth": "fm", "envelope": "bell",
|
||||
"lowpass": 8000,
|
||||
"reverb": 0.3, "reverb_type": "plate",
|
||||
},
|
||||
"music_box": {
|
||||
"synth": "sine", "envelope": "bell",
|
||||
"lowpass": 6000,
|
||||
"reverb": 0.25, "reverb_type": "plate",
|
||||
},
|
||||
|
||||
# ── Strings ──
|
||||
"violin": {
|
||||
"synth": "strings_synth", "envelope": "strings",
|
||||
"detune": 4, "lowpass": 5000,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"viola": {
|
||||
"synth": "strings_synth", "envelope": "strings",
|
||||
"detune": 4, "lowpass": 3500,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"cello": {
|
||||
"synth": "strings_synth", "envelope": "strings",
|
||||
"detune": 4, "lowpass": 2500,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"contrabass": {
|
||||
"synth": "strings_synth", "envelope": "strings",
|
||||
"detune": 3, "lowpass": 1500,
|
||||
"humanize": 0.1,
|
||||
},
|
||||
"string_ensemble": {
|
||||
"synth": "strings_synth", "envelope": "pad",
|
||||
"detune": 12, "spread": 0.6,
|
||||
"chorus": 0.2, "chorus_rate": 0.5,
|
||||
"lowpass": 4000,
|
||||
},
|
||||
|
||||
# ── Woodwinds ──
|
||||
"flute": {
|
||||
"synth": "sine", "envelope": "strings",
|
||||
"lowpass": 4000,
|
||||
"humanize": 0.2,
|
||||
},
|
||||
"clarinet": {
|
||||
"synth": "square", "envelope": "strings",
|
||||
"lowpass": 3000,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"oboe": {
|
||||
"synth": "saw", "envelope": "strings",
|
||||
"lowpass": 3500, "lowpass_q": 1.2,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"bassoon": {
|
||||
"synth": "saw", "envelope": "strings",
|
||||
"lowpass": 2000,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
|
||||
# ── Brass ──
|
||||
"trumpet": {
|
||||
"synth": "saw", "envelope": "pluck",
|
||||
"detune": 3, "lowpass": 4000, "lowpass_q": 1.1,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"trombone": {
|
||||
"synth": "saw", "envelope": "strings",
|
||||
"detune": 3, "lowpass": 2500,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"french_horn": {
|
||||
"synth": "saw", "envelope": "strings",
|
||||
"detune": 4, "lowpass": 2000,
|
||||
"chorus": 0.1,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"tuba": {
|
||||
"synth": "saw", "envelope": "strings",
|
||||
"detune": 3, "lowpass": 1200,
|
||||
"humanize": 0.1,
|
||||
},
|
||||
"brass_ensemble": {
|
||||
"synth": "saw", "envelope": "strings",
|
||||
"detune": 10, "spread": 0.4,
|
||||
"lowpass": 3000,
|
||||
"chorus": 0.15,
|
||||
},
|
||||
|
||||
# ── Plucked ──
|
||||
"acoustic_guitar": {
|
||||
"synth": "pluck_synth", "envelope": "none",
|
||||
"lowpass": 4000,
|
||||
"humanize": 0.2,
|
||||
},
|
||||
"electric_guitar": {
|
||||
"synth": "saw", "envelope": "pluck",
|
||||
"detune": 5, "lowpass": 3500,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"distorted_guitar": {
|
||||
"synth": "saw", "envelope": "pluck",
|
||||
"detune": 8, "distortion": 0.6, "distortion_drive": 5.0,
|
||||
"lowpass": 3000,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"bass_guitar": {
|
||||
"synth": "triangle", "envelope": "pluck",
|
||||
"lowpass": 1000,
|
||||
"humanize": 0.1,
|
||||
},
|
||||
"upright_bass": {
|
||||
"synth": "sine", "envelope": "pluck",
|
||||
"lowpass": 800,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"harp": {
|
||||
"synth": "pluck_synth", "envelope": "none",
|
||||
"lowpass": 5000,
|
||||
"reverb": 0.3, "reverb_type": "plate",
|
||||
},
|
||||
"sitar": {
|
||||
"synth": "saw", "envelope": "pluck",
|
||||
"detune": 12, "lowpass": 3000, "lowpass_q": 1.5,
|
||||
"humanize": 0.2,
|
||||
},
|
||||
"koto": {
|
||||
"synth": "pluck_synth", "envelope": "none",
|
||||
"lowpass": 4000,
|
||||
"reverb": 0.2,
|
||||
},
|
||||
|
||||
# ── Synth presets ──
|
||||
"synth_lead": {
|
||||
"synth": "saw", "envelope": "pluck",
|
||||
"detune": 8, "lowpass": 3000,
|
||||
"delay": 0.2, "delay_time": 0.25, "delay_feedback": 0.3,
|
||||
},
|
||||
"synth_pad": {
|
||||
"synth": "supersaw", "envelope": "pad",
|
||||
"detune": 12, "spread": 0.6,
|
||||
"chorus": 0.2,
|
||||
},
|
||||
"synth_bass": {
|
||||
"synth": "saw", "envelope": "pluck",
|
||||
"lowpass": 800, "lowpass_q": 1.3,
|
||||
},
|
||||
"acid_bass": {
|
||||
"synth": "saw", "envelope": "pad",
|
||||
"legato": True, "glide": 0.03,
|
||||
"distortion": 0.7, "distortion_drive": 8.0,
|
||||
"lowpass": 800, "lowpass_q": 5.0,
|
||||
},
|
||||
"808_bass": {
|
||||
"synth": "sine", "envelope": "pluck",
|
||||
"distortion": 0.4, "distortion_drive": 2.5,
|
||||
"lowpass": 200, "lowpass_q": 1.5,
|
||||
},
|
||||
|
||||
# ── Percussion / Mallet ──
|
||||
"vibraphone": {
|
||||
"synth": "fm", "envelope": "bell",
|
||||
"lowpass": 5000,
|
||||
"reverb": 0.3, "reverb_type": "plate",
|
||||
},
|
||||
"marimba": {
|
||||
"synth": "sine", "envelope": "pluck",
|
||||
"lowpass": 3000,
|
||||
},
|
||||
"xylophone": {
|
||||
"synth": "fm", "envelope": "pluck",
|
||||
"lowpass": 6000,
|
||||
},
|
||||
"glockenspiel": {
|
||||
"synth": "fm", "envelope": "bell",
|
||||
"lowpass": 8000,
|
||||
"reverb": 0.2,
|
||||
},
|
||||
"tubular_bells": {
|
||||
"synth": "fm", "envelope": "bell",
|
||||
"reverb": 0.4, "reverb_type": "cathedral",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Duration(Enum):
|
||||
"""Note durations in beats (quarter note = 1 beat)."""
|
||||
|
||||
@@ -1837,45 +2048,54 @@ class Score:
|
||||
}
|
||||
|
||||
def set_drum_effects(self, **kwargs) -> "Score":
|
||||
"""Set effects on the drum bus.
|
||||
"""Set effects on all drum parts.
|
||||
|
||||
The drums Part is a real Part — set effects the same way
|
||||
you would on any other part.
|
||||
When drums are split, applies to every drum Part (kick, snare,
|
||||
hats, etc.). When not split, applies to the single drums Part.
|
||||
|
||||
Example::
|
||||
|
||||
score.set_drum_effects(reverb=0.2, reverb_type="plate")
|
||||
"""
|
||||
p = self._ensure_drums_part()
|
||||
param_map = {"reverb": "reverb_mix", "delay": "delay_mix",
|
||||
"distortion": "distortion_mix", "chorus": "chorus_mix"}
|
||||
for k, v in kwargs.items():
|
||||
attr = param_map.get(k, k)
|
||||
setattr(p, attr, v)
|
||||
drum_parts = [p for p in self.parts.values() if p.is_drums]
|
||||
if not drum_parts:
|
||||
drum_parts = [self._ensure_drums_part()]
|
||||
for p in drum_parts:
|
||||
for k, v in kwargs.items():
|
||||
attr = param_map.get(k, k)
|
||||
setattr(p, attr, v)
|
||||
return self
|
||||
|
||||
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,
|
||||
def part(self, name: str, *, instrument: str = None,
|
||||
synth: str = None, envelope: str = None,
|
||||
volume: float = None,
|
||||
reverb: float = None, reverb_decay: float = None,
|
||||
reverb_type: str = None,
|
||||
delay: float = None, delay_time: float = None,
|
||||
delay_feedback: float = None,
|
||||
lowpass: float = None, lowpass_q: float = None,
|
||||
distortion: float = None, distortion_drive: float = None,
|
||||
legato: bool = None, glide: float = None,
|
||||
chorus: float = None, chorus_rate: float = None,
|
||||
chorus_depth: float = None,
|
||||
swing: Optional[float] = None,
|
||||
humanize: float = 0.0,
|
||||
sidechain: float = 0.0,
|
||||
sidechain_release: float = 0.1,
|
||||
detune: float = 0.0,
|
||||
pan: float = 0.0,
|
||||
spread: float = 0.0) -> Part:
|
||||
humanize: float = None,
|
||||
sidechain: float = None,
|
||||
sidechain_release: float = None,
|
||||
detune: float = None,
|
||||
pan: float = None,
|
||||
spread: float = None) -> Part:
|
||||
"""Create a named part with its own synth voice and effects.
|
||||
|
||||
Args:
|
||||
name: Part name (e.g. ``"lead"``, ``"bass"``, ``"pads"``).
|
||||
instrument: Instrument preset name (e.g. ``"piano"``,
|
||||
``"violin"``, ``"808_bass"``). See :data:`INSTRUMENTS`
|
||||
for the full list. When set, the preset's synth, envelope,
|
||||
and effects are used as defaults; any explicit keyword
|
||||
argument still overrides the preset value.
|
||||
synth: Waveform — ``"sine"``, ``"saw"``, ``"triangle"``,
|
||||
``"square"``, ``"pulse"``, ``"fm"``, ``"noise"``,
|
||||
``"supersaw"``, ``"pwm_slow"``, ``"pwm_fast"``.
|
||||
@@ -1923,23 +2143,72 @@ class Score:
|
||||
|
||||
lead = score.part("lead", synth="saw", envelope="pluck",
|
||||
reverb=0.3, delay=0.25, lowpass=3000)
|
||||
|
||||
# Or use an instrument preset:
|
||||
piano = score.part("keys", instrument="piano")
|
||||
"""
|
||||
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,
|
||||
swing=swing, humanize=humanize,
|
||||
sidechain=sidechain, sidechain_release=sidechain_release,
|
||||
detune=detune, pan=pan, spread=spread)
|
||||
# Default values for all Part parameters.
|
||||
_defaults = {
|
||||
"synth": "sine", "envelope": "piano", "volume": 0.5,
|
||||
"reverb": 0.0, "reverb_decay": 1.0, "reverb_type": "algorithmic",
|
||||
"delay": 0.0, "delay_time": 0.375, "delay_feedback": 0.4,
|
||||
"lowpass": 0.0, "lowpass_q": 0.707,
|
||||
"distortion": 0.0, "distortion_drive": 3.0,
|
||||
"legato": False, "glide": 0.0,
|
||||
"chorus": 0.0, "chorus_rate": 1.5, "chorus_depth": 0.003,
|
||||
"swing": None, "humanize": 0.0,
|
||||
"sidechain": 0.0, "sidechain_release": 0.1,
|
||||
"detune": 0.0, "pan": 0.0, "spread": 0.0,
|
||||
}
|
||||
|
||||
# If an instrument preset is specified, layer it on top of defaults.
|
||||
if instrument is not None:
|
||||
preset = INSTRUMENTS.get(instrument)
|
||||
if preset is None:
|
||||
raise ValueError(
|
||||
f"Unknown instrument: {instrument!r}. "
|
||||
f"Use Score.list_instruments() to see available presets."
|
||||
)
|
||||
_defaults.update(preset)
|
||||
|
||||
# Collect explicitly-provided kwargs (non-None) and override defaults.
|
||||
explicit = {}
|
||||
_locals = {
|
||||
"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,
|
||||
"swing": swing, "humanize": humanize,
|
||||
"sidechain": sidechain, "sidechain_release": sidechain_release,
|
||||
"detune": detune, "pan": pan, "spread": spread,
|
||||
}
|
||||
for k, v in _locals.items():
|
||||
if v is not None:
|
||||
explicit[k] = v
|
||||
|
||||
merged = {**_defaults, **explicit}
|
||||
|
||||
p = Part(name, **merged)
|
||||
self.parts[name] = p
|
||||
return p
|
||||
|
||||
@classmethod
|
||||
def list_instruments(cls) -> list:
|
||||
"""Return a sorted list of available instrument preset names.
|
||||
|
||||
Example::
|
||||
|
||||
Score.list_instruments()
|
||||
# ['808_bass', 'acid_bass', 'acoustic_guitar', ...]
|
||||
"""
|
||||
return sorted(INSTRUMENTS.keys())
|
||||
|
||||
def add_pattern(self, pattern, repeats: int = 1) -> "Score":
|
||||
"""Add a drum pattern to this score.
|
||||
|
||||
@@ -1967,46 +2236,84 @@ class Score:
|
||||
return self.add_pattern(fill_pattern, repeats=1)
|
||||
|
||||
|
||||
def drums(self, preset: str, repeats: int = 4, fill: str = None,
|
||||
fill_every: int = None) -> "Score":
|
||||
"""Add a drum pattern by preset name, with optional auto-fills.
|
||||
# Drum sound groups for split mode
|
||||
_DRUM_GROUPS = {
|
||||
"kick": {DrumSound.KICK.value},
|
||||
"snare": {DrumSound.SNARE.value, DrumSound.RIMSHOT.value, DrumSound.CLAP.value},
|
||||
"hats": {DrumSound.CLOSED_HAT.value, DrumSound.OPEN_HAT.value, DrumSound.PEDAL_HAT.value},
|
||||
"toms": {DrumSound.LOW_TOM.value, DrumSound.MID_TOM.value, DrumSound.HIGH_TOM.value},
|
||||
"cymbals": {DrumSound.CRASH.value, DrumSound.RIDE.value, DrumSound.RIDE_BELL.value},
|
||||
"percussion": {DrumSound.COWBELL.value, DrumSound.CLAVE.value, DrumSound.SHAKER.value,
|
||||
DrumSound.TAMBOURINE.value, DrumSound.CONGA_HIGH.value, DrumSound.CONGA_LOW.value,
|
||||
DrumSound.BONGO_HIGH.value, DrumSound.BONGO_LOW.value, DrumSound.TIMBALE_HIGH.value,
|
||||
DrumSound.TIMBALE_LOW.value, DrumSound.AGOGO_HIGH.value, DrumSound.AGOGO_LOW.value,
|
||||
DrumSound.GUIRO.value, DrumSound.MARACAS.value},
|
||||
}
|
||||
|
||||
Shorthand for ``score.add_pattern(Pattern.preset(name), repeats=n)``.
|
||||
def drums(self, preset: str, repeats: int = 4, fill: str = None,
|
||||
fill_every: int = None, split: bool = False) -> "Score":
|
||||
"""Add a drum pattern by preset name, with optional auto-fills.
|
||||
|
||||
Args:
|
||||
preset: Pattern preset name (e.g. ``"bossa nova"``, ``"rock"``).
|
||||
repeats: Number of times to repeat (default 4).
|
||||
fill: Optional fill name. When provided, groove bars are
|
||||
periodically replaced with the named fill pattern.
|
||||
fill_every: Replace every Nth bar with a fill. If *fill* is
|
||||
provided but *fill_every* is not, defaults to filling only
|
||||
the last bar.
|
||||
fill: Optional fill name.
|
||||
fill_every: Replace every Nth bar with a fill.
|
||||
split: If True, create separate Parts for kick, snare, hats,
|
||||
toms, cymbals, and percussion — each with independent
|
||||
effects. Access via ``score.parts["kick"]``, etc.
|
||||
|
||||
Returns:
|
||||
Self for chaining.
|
||||
|
||||
Example::
|
||||
|
||||
>>> score = Score("4/4", bpm=140)
|
||||
>>> score.drums("bossa nova", repeats=4)
|
||||
>>> score.drums("rock", repeats=4, split=True)
|
||||
>>> score.parts["snare"].reverb_mix = 0.3
|
||||
>>> score.parts["hats"].lowpass = 6000
|
||||
"""
|
||||
if fill is None:
|
||||
return self.add_pattern(Pattern.preset(preset), repeats=repeats)
|
||||
self.add_pattern(Pattern.preset(preset), repeats=repeats)
|
||||
else:
|
||||
groove = Pattern.preset(preset)
|
||||
fill_pattern = Pattern.fill(fill)
|
||||
if fill_every is None:
|
||||
fill_every = repeats
|
||||
for bar in range(1, repeats + 1):
|
||||
if bar % fill_every == 0:
|
||||
self.add_pattern(fill_pattern, repeats=1)
|
||||
else:
|
||||
self.add_pattern(groove, repeats=1)
|
||||
|
||||
groove = Pattern.preset(preset)
|
||||
fill_pattern = Pattern.fill(fill)
|
||||
if split:
|
||||
self._split_drums()
|
||||
|
||||
if fill_every is None:
|
||||
# Fill only the last bar
|
||||
fill_every = repeats
|
||||
|
||||
for bar in range(1, repeats + 1):
|
||||
if bar % fill_every == 0:
|
||||
self.add_pattern(fill_pattern, repeats=1)
|
||||
else:
|
||||
self.add_pattern(groove, repeats=1)
|
||||
return self
|
||||
|
||||
def _split_drums(self):
|
||||
"""Move drum hits from the 'drums' Part into separate group Parts."""
|
||||
drums_part = self.parts.get("drums")
|
||||
if not drums_part:
|
||||
return
|
||||
|
||||
all_hits = list(drums_part._drum_hits)
|
||||
pattern_beats = drums_part._drum_pattern_beats
|
||||
drums_part._drum_hits.clear()
|
||||
drums_part._drum_pattern_beats = 0.0
|
||||
|
||||
for group_name, sound_values in self._DRUM_GROUPS.items():
|
||||
group_hits = [h for h in all_hits if h.sound.value in sound_values]
|
||||
if group_hits:
|
||||
if group_name not in self.parts:
|
||||
self.parts[group_name] = Part(group_name, synth="sine", volume=0.7)
|
||||
p = self.parts[group_name]
|
||||
p._drum_hits.extend(group_hits)
|
||||
p._drum_pattern_beats = max(p._drum_pattern_beats, pattern_beats)
|
||||
|
||||
# Remove empty drums Part
|
||||
if not drums_part._drum_hits and "drums" in self.parts:
|
||||
del self.parts["drums"]
|
||||
|
||||
def add(self, tone_or_chord, duration=Duration.QUARTER) -> "Score":
|
||||
"""Add a note to the default (unnamed) part.
|
||||
|
||||
|
||||
+75
-1
@@ -5312,7 +5312,7 @@ def test_supersaw_wave():
|
||||
@needs_portaudio
|
||||
def test_all_synths_in_enum():
|
||||
from pytheory.play import Synth
|
||||
assert len(Synth) == 10
|
||||
assert len(Synth) == 13
|
||||
for s in Synth:
|
||||
wave = s(440, n_samples=1000)
|
||||
assert len(wave) == 1000
|
||||
@@ -6451,3 +6451,77 @@ def test_from_midi_note_durations(tmp_path):
|
||||
assert len(sounding) == 2
|
||||
assert abs(sounding[0].beats - 4.0) < 0.01
|
||||
assert abs(sounding[1].beats - 2.0) < 0.01
|
||||
|
||||
|
||||
# ── Instrument presets ────────────────────────────────────────────────────────
|
||||
|
||||
def test_instrument_piano():
|
||||
from pytheory import Score, Duration
|
||||
score = Score("4/4", bpm=120)
|
||||
p = score.part("p", instrument="piano")
|
||||
assert p.synth == "fm"
|
||||
assert p.envelope == "piano"
|
||||
assert p.detune == 5
|
||||
assert p.lowpass == 6000
|
||||
assert p.chorus_mix == 0.1
|
||||
|
||||
|
||||
def test_instrument_violin():
|
||||
from pytheory import Score
|
||||
score = Score("4/4", bpm=120)
|
||||
p = score.part("v", instrument="violin")
|
||||
assert p.synth == "strings_synth"
|
||||
assert p.envelope == "strings"
|
||||
assert p.humanize == 0.15
|
||||
assert p.lowpass == 5000
|
||||
|
||||
|
||||
def test_instrument_override():
|
||||
from pytheory import Score
|
||||
score = Score("4/4", bpm=120)
|
||||
# Explicit synth overrides the preset's "fm"
|
||||
p = score.part("p", instrument="piano", synth="saw")
|
||||
assert p.synth == "saw"
|
||||
# Other preset values still apply
|
||||
assert p.envelope == "piano"
|
||||
assert p.detune == 5
|
||||
|
||||
|
||||
def test_instrument_unknown_raises():
|
||||
from pytheory import Score
|
||||
score = Score("4/4", bpm=120)
|
||||
with pytest.raises(ValueError, match="Unknown instrument"):
|
||||
score.part("x", instrument="kazoo")
|
||||
|
||||
|
||||
def test_list_instruments():
|
||||
from pytheory import Score, INSTRUMENTS
|
||||
result = Score.list_instruments()
|
||||
assert isinstance(result, list)
|
||||
assert result == sorted(result)
|
||||
assert "piano" in result
|
||||
assert "violin" in result
|
||||
assert "808_bass" in result
|
||||
assert len(result) == len(INSTRUMENTS)
|
||||
|
||||
|
||||
def test_instrument_effects():
|
||||
from pytheory import Score
|
||||
score = Score("4/4", bpm=120)
|
||||
p = score.part("c", instrument="celesta")
|
||||
assert p.reverb_mix == 0.3
|
||||
assert p.reverb_type == "plate"
|
||||
assert p.synth == "fm"
|
||||
assert p.envelope == "bell"
|
||||
|
||||
|
||||
def test_instrument_808_bass():
|
||||
from pytheory import Score
|
||||
score = Score("4/4", bpm=120)
|
||||
p = score.part("b", instrument="808_bass")
|
||||
assert p.distortion_mix == 0.4
|
||||
assert p.distortion_drive == 2.5
|
||||
assert p.lowpass == 200
|
||||
assert p.lowpass_q == 1.5
|
||||
assert p.synth == "sine"
|
||||
assert p.envelope == "pluck"
|
||||
|
||||
Reference in New Issue
Block a user