diff --git a/CHANGELOG.md b/CHANGELOG.md index 6381ccd..f020d5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to PyTheory are documented here. +## 0.33.1 + +- **Electric guitar synth** — Karplus-Strong with magnetic pickup comb filter + simulation (single-coil honk, proper sustain) +- **Speaker cabinet simulation** — steep rolloff above 4-5kHz with presence + bump. Makes distorted guitar sound warm instead of fizzy. +- **6 guitar presets:** electric_guitar, clean_guitar, crunch_guitar, + distorted_guitar, orange_crunch, metal_guitar — all with proper cab sim +- **Sitar synth** — Karplus-Strong with jawari bridge buzz, chikari + sympathetic strings, variable damping +- **Guitar strumming** — `Part.strum("Am", Duration.HALF)` with + fretboard fingering lookup, down/up direction, adjustable strum speed +- **World drums:** dhol (bhangra, chaal), dholak (qawwali, folk), + mridangam (adi talam, korvai), djembe (standard, kuku, soli) + — all with bandpass-filtered membrane noise for realistic drum head sound +- **Metal drum kit** — clicky kick, bright snare, tight hats + with 4 patterns (double kick, metal blast, metal groove, metal gallop) +- 15 synth waveforms, 10 envelopes, 40+ instrument presets + ## 0.33.0 - **Non-12-TET support** — `TET(n)` factory creates any equal temperament diff --git a/pytheory/play.py b/pytheory/play.py index 0a064a5..f60207c 100644 --- a/pytheory/play.py +++ b/pytheory/play.py @@ -310,6 +310,63 @@ def strings_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE): return (peak * wave).astype(numpy.int16) +def electric_guitar_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE): + """Electric guitar — Karplus-Strong through magnetic pickup simulation. + + Models a steel string vibrating over a magnetic pickup: + 1. Karplus-Strong plucked string (brighter than acoustic) + 2. Pickup comb filter — a magnetic pickup at 1/4 string length + cancels the 4th harmonic and boosts the 2nd, creating the + characteristic electric guitar "honk" + 3. Slightly longer sustain than acoustic (no body absorption) + """ + period = int(SAMPLE_RATE / hz) + if period < 2: + period = 2 + + rng = numpy.random.default_rng(int(hz * 100) % 2**31) + + # Initial pluck — steel string, bright pick attack + buf = rng.uniform(-1.0, 1.0, period).astype(numpy.float64) + + out = numpy.zeros(n_samples, dtype=numpy.float64) + + # Karplus-Strong with slightly less damping than acoustic + for i in range(n_samples): + out[i] = buf[i % period] + next_idx = (i + 1) % period + # Less damping = more sustain (steel string, no wood body absorbing) + buf[i % period] = 0.5 * (buf[i % period] + buf[next_idx]) * 0.9993 + + # Magnetic pickup simulation — comb filter at pickup position. + # A pickup at 1/4 of the string length cancels the 4th harmonic + # and creates the characteristic electric guitar midrange honk. + pickup_pos = period // 4 + if pickup_pos > 0 and pickup_pos < n_samples: + pickup = numpy.zeros(n_samples, dtype=numpy.float64) + pickup[pickup_pos:] = out[:-pickup_pos] + # Subtract delayed version = comb filter (pickup sampling) + out = out - pickup * 0.3 + + # Slight single-coil brightness boost + # High-shelf boost above 2kHz using a simple 1-pole + t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE + brightness = numpy.zeros(n_samples, dtype=numpy.float64) + if n_samples > 1: + alpha = 0.15 + brightness[0] = out[0] + for i in range(1, n_samples): + brightness[i] = alpha * (out[i] - out[i-1]) + (1 - alpha) * brightness[i-1] + out = out + brightness * 0.2 + + # Normalize + mx = numpy.abs(out).max() + if mx > 0: + out /= mx + + return (peak * out).astype(numpy.int16) + + def sitar_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE): """Sitar — Karplus-Strong with jawari bridge buzz and sympathetic strings. @@ -490,6 +547,7 @@ class Synth(Enum): ORGAN = "organ_synth" STRINGS = "strings_synth" SITAR = "sitar_synth" + ELECTRIC_GUITAR = "electric_guitar_synth" def __call__(self, hz, **kwargs): """Make Synth members callable — dispatches to the wave function.""" @@ -503,6 +561,7 @@ _SYNTH_FUNCTIONS = { "pwm_slow": pwm_slow_wave, "pwm_fast": pwm_fast_wave, "pluck_synth": pluck_wave, "organ_synth": organ_wave, "strings_synth": strings_wave, "sitar_synth": sitar_wave, + "electric_guitar_synth": electric_guitar_wave, } @@ -1256,6 +1315,68 @@ def _synth_mridangam_tha(n_samples): return out +def _synth_metal_kick(n_samples): + """Metal kick — punchy with beater click. Double-bass ready. + + Tight low end with a beater click for definition. Not thin — + needs the low-end weight to anchor the mix alongside bass guitar. + """ + t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE + # Pitch sweep — fast attack, tight body + freq = 50 + 120 * numpy.exp(-60 * t) + phase = 2 * numpy.pi * numpy.cumsum(freq) / SAMPLE_RATE + body = numpy.sin(phase) * _exp_decay(n_samples, 9) * 0.9 + # Beater click — present but not harsh + click_len = min(int(SAMPLE_RATE * 0.012), n_samples) + click = _noise(click_len) * _exp_decay(click_len, 200) * 0.7 + # Sub punch — gives it weight + sub_len = min(int(SAMPLE_RATE * 0.06), n_samples) + sub = _sine_f32(50, sub_len) * _exp_decay(sub_len, 20) * 0.5 + # Membrane thump + thump_len = min(int(SAMPLE_RATE * 0.03), n_samples) + thump = _noise(thump_len) * _exp_decay(thump_len, 60) * 0.4 + body[:sub_len] += sub + body[:click_len] += click + body[:thump_len] += thump + return numpy.tanh(body * 1.5).astype(numpy.float32) + + +def _synth_metal_snare(n_samples): + """Metal snare — bright crack, tight, cutting. + + High-tuned, cranked snare wires, lots of attack. Needs to cut + through double kicks and wall-of-gain guitars. + """ + t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE + # Higher pitched body than rock snare — tuned tight + body = numpy.sin(2 * numpy.pi * 280 * t) * _exp_decay(n_samples, 30) * 0.5 + # Snare wire rattle — shorter, tighter than rock + wire = _noise(n_samples) * _exp_decay(n_samples, 25) * 0.7 + # Bandpass the wire for presence + bl, al = scipy.signal.butter(2, [2000, 8000], btype='band', fs=SAMPLE_RATE) + wire = scipy.signal.lfilter(bl, al, wire).astype(numpy.float32) * 1.5 + # Hard stick crack + crack_len = min(int(SAMPLE_RATE * 0.005), n_samples) + crack = _noise(crack_len) * _exp_decay(crack_len, 400) * 1.5 + result = body + wire + result[:crack_len] += crack + return numpy.tanh(result * 1.8).astype(numpy.float32) + + +def _synth_metal_hat(n_samples): + """Metal hi-hat — ultra tight, precise, machine-gun ready.""" + n = min(n_samples, int(SAMPLE_RATE * 0.02)) # 20ms — very tight + t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE + metallic = (numpy.sin(2 * numpy.pi * 7000 * t) * 0.3 + + numpy.sin(2 * numpy.pi * 9500 * t) * 0.25 + + numpy.sin(2 * numpy.pi * 13000 * t) * 0.2) + noise = _noise(n) * 0.5 + wave = (metallic + noise) * _exp_decay(n, 150) + out = numpy.zeros(n_samples, dtype=numpy.float32) + out[:n] = numpy.tanh(wave * 1.5) + return out + + def _synth_djembe_bass(n_samples): """Djembe bass — open palm strike in center of goatskin head. @@ -1425,6 +1546,10 @@ def _render_drum_hit(sound_value, n_samples): DrumSound.DJEMBE_BASS.value: lambda n: _synth_djembe_bass(n), DrumSound.DJEMBE_TONE.value: lambda n: _synth_djembe_tone(n), DrumSound.DJEMBE_SLAP.value: lambda n: _synth_djembe_slap(n), + # Metal kit + DrumSound.METAL_KICK.value: lambda n: _synth_metal_kick(n), + DrumSound.METAL_SNARE.value: lambda n: _synth_metal_snare(n), + DrumSound.METAL_HAT.value: lambda n: _synth_metal_hat(n), } renderer = _dispatch.get(sound_value, lambda n: _synth_clave(n)) @@ -2257,6 +2382,41 @@ def _apply_phaser(samples, mix=0.5, rate=0.5, stages=4, return (samples * (1 - mix) + wet.astype(numpy.float32) * mix).astype(numpy.float32) +def _apply_cabinet(samples, brightness=0.5, sample_rate=SAMPLE_RATE): + """Guitar speaker cabinet simulation. + + A real guitar cabinet (4x12, 2x12, 1x12) rolls off everything + above ~5kHz sharply. This is what makes distorted guitar sound + warm and musical instead of fizzy and harsh. Without cab sim, + distorted guitar sounds like a broken radio. + + Also adds a presence bump around 2-3kHz (the "cut" frequency) + and rolls off sub-bass below 80Hz (speakers can't reproduce it). + + Args: + samples: Float32 numpy array. + brightness: 0.0 = dark (jazz combo), 0.5 = normal, 1.0 = bright. + """ + # Highpass at 80Hz — speakers don't go that low + if len(samples) > 10: + bl, al = scipy.signal.butter(2, 80, btype='high', fs=sample_rate) + samples = scipy.signal.lfilter(bl, al, samples).astype(numpy.float32) + # Steep lowpass — the cabinet rolloff. This is the magic. + cutoff = 3500 + brightness * 2000 # 3.5kHz (dark) to 5.5kHz (bright) + if cutoff < sample_rate / 2: + bl, al = scipy.signal.butter(3, cutoff, btype='low', fs=sample_rate) + samples = scipy.signal.lfilter(bl, al, samples).astype(numpy.float32) + # Presence bump at 2-3kHz (the "cut through the mix" frequency) + center = 2500 + bw = 800 + if center + bw < sample_rate / 2: + bp, ap = scipy.signal.butter(2, [center - bw, center + bw], + btype='band', fs=sample_rate) + presence = scipy.signal.lfilter(bp, ap, samples).astype(numpy.float32) + samples = samples + presence * 0.3 * brightness + return samples + + def _apply_distortion(samples, drive=1.0, mix=1.0): """Apply soft-clip distortion (tanh waveshaping). @@ -2288,8 +2448,8 @@ def _apply_distortion(samples, drive=1.0, mix=1.0): def _apply_effects_with_params(samples, params, skip_reverb=False): """Apply effects using a params dict. Used for both static and automated rendering.""" - # Signal chain: saturation → tremolo → distortion → chorus → phaser - # → highpass → lowpass → delay → reverb + # Signal chain: saturation → tremolo → distortion → cabinet → chorus + # → phaser → highpass → lowpass → delay → reverb if params.get("saturation", 0) > 0: samples = _apply_saturation(samples, amount=params["saturation"]) if params.get("tremolo_depth", 0) > 0: @@ -2299,6 +2459,9 @@ def _apply_effects_with_params(samples, params, skip_reverb=False): samples = _apply_distortion(samples, drive=params.get("distortion_drive", 3.0), mix=params["distortion_mix"]) + if params.get("cabinet", 0) > 0: + samples = _apply_cabinet(samples, + brightness=params.get("cabinet_brightness", 0.5)) if params.get("chorus_mix", 0) > 0: samples = _apply_chorus(samples, mix=params["chorus_mix"], @@ -2344,6 +2507,8 @@ def _apply_part_effects(samples, part): "chorus_depth": part.chorus_depth, "phaser_mix": part.phaser_mix, "phaser_rate": part.phaser_rate, + "cabinet": part.cabinet, + "cabinet_brightness": part.cabinet_brightness, "highpass": part.highpass, "highpass_q": part.highpass_q, "lowpass": part.lowpass, @@ -2822,7 +2987,8 @@ def render_score(score): part_buf[seg_start:seg_end] = segment else: has_fx = (part.saturation > 0 or part.tremolo_depth > 0 - or part.distortion_mix > 0 or part.chorus_mix > 0 + or part.distortion_mix > 0 or part.cabinet > 0 + or part.chorus_mix > 0 or part.phaser_mix > 0 or part.highpass > 0 or part.lowpass > 0 or part.delay_mix > 0 or part.reverb_mix > 0) @@ -2910,6 +3076,10 @@ def render_score(score): DrumSound.DJEMBE_BASS.value: 0.0, DrumSound.DJEMBE_TONE.value: 0.1, DrumSound.DJEMBE_SLAP.value: -0.1, + # Metal kit + DrumSound.METAL_KICK.value: 0.0, + DrumSound.METAL_SNARE.value: 0.0, + DrumSound.METAL_HAT.value: 0.3, } # Render all drum Parts (may be one "drums" or split into kick/snare/hats/etc.) diff --git a/pytheory/rhythm.py b/pytheory/rhythm.py index a6d96b1..b55ceb7 100644 --- a/pytheory/rhythm.py +++ b/pytheory/rhythm.py @@ -144,20 +144,51 @@ INSTRUMENTS = { # ── Plucked ── "acoustic_guitar": { "synth": "pluck_synth", "envelope": "none", - "lowpass": 4000, - "humanize": 0.2, + "lowpass": 3000, + "humanize": 0.2, "saturation": 0.05, }, "electric_guitar": { - "synth": "saw", "envelope": "pluck", - "detune": 5, "lowpass": 3500, + "synth": "electric_guitar_synth", "envelope": "none", + "cabinet": 1.0, "cabinet_brightness": 0.6, + "humanize": 0.15, + }, + "clean_guitar": { + "synth": "electric_guitar_synth", "envelope": "none", + "cabinet": 1.0, "cabinet_brightness": 0.7, + "chorus": 0.15, "chorus_rate": 1.0, + "reverb": 0.2, "reverb_type": "spring", + "humanize": 0.15, + }, + "crunch_guitar": { + "synth": "electric_guitar_synth", "envelope": "none", + "saturation": 0.3, + "distortion": 0.5, "distortion_drive": 4.0, + "cabinet": 1.0, "cabinet_brightness": 0.5, "humanize": 0.15, }, "distorted_guitar": { - "synth": "saw", "envelope": "pluck", - "detune": 8, "distortion": 0.6, "distortion_drive": 5.0, - "lowpass": 3000, "saturation": 0.3, + "synth": "electric_guitar_synth", "envelope": "none", + "saturation": 0.3, + "distortion": 0.7, "distortion_drive": 5.0, + "cabinet": 1.0, "cabinet_brightness": 0.5, "humanize": 0.15, }, + "orange_crunch": { + "synth": "electric_guitar_synth", "envelope": "none", + "saturation": 0.4, + "distortion": 0.7, "distortion_drive": 6.0, + "cabinet": 1.0, "cabinet_brightness": 0.4, + "humanize": 0.15, + }, + "metal_guitar": { + "synth": "electric_guitar_synth", "envelope": "none", + "saturation": 0.35, + "distortion": 0.8, "distortion_drive": 7.0, + "cabinet": 1.0, "cabinet_brightness": 0.5, + "highpass": 80, + "detune": 4, + "humanize": 0.1, + }, "bass_guitar": { "synth": "triangle", "envelope": "pluck", "lowpass": 1000, @@ -405,6 +436,10 @@ class DrumSound(Enum): DJEMBE_BASS = 102 # open bass (center of head) DJEMBE_TONE = 103 # open tone (edge, fingers together) DJEMBE_SLAP = 104 # slap (edge, fingers spread, sharp crack) + # Metal kit — tighter, punchier, more attack + METAL_KICK = 105 # clicky, punchy, tight + METAL_SNARE = 106 # crack, bright, cutting + METAL_HAT = 107 # tight, short, precise class _Hit: @@ -1432,6 +1467,72 @@ Pattern._PRESETS["tabla solo"] = dict( ], ) +# ── Metal kit patterns ──────────────────────────────────────────────────── +MK = DrumSound.METAL_KICK +MS = DrumSound.METAL_SNARE +MH = DrumSound.METAL_HAT + +# Metal double kick — the classic thrash/death metal beat +Pattern._PRESETS["double kick"] = dict( + name="double kick", + time_signature="4/4", + beats=4.0, + hits=[ + # Double kick 16ths, snare on 2 and 4, tight hats + *[_h(MK, i * 0.25) for i in range(16)], + _h(MS, 1.0), _h(MS, 3.0), + *[_h(MH, i * 0.5) for i in range(8)], + ], +) + +# Metal blast — blast beat with metal kit sounds +Pattern._PRESETS["metal blast"] = dict( + name="metal blast", + time_signature="4/4", + beats=4.0, + hits=[ + *[_h(MK, i * 0.25) for i in range(16)], + *[_h(MS, i * 0.25) for i in range(16)], + *[_h(MH, i * 0.25) for i in range(16)], + ], +) + +# Metal groove — half time with double kick fills +Pattern._PRESETS["metal groove"] = dict( + name="metal groove", + time_signature="4/4", + beats=4.0, + hits=[ + _h(MK, 0.0), _h(MH, 0.0), + _h(MH, 0.5), + _h(MS, 1.0), _h(MH, 1.0), + _h(MK, 1.5), _h(MH, 1.5), + _h(MK, 2.0), _h(MH, 2.0), + _h(MK, 2.25), + _h(MK, 2.5), _h(MH, 2.5), + _h(MK, 2.75), + _h(MS, 3.0), _h(MH, 3.0), + _h(MH, 3.5), + ], +) + +# Metal gallop — the classic Iron Maiden triplet feel +Pattern._PRESETS["metal gallop"] = dict( + name="metal gallop", + time_signature="4/4", + beats=4.0, + hits=[ + _h(MK, 0.0), _h(MH, 0.0), + _h(MK, 0.33), _h(MK, 0.67), + _h(MS, 1.0), _h(MH, 1.0), + _h(MK, 1.33), _h(MK, 1.67), + _h(MK, 2.0), _h(MH, 2.0), + _h(MK, 2.33), _h(MK, 2.67), + _h(MS, 3.0), _h(MH, 3.0), + _h(MK, 3.33), _h(MK, 3.67), + ], +) + # Tabla tiri-kita — rapid 16th-note dayan patter Pattern._PRESETS["tiri kita"] = dict( name="tiri kita", @@ -1903,6 +2004,8 @@ class Part: tremolo_rate: float = 5.0, phaser: float = 0.0, phaser_rate: float = 0.5, + cabinet: float = 0.0, + cabinet_brightness: float = 0.5, fm_ratio: float = 2.0, fm_index: float = 3.0): self.name = name @@ -1946,9 +2049,12 @@ class Part: self.tremolo_rate = tremolo_rate self.phaser_mix = phaser self.phaser_rate = phaser_rate + self.cabinet = cabinet + self.cabinet_brightness = cabinet_brightness self.fm_ratio = fm_ratio self.fm_index = fm_index self._system = "western" # default, overridden by Score.part() + self._fretboard = None # set by Score.part(fretboard=...) self.notes: list[Note] = [] self._drum_hits: list[_Hit] = [] self._drum_pattern_beats: float = 0.0 @@ -2032,6 +2138,7 @@ class Part: "delay_mix": self.delay_mix, "delay_time": self.delay_time, "delay_feedback": self.delay_feedback, "phaser_mix": self.phaser_mix, "phaser_rate": self.phaser_rate, + "cabinet": self.cabinet, "cabinet_brightness": self.cabinet_brightness, "highpass": self.highpass, "highpass_q": self.highpass_q, "lowpass": self.lowpass, "lowpass_q": self.lowpass_q, "distortion_mix": self.distortion_mix, @@ -2249,6 +2356,94 @@ class Part: return self + def strum(self, chord_name: str, duration=Duration.QUARTER, *, + direction: str = "down", velocity: int = 100, + strum_time: float = 0.08) -> "Part": + """Strum a chord using the part's fretboard fingering. + + Looks up the chord on the fretboard, gets the fingering, and + adds each string as a rapid sequence with tiny time offsets — + like a real guitar strum. Muted strings are skipped. + + Args: + chord_name: Chord name (e.g. ``"Am"``, ``"G"``, ``"D"``). + duration: Total duration of the strum (default QUARTER). + direction: ``"down"`` (low→high, default) or ``"up"`` (high→low). + velocity: Base velocity (each string gets slight variation). + strum_time: Time in beats for the full strum sweep + (default 0.03 = very fast). Larger values = slower, + more audible strum. Try 0.1 for a lazy strum. + + Returns: + Self for chaining. + + Example:: + + >>> guitar = score.part("guitar", instrument="acoustic_guitar", + ... fretboard=Fretboard.guitar()) + >>> guitar.strum("Am", Duration.HALF) + >>> guitar.strum("G", Duration.HALF, direction="up") + """ + if self._fretboard is None: + raise ValueError( + "Cannot strum without a fretboard. " + "Set fretboard= when creating the part." + ) + from .charts import CHARTS + + # Get the fingering + system_name = self._system if isinstance(self._system, str) else "western" + if system_name in CHARTS: + chart = CHARTS[system_name] + else: + chart = CHARTS["western"] + if chord_name in chart: + fingering = chart[chord_name].fingering(fretboard=self._fretboard) + else: + # Try fretboard.chord() as fallback + fingering = self._fretboard.chord(chord_name) + + # Get the sounding tones (skips muted strings) + tones = fingering.tones # list of Tone objects, high to low + + if not tones: + self.rest(duration) + return self + + # Order: down strum = low to high (reverse since tones are high-to-low) + if direction == "down": + strum_tones = list(reversed(tones)) + else: + strum_tones = list(tones) + + if hasattr(duration, 'value'): + total_beats = duration.value + else: + total_beats = float(duration) + + # Build a Chord from the fingering tones so all strings ring together + from .chords import Chord as ChordClass + chord_obj = ChordClass(tones=strum_tones) + + # Add grace notes for the strum sweep — short individual string + # hits before the full chord, creating the audible "sweep" + n_strings = len(strum_tones) + per_string = strum_time / max(1, n_strings) if n_strings > 1 else 0 + + import random as _rnd + # Grace notes: each string except the last gets a quiet hit + # Lower velocity than the main chord to avoid pick noise buildup + grace_vel = max(1, int(velocity * 0.5)) + for i in range(n_strings - 1): + vel = max(1, min(127, grace_vel + _rnd.randint(-5, 5))) + self.add(strum_tones[i], per_string, velocity=vel) + + # Full chord rings for the remaining duration + ring_beats = max(0.1, total_beats - strum_time) + self.add(chord_obj, ring_beats, velocity=velocity) + + return self + @property def is_drums(self) -> bool: """True if this part contains drum hits.""" @@ -2451,8 +2646,11 @@ class Score: tremolo_rate: float = None, phaser: float = None, phaser_rate: float = None, + cabinet: float = None, + cabinet_brightness: float = None, fm_ratio: float = None, - fm_index: float = None) -> Part: + fm_index: float = None, + fretboard=None) -> Part: """Create a named part with its own synth voice and effects. Args: @@ -2561,6 +2759,7 @@ class Score: "saturation": saturation, "tremolo_depth": tremolo_depth, "tremolo_rate": tremolo_rate, "phaser": phaser, "phaser_rate": phaser_rate, + "cabinet": cabinet, "cabinet_brightness": cabinet_brightness, "fm_ratio": fm_ratio, "fm_index": fm_index, } for k, v in _locals.items(): @@ -2571,6 +2770,7 @@ class Score: p = Part(name, **merged) p._system = self.system + p._fretboard = fretboard self.parts[name] = p return p diff --git a/test_pytheory.py b/test_pytheory.py index 8da7772..5b09bc7 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -5320,7 +5320,7 @@ def test_supersaw_wave(): @needs_portaudio def test_all_synths_in_enum(): from pytheory.play import Synth - assert len(Synth) == 14 + assert len(Synth) == 15 for s in Synth: wave = s(440, n_samples=1000) assert len(wave) == 1000