v0.18.1: Distortion effect, 3 new songs (dub delay, DnB, Drake)

- Soft-clip distortion (tanh waveshaping) with drive and mix controls
- Dub Delay Madness: separate snare track with massive delay/reverb
- Liquid DnB: 174bpm rollers with flowing lead
- Late Night Texts: Drake-style trap with 808 bass + distortion
- 16 total example songs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 13:05:56 -04:00
parent cf061e3783
commit a11523e889
4 changed files with 212 additions and 4 deletions
+6
View File
@@ -2,6 +2,12 @@
All notable changes to PyTheory are documented here.
## 0.18.1
- Add distortion effect (tanh soft-clip waveshaping) with drive and mix controls
- 3 new example songs: Dub Delay Madness (separate delay snare), Liquid DnB (174bpm), Late Night Texts (Drake-style trap)
- 16 total songs in the song player
## 0.18.0
- Add per-part audio effects: reverb, delay, and lowpass filter
+162 -1
View File
@@ -596,6 +596,164 @@ def gospel_shuffle():
play_song(score)
def dub_delay_madness():
"""Dub with separate delay snare track — King Tubby style."""
print(" Dub Delay Madness in E minor")
print(" dub drums + separate snare w/ massive delay + reverb")
print(" square skank + reverb | sine sub bass | PWM siren")
score = Score("4/4", bpm=68)
score.drums("dub", repeats=8)
# Separate snare hits — fed through massive delay and reverb
# This is how Tubby did it: mute the snare from the main mix,
# then send it to a separate channel drowning in effects
from pytheory.rhythm import _Hit, DrumSound
for bar in range(8):
offset = bar * 4.0
# Snare on beat 3 of every bar
score._drum_hits.append(_Hit(DrumSound.SNARE, offset + 2.0, 110))
# Occasional rimshot ghost that the delay catches
if bar % 2 == 1:
score._drum_hits.append(_Hit(DrumSound.RIMSHOT, offset + 3.5, 60))
chords = score.part("skank", synth="square", envelope="staccato",
volume=0.15, reverb=0.7, reverb_decay=3.0,
lowpass=1200)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.6, lowpass=350, lowpass_q=1.5)
siren = score.part("siren", synth="pwm_slow", envelope="pad",
volume=0.12, reverb=0.8, reverb_decay=4.0,
delay=0.4, delay_time=0.88, delay_feedback=0.6,
lowpass=900)
# Melodica stabs — sparse, lots of delay
melodica = score.part("melodica", synth="triangle", envelope="pluck",
volume=0.35, delay=0.6, delay_time=0.66,
delay_feedback=0.55, reverb=0.5, reverb_decay=2.5)
for sym in ["Em", "Em", "Am", "Am", "Em", "Em", "Bm", "Em"]:
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
for n in ["E1","E1","E1","E1","A1","A1","A1","A1",
"E1","E1","E1","E1","B1","B1","E1","E1"]:
bass.add(n, Duration.HALF)
# Melodica: very sparse — let the delay do the work
for n, d in [
("E5", 1.5), (None, 6.5),
("G5", 1), ("A5", 1), (None, 6),
(None, 4), ("B5", 2), (None, 6),
("E5", 1), (None, 3), ("D5", 1.5), (None, 2.5),
]:
melodica.rest(d) if n is None else melodica.add(n, d)
# Siren: long notes that disappear into the void
for n, d in [
(None, 12), ("B5", 6), (None, 6),
("E5", 4), (None, 4),
]:
siren.rest(d) if n is None else siren.add(n, d)
play_song(score)
def drum_and_bass():
"""Drum and bass in A minor — 174 bpm liquid rollers."""
print(" Liquid DnB in A minor")
print(" drum and bass drums + fill | supersaw pads + reverb")
print(" triangle lead + delay | sine sub bass + LP 300Hz")
score = Score("4/4", bpm=174)
score.drums("drum and bass", repeats=8, fill="buildup", fill_every=8)
pads = score.part("pads", synth="supersaw", envelope="pad",
volume=0.25, reverb=0.5, reverb_decay=2.5,
lowpass=4000)
lead = score.part("lead", synth="triangle", envelope="strings",
volume=0.4, delay=0.3, delay_time=0.172,
delay_feedback=0.4, reverb=0.25)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.55, lowpass=300)
for sym in ["Am", "F", "C", "G"] * 2:
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
# Liquid melody — flowing, emotional
for n, d in [
("A5", 1), ("G5", .5), ("E5", .5), ("C5", 1), (None, 1),
("D5", .5), ("E5", .5), ("G5", 1), ("A5", 1.5), (None, .5),
("C6", 1), ("B5", .5), ("A5", .5), ("G5", 1), ("E5", 1),
("F5", .5), ("G5", .5), ("A5", 1.5), (None, .5), ("G5", 1),
("A5", 1), ("G5", .5), ("E5", .5), ("C5", 1), (None, 1),
("E5", .5), ("G5", .5), ("B5", 1), ("A5", 2),
("G5", 1), ("E5", .5), ("D5", .5), ("C5", 1.5), (None, .5),
("E5", .5), ("G5", .5), ("A5", 2), (None, 1),
]:
lead.rest(d) if n is None else lead.add(n, d)
# Sub bass — half note roots, deep
for n in ["A1","A1","F1","F1","C1","C1","G1","G1"] * 2:
bass.add(n, Duration.HALF)
play_song(score)
def drake_vibes():
"""Drake-style moody hip hop — 808s, pads, and melancholy."""
print(" Late Night Texts (Drake-style)")
print(" trap drums | FM bells + reverb + delay | supersaw pads + LP")
print(" sine 808 bass + distortion | PWM slow lead")
score = Score("4/4", bpm=68)
score.drums("trap", repeats=8, fill="trap", fill_every=8)
pads = score.part("pads", synth="supersaw", envelope="pad",
volume=0.2, reverb=0.5, reverb_decay=3.0,
lowpass=2500)
bells = score.part("bells", synth="fm", envelope="bell",
volume=0.3, reverb=0.4, reverb_decay=2.0,
delay=0.25, delay_time=0.44,
delay_feedback=0.35)
lead = score.part("lead", synth="pwm_slow", envelope="strings",
volume=0.35, reverb=0.3, lowpass=2000,
delay=0.2, delay_time=0.88, delay_feedback=0.3)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.6, lowpass=200, lowpass_q=1.8,
distortion=0.4, distortion_drive=2.0)
for sym in ["Ebm", "B", "Gb", "Db"] * 2:
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
# FM bells — sparse, melancholy arpeggios
for n, d in [
("Eb5", .5), ("Gb5", .5), ("Bb5", 1), (None, 2),
("Db5", .5), ("F5", .5), ("Ab5", 1), (None, 2),
("Gb5", .5), ("Bb5", .5), ("Db6", 1), (None, 2),
("F5", .5), ("Ab5", .5), ("Db6", 1.5), (None, 1.5),
("Eb5", .5), ("Gb5", .5), ("Bb5", 1), (None, 2),
("B4", .5), ("Eb5", .5), ("Gb5", 1), (None, 2),
("Gb5", 1), ("F5", .5), ("Eb5", .5), ("Db5", 2),
(None, 4),
]:
bells.rest(d) if n is None else bells.add(n, d)
# PWM lead — long held notes, moody
for n, d in [
(None, 4), ("Bb5", 3), (None, 1),
(None, 2), ("Ab5", 2), ("Gb5", 2), (None, 2),
("Db6", 4), (None, 4),
("Bb5", 2), ("Ab5", 2), (None, 4),
]:
lead.rest(d) if n is None else lead.add(n, d)
# 808 bass — sustained with distortion warmth
for n in ["Eb1","Eb1","Eb1","Eb1","B0","B0","B0","B0",
"Gb1","Gb1","Gb1","Gb1","Db1","Db1","Db1","Db1"] * 2:
bass.add(n, Duration.QUARTER)
play_song(score)
# ── Main ───────────────────────────────────────────────────────────────────
SONGS = {
@@ -612,6 +770,9 @@ SONGS = {
"11": ("Kingston After Dark (Dub)", dub_kingston),
"12": ("Minimal Techno in F minor", techno_minimal),
"13": ("Gospel Shuffle in C major", gospel_shuffle),
"14": ("Dub Delay Madness in E minor", dub_delay_madness),
"15": ("Liquid DnB in A minor", drum_and_bass),
"16": ("Late Night Texts (Drake-style)", drake_vibes),
}
if __name__ == "__main__":
@@ -625,7 +786,7 @@ if __name__ == "__main__":
print(f" {key:>2}. {name}")
print()
choice = input(" Pick a song (1-13, or 'all'): ").strip()
choice = input(" Pick a song (1-16, or 'all'): ").strip()
print()
if choice == "all":
+33
View File
@@ -838,8 +838,41 @@ def _apply_lowpass(samples, cutoff, q=0.707, sample_rate=SAMPLE_RATE):
return scipy.signal.lfilter(b, a, samples).astype(numpy.float32)
def _apply_distortion(samples, drive=1.0, mix=1.0):
"""Apply soft-clip distortion (tanh waveshaping).
Models the warm saturation of an overdriven tube amplifier.
Low drive values add subtle harmonic warmth; high values
produce aggressive fuzz.
The tanh function is the classic soft clipper — it smoothly
compresses peaks rather than hard-clipping them, which is
why tube amps sound "warm" when overdriven while digital
clipping sounds harsh.
Args:
samples: Float32 numpy array.
drive: Gain before clipping, 0.520.0.
0.52 = subtle warmth (tube preamp)
38 = overdrive (cranked amp)
10+ = fuzz/distortion
mix: Wet/dry ratio 0.01.0.
Returns:
Float32 array with distortion applied.
"""
if mix <= 0 or drive <= 0:
return samples
driven = numpy.tanh(samples * drive)
return samples * (1 - mix) + driven * mix
def _apply_part_effects(samples, part):
"""Apply all effects configured on a Part to a float32 buffer."""
# Distortion first (before filter, like a real signal chain)
if part.distortion_mix > 0:
samples = _apply_distortion(samples, drive=part.distortion_drive,
mix=part.distortion_mix)
if part.lowpass > 0:
samples = _apply_lowpass(samples, part.lowpass, part.lowpass_q)
if part.delay_mix > 0:
+11 -3
View File
@@ -1358,7 +1358,8 @@ class Part:
reverb: float = 0.0, reverb_decay: float = 1.0,
delay: float = 0.0, delay_time: float = 0.375,
delay_feedback: float = 0.4,
lowpass: float = 0.0, lowpass_q: float = 0.707):
lowpass: float = 0.0, lowpass_q: float = 0.707,
distortion: float = 0.0, distortion_drive: float = 3.0):
self.name = name
self.synth = synth
self.envelope = envelope
@@ -1370,6 +1371,8 @@ class Part:
self.delay_feedback = delay_feedback
self.lowpass = lowpass
self.lowpass_q = lowpass_q
self.distortion_mix = distortion
self.distortion_drive = distortion_drive
self.notes: list[Note] = []
def add(self, tone_or_string, duration=Duration.QUARTER) -> "Part":
@@ -1452,7 +1455,8 @@ class Score:
reverb: float = 0.0, reverb_decay: float = 1.0,
delay: float = 0.0, delay_time: float = 0.375,
delay_feedback: float = 0.4,
lowpass: float = 0.0, lowpass_q: float = 0.707) -> Part:
lowpass: float = 0.0, lowpass_q: float = 0.707,
distortion: float = 0.0, distortion_drive: float = 3.0) -> Part:
"""Create a named part with its own synth voice and effects.
Args:
@@ -1475,6 +1479,9 @@ class Score:
lowpass_q: Filter resonance/Q factor (default 0.707, flat).
Higher values add a resonant peak at the cutoff —
1.0 = slight peak, 2.0 = pronounced, 5.0+ = aggressive.
distortion: Distortion wet/dry mix, 0.01.0 (default 0, off).
distortion_drive: Gain before soft clipping (default 3.0).
0.52 = subtle warmth, 38 = overdrive, 10+ = fuzz.
Returns:
A :class:`Part` object. Add notes with ``.add()`` and ``.rest()``.
@@ -1488,7 +1495,8 @@ class Score:
reverb=reverb, reverb_decay=reverb_decay,
delay=delay, delay_time=delay_time,
delay_feedback=delay_feedback,
lowpass=lowpass, lowpass_q=lowpass_q)
lowpass=lowpass, lowpass_q=lowpass_q,
distortion=distortion, distortion_drive=distortion_drive)
self.parts[name] = p
return p