from enum import Enum
import time
import numpy
from .tones import Tone
class _LazyModule:
"""Lazy import wrapper — module loaded on first attribute access."""
def __init__(self, name):
self._name = name
self._mod = None
def __getattr__(self, attr):
if self._mod is None:
import importlib
self._mod = importlib.import_module(self._name)
return getattr(self._mod, attr)
scipy = type('scipy', (), {'signal': _LazyModule('scipy.signal')})()
def _get_sd():
"""Lazy import sounddevice — only needed for actual audio playback."""
import sounddevice as sd
return sd
SAMPLE_RATE = 44_100 # CD-quality sample rate (Hz)
SAMPLE_PEAK = 4_096 # Peak amplitude for 16-bit integer samples
[docs]
def sine_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Compute N samples of a sine wave with given frequency and peak amplitude.
Defaults to one second.
"""
length = SAMPLE_RATE / float(hz)
omega = numpy.pi * 2 / length
xvalues = numpy.arange(int(length)) * omega
onecycle = peak * numpy.sin(xvalues)
return numpy.resize(onecycle, (n_samples,)).astype(numpy.int16)
[docs]
def sawtooth_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Compute N samples of a sawtooth wave with given frequency and peak amplitude.
Defaults to one second.
"""
length = SAMPLE_RATE / float(hz)
omega = numpy.pi * 2 / length
xvalues = numpy.arange(int(length)) * omega
onecycle = scipy.signal.sawtooth(xvalues, width=1)
onecycle = (peak * onecycle).astype(numpy.int16)
return numpy.resize(onecycle, (n_samples,))
[docs]
def triangle_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Compute N samples of a triangle wave with given frequency and peak amplitude.
Defaults to one second.
"""
length = SAMPLE_RATE / float(hz)
omega = numpy.pi * 2 / length
xvalues = numpy.arange(int(length)) * omega
onecycle = scipy.signal.sawtooth(xvalues, width=0.5)
onecycle = (peak * onecycle).astype(numpy.int16)
return numpy.resize(onecycle, (n_samples,))
[docs]
def square_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Compute N samples of a square wave — classic chiptune / 8-bit sound.
Hollow and buzzy, containing only odd harmonics (1, 3, 5, 7...) each
at amplitude 1/n. The building block of NES and Game Boy music.
"""
length = SAMPLE_RATE / float(hz)
omega = numpy.pi * 2 / length
xvalues = numpy.arange(int(length)) * omega
onecycle = peak * numpy.sign(numpy.sin(xvalues))
return numpy.resize(onecycle, (n_samples,)).astype(numpy.int16)
[docs]
def pulse_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE, duty=0.25):
"""Compute N samples of a pulse wave with variable duty cycle.
A generalized square wave. Duty cycle controls the timbre:
- 50% = square wave (hollow)
- 25% = nasal, reedy (NES pulse channel default)
- 12.5% = thin, buzzy (NES narrow pulse)
Width changes dramatically affect the harmonic content — narrower
pulses emphasize higher harmonics, producing a brighter, more
cutting sound.
"""
length = SAMPLE_RATE / float(hz)
omega = numpy.pi * 2 / length
xvalues = numpy.arange(int(length)) * omega
onecycle = scipy.signal.square(xvalues, duty=duty)
onecycle = (peak * onecycle).astype(numpy.int16)
return numpy.resize(onecycle, (n_samples,))
[docs]
def fm_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
mod_ratio=2.0, mod_index=3.0):
"""Compute N samples of an FM synthesis wave.
One sine wave (the carrier) has its frequency modulated by another
sine wave (the modulator). This is the basis of the Yamaha DX7 —
the most commercially successful synthesizer ever made.
Args:
hz: Carrier frequency.
mod_ratio: Modulator frequency as a multiple of carrier.
Integer ratios (1, 2, 3) produce harmonic timbres (bells,
electric piano). Non-integer ratios (1.41, 2.76) produce
inharmonic, metallic sounds.
mod_index: Modulation depth. Higher = more harmonics = brighter.
0 = pure sine. 1-3 = warm. 5+ = harsh/metallic.
Common presets:
- Electric piano: ratio=1, index=1.5
- Bell: ratio=3.5, index=5
- Brass: ratio=1, index=3
- Metallic: ratio=1.41, index=8
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
mod_freq = hz * mod_ratio
modulator = mod_index * numpy.sin(2 * numpy.pi * mod_freq * t)
carrier = numpy.sin(2 * numpy.pi * hz * t + modulator)
return (peak * carrier).astype(numpy.int16)
[docs]
def noise_wave(hz=0, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Compute N samples of white noise.
Unpitched — the ``hz`` parameter is accepted for API compatibility
but ignored. Useful for percussion textures, wind effects, and
hi-hat-like sounds in melodic parts.
"""
return (peak * numpy.random.uniform(-1, 1, n_samples)).astype(numpy.int16)
[docs]
def supersaw_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
voices=7, detune_cents=15):
"""Compute N samples of a supersaw — multiple detuned saws summed.
The signature sound of trance and EDM. Multiple sawtooth oscillators
are slightly detuned from each other, creating a fat, shimmering,
chorus-like wall of sound. The Roland JP-8000 (1997) popularized
this as "SuperSaw."
Args:
voices: Number of saw oscillators (default 7). More = fatter.
detune_cents: Maximum detune spread in cents (default 15).
Each voice is spread evenly across ±detune_cents.
"""
spread = numpy.linspace(-detune_cents, detune_cents, voices)
mixed = numpy.zeros(n_samples, dtype=numpy.float64)
for offset in spread:
detuned_hz = hz * (2 ** (offset / 1200))
length = SAMPLE_RATE / float(detuned_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,))
mixed += wave
mixed /= voices # normalize
return (peak * mixed).astype(numpy.int16)
[docs]
def pwm_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE, lfo_rate=0.3):
"""Compute N samples of a pulse-width modulated wave.
A pulse wave whose duty cycle sweeps back and forth via an LFO,
creating a rich, evolving timbre. This is the signature sound of
the Roland Juno-106 and many classic analog polysynths.
As the pulse width changes, different harmonics fade in and out,
producing a natural chorus-like shimmer without any detuning.
At 50% width it's a square wave; at narrow widths it thins to a
bright, reedy tone. The constant motion between these extremes
is what makes PWM so alive-sounding.
Args:
lfo_rate: Speed of the width sweep in Hz.
0.1–0.5 = slow, lush pads.
1–5 = faster, more vibrato/chorus-like.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
# LFO sweeps duty cycle between 0.15 and 0.85
duty = 0.5 + 0.35 * numpy.sin(2 * numpy.pi * lfo_rate * t)
# Generate pulse wave sample-by-sample with varying duty
phase = (t * hz) % 1.0
wave = numpy.where(phase < duty, 1.0, -1.0)
return (peak * wave).astype(numpy.int16)
[docs]
def pwm_slow_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""PWM with slow LFO (0.3 Hz) — lush Juno-style pads."""
return pwm_wave(hz, peak, n_samples, lfo_rate=0.3)
[docs]
def pwm_fast_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""PWM with fast LFO (3 Hz) — chorused, vibrato-like texture."""
return pwm_wave(hz, peak, n_samples, lfo_rate=3.0)
[docs]
def hard_sync_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
slave_ratio=1.5):
"""Hard-sync oscillator — slave saw reset by master clock.
The quintessential analog lead sound. A "slave" oscillator runs at
a different frequency but is forced to restart its cycle every time
the "master" oscillator completes one. The abrupt restart creates
bright, harmonically complex formant peaks that sweep as the slave
ratio changes.
This is THE sound of the Prophet-5, Moog Prodigy, and every
screaming analog lead since 1978.
Args:
slave_ratio: Slave frequency as a multiple of master.
1.0 = unison (plain saw). 1.5–3.0 = sweet spot for leads.
Higher = more metallic, ring-mod-like.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
# Master phase ramps 0→1 at hz
master_phase = (t * hz) % 1.0
# Detect master zero-crossings (phase resets)
resets = numpy.diff(master_phase, prepend=master_phase[0]) < -0.5
# Slave phase: runs at slave_ratio * hz, but resets with master
slave_phase = numpy.zeros(n_samples, dtype=numpy.float64)
phase = 0.0
slave_freq = hz * slave_ratio
dt = 1.0 / SAMPLE_RATE
for i in range(n_samples):
if resets[i]:
phase = 0.0
slave_phase[i] = phase
phase += slave_freq * dt
phase %= 1.0
# Slave is a sawtooth: 2*phase - 1
wave = 2.0 * slave_phase - 1.0
return (peak * wave).astype(numpy.int16)
[docs]
def ring_mod_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
mod_ratio=1.5):
"""Ring modulation — two oscillators multiplied together.
Multiplying two signals produces sum and difference frequencies,
creating inharmonic, metallic, bell-like tones. Unlike FM, ring mod
produces only sidebands — no carrier or modulator in the output.
Classic Dalek voice, Stockhausen elektronische Musik, and the
metallic clang of every sci-fi soundtrack.
Args:
mod_ratio: Modulator frequency as a multiple of carrier.
Integer ratios (2, 3) = harmonic (bell-like).
Non-integer (1.5, 2.1) = inharmonic (metallic, alien).
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
carrier = numpy.sin(2 * numpy.pi * hz * t)
modulator = numpy.sin(2 * numpy.pi * hz * mod_ratio * t)
wave = carrier * modulator
return (peak * wave).astype(numpy.int16)
[docs]
def wavefold_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
folds=3.0):
"""Wavefolding — signal folded back on itself for complex harmonics.
The heart of west coast synthesis (Buchla, Make Noise, Verbos).
A sine wave is amplified past ±1.0, then "folded" — the overflow
bounces back instead of clipping. Each fold adds a new pair of
harmonics. At low fold counts it's warm and round; crank it up
and it gets buzzy, gnarly, and alive.
Sounds completely different from subtractive synthesis — instead
of removing harmonics with a filter, you're *generating* them
by shaping the wave. Pairs beautifully with a lowpass filter.
Args:
folds: Drive amount. 1.0 = clean sine. 2–4 = sweet spot.
6+ = harsh, buzzy territory.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
wave = numpy.sin(2 * numpy.pi * hz * t) * folds
# Triangle-fold: repeatedly reflect at ±1
# Uses the mathematical identity for folding
wave = 4.0 * numpy.abs((wave / 4.0 + 0.25) % 1.0 - 0.5) - 1.0
return (peak * wave).astype(numpy.int16)
[docs]
def drift_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
shape="saw", drift_amount=0.15):
"""Analog VCO with pitch drift, instability, and soft noise floor.
Real analog oscillators are never perfectly stable. Capacitor
charging, thermal variations, and component tolerances make the
pitch wander slightly. This is what makes a Minimoog sound "fat"
and a VST sound "thin" — the constant micro-motion of imperfect
hardware.
Models:
- Slow pitch drift (< 1 Hz wander, like warming up)
- Fast jitter (subtle per-cycle randomness)
- Soft analog noise floor (faint hiss blended in)
- Slightly rounded edges (no mathematically perfect transitions)
Args:
shape: Base oscillator — "saw", "square", "triangle", or "pulse".
drift_amount: How unstable the oscillator is.
0.05 = studio-grade (Sequential, Oberheim).
0.15 = classic vintage (Minimoog, ARP).
0.3 = barely-holding-it-together (old SH-101).
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Slow pitch drift — 2 LFOs at sub-Hz rates with random phase
drift1 = drift_amount * 0.6 * numpy.sin(
2 * numpy.pi * 0.07 * t + rng.uniform(0, 2 * numpy.pi))
drift2 = drift_amount * 0.4 * numpy.sin(
2 * numpy.pi * 0.23 * t + rng.uniform(0, 2 * numpy.pi))
# Fast jitter — per-sample noise filtered to ~50 Hz bandwidth
jitter_raw = rng.normal(0, drift_amount * 0.08, n_samples)
# Simple one-pole lowpass for jitter smoothing
alpha = 2 * numpy.pi * 50.0 / SAMPLE_RATE
jitter = numpy.zeros(n_samples, dtype=numpy.float64)
jitter[0] = jitter_raw[0]
for i in range(1, n_samples):
jitter[i] = jitter[i-1] + alpha * (jitter_raw[i] - jitter[i-1])
# Instantaneous frequency with drift (in cents, converted to ratio)
cents_offset = drift1 + drift2 + jitter
freq_ratio = 2.0 ** (cents_offset / 1200.0)
# Accumulate phase with varying frequency
phase = numpy.cumsum(hz * freq_ratio / SAMPLE_RATE)
phase %= 1.0
# Generate waveform from phase
if shape == "square":
wave = numpy.where(phase < 0.5, 1.0, -1.0)
elif shape == "triangle":
wave = 4.0 * numpy.abs(phase - 0.5) - 1.0
elif shape == "pulse":
wave = numpy.where(phase < 0.25, 1.0, -1.0)
else: # saw
wave = 2.0 * phase - 1.0
# Soft edges — gentle lowpass to round off transitions
cutoff = min(16000, hz * 12)
bl, al = scipy.signal.butter(1, cutoff, btype='low', fs=SAMPLE_RATE)
wave = scipy.signal.lfilter(bl, al, wave)
# Subtle analog noise floor
noise = rng.normal(0, 0.005, n_samples)
wave = wave + noise
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
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)
[docs]
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)
[docs]
def strings_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Bowed string — additive synthesis with natural harmonic rolloff.
Models bowed string physics:
- Additive harmonics with 1/n rolloff shaped by body resonance
- Delayed vibrato (develops ~200ms in, like a real player)
- Subtle bow pressure variation (amplitude modulation)
- Per-harmonic phase randomization for natural timbre
- Gentle spectral tilt to avoid synthetic brightness
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Delayed vibrato: ramps in over ~200ms, like a real bow
vib_rate = 5.2 + rng.uniform(-0.3, 0.3) # slight randomness per note
vib_depth = hz * 0.001 # subtle
vib_onset = numpy.clip(t / 0.2, 0.0, 1.0) # ramp over 200ms
vibrato = vib_depth * vib_onset * numpy.sin(2 * numpy.pi * vib_rate * t)
# Additive synthesis — build harmonics with natural rolloff
nyquist = SAMPLE_RATE / 2.0
n_harmonics = min(40, int(nyquist / hz))
wave = numpy.zeros(n_samples, dtype=numpy.float64)
# Vectorized harmonic synthesis with body resonance
harmonics = numpy.arange(1, n_harmonics + 1, dtype=numpy.float64)
freqs = hz * harmonics
valid = freqs < nyquist
harmonics = harmonics[valid]
freqs = freqs[valid]
n_valid = len(harmonics)
# Body resonance — vectorized
air_f = max(200, min(400, hz * 1.5))
body = (1.0
+ 0.6 * numpy.exp(-((freqs - air_f) / 100) ** 2)
+ 0.4 * numpy.exp(-((freqs - 1000) / 300) ** 2)
+ 0.3 * numpy.exp(-((freqs - 2500) / 500) ** 2))
# Amplitude: 1/n with body shaping, even harmonics weaker
amps = (1.0 / harmonics) * body
amps[1::2] *= 0.85 # even harmonics (index 1,3,5... = harmonic 2,4,6...)
# Random phases
phases = rng.uniform(0, 2 * numpy.pi, n_valid)
# Process in small batches to stay cache-friendly
for i in range(n_valid):
phase = 2 * numpy.pi * (freqs[i] * t + vibrato * harmonics[i] / hz) + phases[i]
wave += amps[i] * numpy.sin(phase)
# Normalize
max_val = numpy.abs(wave).max()
if max_val > 0:
wave /= max_val
# Subtle bow pressure variation — slow amplitude wobble
bow_pressure = 1.0 + 0.03 * numpy.sin(2 * numpy.pi * 3.7 * t)
wave *= bow_pressure
# Gentle lowpass — real instruments don't have infinite bandwidth
cutoff = min(10000, hz * 10)
bl, al = scipy.signal.butter(2, cutoff, btype='low', fs=SAMPLE_RATE)
wave = scipy.signal.lfilter(bl, al, wave)
return (peak * wave).astype(numpy.int16)
[docs]
def piano_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Piano — steel strings struck by felt hammer.
The piano sound has three key qualities:
1. Metallic ring — steel strings produce clear, ringing harmonics
(especially 2nd and 3rd) that sustain for seconds
2. Smooth attack — felt hammer absorbs the initial transient,
no pick noise or pluck character
3. Two-stage decay — fast initial drop (~0.3s) as hammer energy
dissipates, then a long slow ring as the string sustains
Two slightly detuned strings per note create the natural
chorus/shimmer that makes piano sound like piano.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Two-stage decay: fast initial (3.0/s) then slow sustain (0.8/s)
decay = numpy.where(t < 0.3,
numpy.exp(-3.0 * t),
numpy.exp(-3.0 * 0.3) * numpy.exp(-0.8 * (t - 0.3)))
# Two detuned strings — subtle shimmer
detune = 1.5 # cents
hz2 = hz * (2 ** (detune / 1200))
wave = numpy.zeros(n_samples, dtype=numpy.float64)
# Brightness scales with pitch — high notes are brighter, low notes warmer
# C2=65Hz → 0.0, C4=262Hz → 0.5, C6=1047Hz → 1.0
brightness = numpy.clip((hz - 65) / 1000, 0.0, 1.0)
# Harmonics with the metallic spectral shape of steel strings
n_harmonics = min(15, int((SAMPLE_RATE / 2) / hz))
# Vectorized harmonic synthesis — all harmonics at once
harmonics = numpy.arange(1, n_harmonics + 1, dtype=numpy.float64)
# Piano spectral shape as array
amps = numpy.zeros(n_harmonics, dtype=numpy.float64)
amps[0] = 1.0
if n_harmonics > 1:
amps[1] = 0.7 + 0.15 * brightness
if n_harmonics > 2:
amps[2] = 0.45 + 0.2 * brightness
for i in range(3, min(6, n_harmonics)):
amps[i] = (0.25 + 0.15 * brightness) / (i + 1)
for i in range(6, n_harmonics):
amps[i] = (0.1 + 0.1 * brightness) / ((i + 1) ** 2)
# Per-harmonic decay rates
h_decay_rates = (1.5 - 0.5 * brightness) * (harmonics - 1)
# Random phases
phases = rng.uniform(0, 2 * numpy.pi, n_harmonics)
for string_hz in [hz, hz2]:
freqs = string_hz * harmonics # (n_harmonics,)
# Mask out harmonics above Nyquist
valid = freqs < SAMPLE_RATE / 2
if not valid.any():
continue
v_freqs = freqs[valid]
v_amps = amps[valid]
v_rates = h_decay_rates[valid]
v_phases = phases[valid]
# 2D: (n_valid, n_samples) — one sin() call for all harmonics
phase_matrix = 2 * numpy.pi * v_freqs[:, numpy.newaxis] * t[numpy.newaxis, :] + v_phases[:, numpy.newaxis]
decay_matrix = decay[numpy.newaxis, :] * numpy.exp(-v_rates[:, numpy.newaxis] * t[numpy.newaxis, :])
wave += (v_amps[:, numpy.newaxis] * numpy.sin(phase_matrix) * decay_matrix).sum(axis=0)
wave *= 0.5
# Hammer impact — felt-on-string thump
# Brighter and punchier on high notes, warmer on low notes
hammer_len = min(int(SAMPLE_RATE * (0.015 - 0.007 * brightness)), n_samples)
if hammer_len < 4:
hammer_len = 4
hammer_t = numpy.arange(hammer_len, dtype=numpy.float64) / SAMPLE_RATE
hammer_freq = 300 + 400 * brightness # 300Hz low register, 700Hz high
hammer = (numpy.sin(2 * numpy.pi * hammer_freq * hammer_t) * (0.4 + 0.3 * brightness) +
numpy.sin(2 * numpy.pi * hz * 1.5 * hammer_t) * 0.3)
hammer *= numpy.exp(-numpy.linspace(0, 12 + 8 * brightness, hammer_len))
wave[:hammer_len] += hammer
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def rhodes_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Rhodes electric piano — tine struck by hammer, electromagnetic pickup.
The Rhodes sound comes from a rubber-tipped hammer hitting a thin
steel tine next to a tonebar. The tine vibrates near an electromagnetic
pickup (like a guitar pickup), producing a warm, bell-like tone with:
1. Strong fundamental + 2nd harmonic (tine character)
2. Bright metallic attack that mellows quickly (hammer on tine)
3. Bell-like inharmonic partials on soft hits, bark on hard hits
4. Asymmetric waveform from the pickup's nonlinear response
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Two-stage decay: quick initial drop, then long sustain
decay = numpy.where(t < 0.15,
numpy.exp(-4.0 * t),
numpy.exp(-4.0 * 0.15) * numpy.exp(-1.2 * (t - 0.15)))
wave = numpy.zeros(n_samples, dtype=numpy.float64)
# Brightness scales with pitch
brightness = numpy.clip((hz - 65) / 800, 0.0, 1.0)
# Tine harmonics — strong fundamental, prominent 2nd, bell-like upper
tine_harmonics = [
(1, 1.0), # fundamental
(2, 0.6 + 0.15 * brightness), # 2nd — the Rhodes character
(3, 0.15 + 0.1 * brightness), # 3rd — adds warmth
(4, 0.08 + 0.08 * brightness), # 4th — bell quality
(5, 0.04), # 5th — subtle shimmer
(6, 0.02), # 6th
]
for n, amp in tine_harmonics:
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
# Higher harmonics decay faster
h_decay = decay * numpy.exp(-(0.8 + 0.3 * brightness) * (n - 1) * t)
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * f_n * t + phase) * h_decay
# Hammer-on-tine transient — bright metallic click
click_len = min(int(SAMPLE_RATE * 0.008), n_samples)
click_t = numpy.arange(click_len, dtype=numpy.float64) / SAMPLE_RATE
# Inharmonic tine ring at attack (bell partials)
click = (numpy.sin(2 * numpy.pi * hz * 5.3 * click_t) * 0.15 +
numpy.sin(2 * numpy.pi * hz * 7.1 * click_t) * 0.08)
click *= numpy.exp(-numpy.linspace(0, 20, click_len))
wave[:click_len] += click
# Subtle asymmetry from pickup nonlinearity (soft saturation)
wave = numpy.tanh(wave * 1.2) / 1.2
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def wurlitzer_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Wurlitzer electric piano — vibrating steel reed over a pickup.
Unlike the Rhodes (tine + tonebar), the Wurlitzer uses a flat
steel reed that vibrates near an electrostatic pickup. The result
is more nasal, reedy, and biting — especially when driven hard.
Think Supertramp, Ray Charles, early Billy Joel. It barks and
growls in a way the Rhodes never does.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Faster decay than Rhodes — reeds don't sustain like tines
decay = numpy.where(t < 0.1,
numpy.exp(-5.0 * t),
numpy.exp(-5.0 * 0.1) * numpy.exp(-2.0 * (t - 0.1)))
wave = numpy.zeros(n_samples, dtype=numpy.float64)
brightness = numpy.clip((hz - 65) / 800, 0.0, 1.0)
# Reed harmonics — more odd harmonics than Rhodes (nasal character)
reed_harmonics = [
(1, 1.0),
(2, 0.4), # less 2nd than Rhodes
(3, 0.5 + 0.15 * brightness), # strong 3rd — the nasal quality
(4, 0.15),
(5, 0.25 + 0.1 * brightness), # strong odd harmonics
(6, 0.08),
(7, 0.12), # 7th present — reed buzz
]
for n, amp in reed_harmonics:
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
h_decay = decay * numpy.exp(-(1.0 + 0.4 * brightness) * (n - 1) * t)
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * f_n * t + phase) * h_decay
# Reed buzz — slight asymmetric distortion at attack
# This is the "bark" when you hit hard
attack_len = min(int(SAMPLE_RATE * 0.03), n_samples)
attack_env = numpy.zeros(n_samples, dtype=numpy.float64)
attack_env[:attack_len] = numpy.exp(-numpy.linspace(0, 6, attack_len))
wave += numpy.tanh(wave * 3.0 * attack_env) * 0.15
# Electrostatic pickup character — slightly compressed/nasal
wave = numpy.tanh(wave * 1.1) / 1.1
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def mellotron_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
tape="strings"):
"""Mellotron — tape-replay keyboard from the 1960s.
Each key triggers a strip of magnetic tape with a pre-recorded
instrument — the original "sampler." The mechanical transport gives
it a lo-fi, haunted quality that no digital emulation fully captures:
- Tape flutter: pitch wobbles from uneven capstan speed
- Limited bandwidth: 300 Hz–6 kHz, like a worn cassette
- Tape saturation: soft compression, rounded transients
- 8-second limit: tapes physically run out (we model the fadeout)
- Head noise: faint hiss baked into the character
The Mellotron defined the sound of Strawberry Fields Forever,
Stairway to Heaven, and every prog rock record from 1969–1977.
Args:
tape: Which tape bank to simulate.
"strings" — the iconic MkII string section
"flute" — breathy, haunting solo flute
"choir" — ghostly vocal pad
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# --- Tape flutter: slow wow + faster flutter ---
wow = 0.12 * numpy.sin(2 * numpy.pi * 0.4 * t + rng.uniform(0, 6.28))
flutter = 0.06 * numpy.sin(2 * numpy.pi * 6.3 * t + rng.uniform(0, 6.28))
flutter += 0.03 * numpy.sin(2 * numpy.pi * 9.7 * t + rng.uniform(0, 6.28))
# Cents of pitch deviation
pitch_cents = wow + flutter
freq_ratio = 2.0 ** (pitch_cents / 1200.0)
# Accumulate phase with flutter
inst_freq = hz * freq_ratio
phase = numpy.cumsum(inst_freq / SAMPLE_RATE)
# --- Generate the "tape" source ---
nyquist = SAMPLE_RATE / 2.0
wave = numpy.zeros(n_samples, dtype=numpy.float64)
if tape == "flute":
# Breathy flute: fundamental + weak odd harmonics + breath noise
wave += 0.7 * numpy.sin(2 * numpy.pi * phase)
if hz * 3 < nyquist:
wave += 0.12 * numpy.sin(2 * numpy.pi * 3 * phase + rng.uniform(0, 6.28))
if hz * 5 < nyquist:
wave += 0.04 * numpy.sin(2 * numpy.pi * 5 * phase + rng.uniform(0, 6.28))
# Breath noise — bandpass filtered around fundamental
breath = rng.normal(0, 0.25, n_samples)
bw = max(100, hz * 0.3)
lo = max(20, hz - bw)
hi = min(nyquist * 0.95, hz + bw)
bb, ab = scipy.signal.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
breath = scipy.signal.lfilter(bb, ab, breath)
wave += breath
elif tape == "choir":
# Ghostly vocal pad: formant-shaped harmonics with slow drift
formants = [
(800, 100), # first formant ~'ah'
(1200, 120), # second formant
(2500, 200), # third formant
]
n_harmonics = min(20, int(nyquist / hz))
for n in range(1, n_harmonics + 1):
f_n = hz * n
if f_n >= nyquist:
break
amp = 1.0 / n
# Shape by formant peaks
for f_center, f_bw in formants:
amp *= 1.0 + 1.5 * numpy.exp(-((f_n - f_center) / f_bw) ** 2)
p = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * n * phase + p)
# Slow ensemble drift between "voices"
drift = 0.005 * numpy.sin(2 * numpy.pi * 0.15 * t + rng.uniform(0, 6.28))
wave2 = numpy.zeros(n_samples, dtype=numpy.float64)
phase2 = numpy.cumsum(hz * (1.0 + drift) * freq_ratio / SAMPLE_RATE)
for n in range(1, min(8, int(nyquist / hz)) + 1):
f_n = hz * n
if f_n >= nyquist:
break
amp = 0.6 / n
for f_center, f_bw in formants:
amp *= 1.0 + 1.5 * numpy.exp(-((f_n - f_center) / f_bw) ** 2)
wave2 += amp * numpy.sin(2 * numpy.pi * n * phase2 + rng.uniform(0, 6.28))
wave += wave2
else:
# Strings (default): layered ensemble with detuned unison
n_harmonics = min(25, int(nyquist / hz))
# Two "sections" slightly detuned for ensemble width
for section_detune in [-1.5, 0, 1.5]:
section_hz = hz * (2 ** (section_detune / 1200.0))
section_phase = numpy.cumsum(section_hz * freq_ratio / SAMPLE_RATE)
for n in range(1, n_harmonics + 1):
f_n = section_hz * n
if f_n >= nyquist:
break
# String-like: 1/n rolloff, even harmonics slightly weaker
amp = 1.0 / n
if n % 2 == 0:
amp *= 0.8
p = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * n * section_phase + p)
# Normalize before processing
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
# --- Tape bandwidth limiting: 300 Hz – 6 kHz ---
lo_cut = min(300, hz * 0.9) # don't cut fundamental
hi_cut = min(6000, nyquist * 0.95)
if lo_cut < hi_cut and lo_cut > 0:
bb, ab = scipy.signal.butter(2, [lo_cut, hi_cut], btype='band', fs=SAMPLE_RATE)
wave = scipy.signal.lfilter(bb, ab, wave)
# --- Tape saturation: soft compression ---
wave = numpy.tanh(wave * 1.4) / 1.2
# --- Tape run-out: gentle fadeout after ~7 seconds ---
if n_samples > int(SAMPLE_RATE * 7):
fadeout_start = int(SAMPLE_RATE * 7)
fadeout_len = n_samples - fadeout_start
fade = numpy.linspace(1.0, 0.0, fadeout_len)
wave[fadeout_start:] *= fade
# --- Head noise / tape hiss ---
hiss = rng.normal(0, 0.008, n_samples)
wave += hiss
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def vibraphone_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Vibraphone — struck aluminum bars with motor-driven tremolo.
Metal bars hit with soft mallets, resonator tubes underneath,
and a spinning disc (motor) that modulates the sound creating
the signature vibraphone shimmer/tremolo.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Long sustain — bars ring for seconds
decay = numpy.exp(-0.8 * t)
wave = numpy.zeros(n_samples, dtype=numpy.float64)
# Metal bar modes — slightly inharmonic
bar_modes = [
(1.0, 1.0), # fundamental
(2.76, 0.3), # first overtone (not 2x — bars are inharmonic)
(5.4, 0.12), # second overtone
(8.93, 0.04), # third
]
for ratio, amp in bar_modes:
f = hz * ratio
if f >= SAMPLE_RATE / 2:
break
mode_decay = decay * numpy.exp(-0.5 * (ratio - 1) * t)
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * f * t + phase) * mode_decay
# Motor tremolo — spinning disc modulates amplitude at ~5-7 Hz
motor_rate = 5.5
motor_depth = 0.35
# Motor takes a moment to spin up
motor_env = 1.0 - numpy.exp(-2.0 * t)
tremolo = 1.0 - motor_depth * motor_env * (0.5 + 0.5 * numpy.sin(2 * numpy.pi * motor_rate * t))
wave *= tremolo
# Soft mallet attack
mallet_len = min(int(SAMPLE_RATE * 0.005), n_samples)
mallet = rng.uniform(-0.15, 0.15, mallet_len).astype(numpy.float64)
mallet *= numpy.exp(-numpy.linspace(0, 12, mallet_len))
wave[:mallet_len] += mallet
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def pipe_organ_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Pipe organ — air through ranks of pipes, multiple stops.
The pipe organ is additive synthesis incarnate — each stop adds
a rank of pipes at a specific harmonic. We model a classic
registration: principal 8', octave 4', fifteenth 2', mixture.
Constant air pressure means no dynamics — always full and sustained.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
wave = numpy.zeros(n_samples, dtype=numpy.float64)
# Principal 8' — the fundamental organ tone
# Pipe harmonics with subtle wind noise
for n in range(1, 12):
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
# Pipe spectral shape — principalish
if n == 1:
amp = 1.0
elif n == 2:
amp = 0.6
elif n == 3:
amp = 0.4
elif n <= 6:
amp = 0.2 / n
else:
amp = 0.08 / n
wave += amp * numpy.sin(2 * numpy.pi * f_n * t)
# Octave 4' stop — one octave up
for n in range(1, 8):
f_n = hz * 2 * n
if f_n >= SAMPLE_RATE / 2:
break
amp = (0.4 if n == 1 else 0.15 / n)
wave += amp * numpy.sin(2 * numpy.pi * f_n * t)
# Fifteenth 2' — two octaves up, brightness
wave += 0.2 * numpy.sin(2 * numpy.pi * hz * 4 * t)
wave += 0.08 * numpy.sin(2 * numpy.pi * hz * 5 * t)
# Subtle wind/chiff noise at attack
chiff_len = min(int(SAMPLE_RATE * 0.04), n_samples)
chiff = _noise(chiff_len).astype(numpy.float64) * 0.08
chiff *= numpy.exp(-numpy.linspace(0, 10, chiff_len))
wave[:chiff_len] += chiff
# Constant amplitude — organ doesn't decay
# Just a tiny fade-in to avoid click
fadein = min(int(SAMPLE_RATE * 0.01), n_samples)
wave[:fadein] *= numpy.linspace(0, 1, fadein)
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def choir_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE, lyric="ah"):
"""Choir — voices singing vowels shaped by strong formant filters.
The key to vocal sound is FORMANTS — resonant peaks from the
vocal tract shape. We generate a rich glottal source then filter
it hard through formant bandpass filters. The formants are what
make "ah" sound different from "oo".
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
# Vowel formant frequencies + bandwidths (Hz) — F1, F2, F3, F4
_FORMANTS = {
"ah": [(730, 90), (1090, 110), (2440, 170), (3400, 250)],
"ee": [(270, 60), (2290, 200), (3010, 300), (3500, 250)],
"oh": [(570, 80), (840, 100), (2410, 170), (3400, 250)],
"oo": [(300, 50), (870, 90), (2240, 170), (3400, 250)],
"eh": [(530, 70), (1840, 150), (2480, 200), (3400, 250)],
}
formants = _FORMANTS.get(lyric, _FORMANTS["ah"])
# Glottal source — rich buzz with all harmonics
n_harmonics = min(25, int((SAMPLE_RATE / 2) / hz))
# No per-harmonic vibrato — it causes amplitude wobble through formants.
# Choir vibrato comes from the ensemble= parameter instead (natural
# pitch variation between voices).
source = numpy.zeros(n_samples, dtype=numpy.float64)
for n in range(1, n_harmonics + 1):
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
# Glottal slope: -12dB/octave
amp = 1.0 / (n * n) * 4.0
source += amp * numpy.sin(2 * numpy.pi * f_n * t)
# Filter through formants — this is where the voice happens
wave = numpy.zeros(n_samples, dtype=numpy.float64)
for fc, bw in formants:
lo = max(20, fc - bw)
hi = min(SAMPLE_RATE // 2 - 1, fc + bw)
if lo < hi:
bp, ap = scipy.signal.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
filtered = scipy.signal.lfilter(bp, ap, source)
# Boost formants proportionally
gain = 1.0 if fc < 1000 else 0.7
wave += filtered * gain
# Breathy onset — air before phonation
breath_len = min(int(SAMPLE_RATE * 0.08), n_samples)
breath = _noise(breath_len).astype(numpy.float64) * 0.04
# Filter breath through formants too
for fc, bw in formants[:2]:
lo = max(20, fc - bw * 2)
hi = min(SAMPLE_RATE // 2 - 1, fc + bw * 2)
if lo < hi:
bp, ap = scipy.signal.butter(1, [lo, hi], btype='band', fs=SAMPLE_RATE)
breath = scipy.signal.lfilter(bp, ap, numpy.pad(breath, (0, max(0, n_samples - breath_len))))[:breath_len]
breath *= numpy.exp(-numpy.linspace(0, 5, breath_len))
wave[:breath_len] += breath
# Gentle attack
attack_len = min(int(SAMPLE_RATE * 0.06), n_samples)
wave[:attack_len] *= numpy.linspace(0, 1, attack_len)
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def bass_guitar_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Bass guitar — plucked thick string with magnetic pickup.
Heavier Karplus-Strong with:
1. Thicker initial burst (roundwound string character)
2. More fundamental, less high harmonics
3. Pickup emphasizes low-mids
"""
period = int(SAMPLE_RATE / hz)
if period < 2:
period = 2
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Thick string — warmer initial noise
buf = rng.uniform(-0.8, 0.8, period).astype(numpy.float64)
# Pre-filter: warm the initial burst heavily
for _ in range(3):
for k in range(period - 1):
buf[k] = 0.5 * buf[k] + 0.5 * buf[k + 1]
out = numpy.zeros(n_samples, dtype=numpy.float64)
for i in range(n_samples):
out[i] = buf[i % period]
next_idx = (i + 1) % period
# Heavier damping on highs — thick string loses brightness fast
buf[i % period] = 0.45 * buf[i % period] + 0.55 * buf[next_idx]
buf[i % period] *= 0.9992
# Low-mid emphasis (pickup position)
bl, al = scipy.signal.butter(2, 1200, btype='low', fs=SAMPLE_RATE)
out = scipy.signal.lfilter(bl, al, out)
mx = numpy.abs(out).max()
if mx > 0:
out /= mx
return (peak * out).astype(numpy.int16)
[docs]
def flute_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Flute — breath noise through a resonant tube.
Models an air jet exciting a cylindrical tube:
1. Breath noise — bandpass filtered around the fundamental
2. Tube resonance — mostly fundamental + odd harmonics
3. Vibrato that develops over time
4. Breathy attack
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Vibrato — develops after ~200ms
vib_onset = numpy.clip(t / 0.2, 0.0, 1.0)
vib = hz * 0.0008 * vib_onset * numpy.sin(2 * numpy.pi * 5.0 * t)
# Tube resonance — mostly fundamental + weak odd harmonics
wave = numpy.sin(2 * numpy.pi * (hz + vib) * t) * 0.7
wave += numpy.sin(2 * numpy.pi * (hz * 3 + vib * 3) * t) * 0.15
wave += numpy.sin(2 * numpy.pi * (hz * 5 + vib * 5) * t) * 0.05
# Breath noise — bandpassed around the fundamental
breath = rng.normal(0, 0.15, n_samples)
bw = max(100, hz * 0.3)
lo = max(20, hz - bw)
hi = min(SAMPLE_RATE // 2 - 1, hz + bw)
if lo < hi:
bn, an = scipy.signal.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
breath = scipy.signal.lfilter(bn, an, breath)
wave = wave + breath
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def trumpet_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Trumpet — lip buzz through a brass bell.
Models the key trumpet characteristics:
1. Lip buzz — rich in harmonics (like a saw but with specific spectral shape)
2. Bell resonance — boosts 1-2kHz "brightness" range
3. Brass warmth — even harmonics stronger than clarinet
4. Slight vibrato
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Vibrato
vib_onset = numpy.clip(t / 0.15, 0.0, 1.0)
vib = hz * 0.001 * vib_onset * numpy.sin(2 * numpy.pi * 5.5 * t)
# Lip buzz — additive with brass spectral shape
# Trumpet has strong even AND odd harmonics (unlike clarinet)
wave = numpy.zeros(n_samples, dtype=numpy.float64)
n_harmonics = min(20, int((SAMPLE_RATE / 2) / hz))
for n in range(1, n_harmonics + 1):
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
# Brass spectral envelope: peaks around harmonics 3-6
amp = (1.0 / n) * numpy.exp(-0.08 * (n - 4) ** 2)
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * (f_n + vib * n) * t + phase)
# Bell resonance — boost around 1.5-3kHz
bl, al = scipy.signal.butter(2, [1500, 3000], btype='band', fs=SAMPLE_RATE)
bell = scipy.signal.lfilter(bl, al, wave) * 0.4
wave = wave + bell
# Gentle attack buzz
attack_len = min(int(SAMPLE_RATE * 0.02), n_samples)
buzz = rng.uniform(-0.1, 0.1, attack_len) * numpy.exp(-numpy.linspace(0, 5, attack_len))
wave[:attack_len] += buzz
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def clarinet_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Clarinet — reed vibration in a cylindrical bore.
A cylindrical bore produces mostly odd harmonics (like a square wave
but with a specific spectral envelope). The reed adds a nasal quality.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
vib_onset = numpy.clip(t / 0.3, 0.0, 1.0)
vib = hz * 0.001 * vib_onset * numpy.sin(2 * numpy.pi * 4.5 * t)
# Cylindrical bore: odd harmonics dominate
wave = numpy.zeros(n_samples, dtype=numpy.float64)
n_harmonics = min(15, int((SAMPLE_RATE / 2) / hz))
for n in range(1, n_harmonics + 1, 2): # odd harmonics only
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
amp = 1.0 / n
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * (f_n + vib * n) * t + phase)
# Reed noise — nasal character
reed = rng.normal(0, 0.05, n_samples)
wave += reed
# Bore resonance — slight lowpass
bl, al = scipy.signal.butter(2, min(4000, hz * 8), btype='low', fs=SAMPLE_RATE)
wave = scipy.signal.lfilter(bl, al, wave)
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def marimba_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Marimba — struck wooden bar with resonator tube.
The bar produces a fundamental plus inharmonic partials (the bar
modes are NOT integer multiples). The tubular resonator under
each bar amplifies the fundamental.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
# Bar modes: fundamental, then 4x, 9.2x (not harmonic!)
wave = numpy.sin(2 * numpy.pi * hz * t) * 0.8
wave += numpy.sin(2 * numpy.pi * hz * 4.0 * t) * 0.15 * numpy.exp(-20 * t)
wave += numpy.sin(2 * numpy.pi * hz * 9.2 * t) * 0.05 * numpy.exp(-40 * t)
# Resonator tube amplifies fundamental
wave *= (1.0 + 0.3 * numpy.exp(-3 * t))
# Mallet impact
impact_len = min(int(SAMPLE_RATE * 0.005), n_samples)
impact = numpy.random.default_rng(int(hz * 100) % 2**31).uniform(-0.2, 0.2, impact_len)
impact *= numpy.exp(-numpy.linspace(0, 10, impact_len))
wave[:impact_len] += impact
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def oboe_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Oboe — double reed through a conical bore.
The conical bore (unlike clarinet's cylinder) produces both odd
AND even harmonics, but with a nasal, reedy quality from the
double reed. Brighter and more piercing than clarinet.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
vib_onset = numpy.clip(t / 0.2, 0.0, 1.0)
vib = hz * 0.001 * vib_onset * numpy.sin(2 * numpy.pi * 5.0 * t)
wave = numpy.zeros(n_samples, dtype=numpy.float64)
n_harmonics = min(18, int((SAMPLE_RATE / 2) / hz))
for n in range(1, n_harmonics + 1):
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
# Conical bore: all harmonics, peaked around 3-5
amp = (1.0 / n) * numpy.exp(-0.05 * (n - 3) ** 2)
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * (f_n + vib * n) * t + phase)
# Double reed buzz — nasal character
reed = rng.normal(0, 0.06, n_samples)
bw = max(100, hz * 0.4)
lo, hi = max(20, int(hz * 2 - bw)), min(SAMPLE_RATE // 2 - 1, int(hz * 2 + bw))
if lo < hi:
br, ar = scipy.signal.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
reed = scipy.signal.lfilter(br, ar, reed)
wave += reed
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def harpsichord_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Harpsichord — quill plucking a metal string.
Distinctive bright, metallic pluck with no sustain control
(unlike piano, you can't play soft). Rich in harmonics with
a sharp attack and moderate decay.
"""
period = int(SAMPLE_RATE / hz)
if period < 2:
period = 2
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Bright initial noise — quill pluck is sharper than felt hammer
buf = rng.uniform(-1.0, 1.0, period).astype(numpy.float64)
# Less filtering than guitar — harpsichord keeps brightness
for k in range(period - 1):
buf[k] = 0.7 * buf[k] + 0.3 * buf[k + 1]
out = numpy.zeros(n_samples, dtype=numpy.float64)
for i in range(n_samples):
out[i] = buf[i % period]
next_idx = (i + 1) % period
# Moderate decay — not as long as piano, not as short as guitar
buf[i % period] = 0.5 * (buf[i % period] + buf[next_idx]) * 0.999
# Harpsichord has a distinctive "chiff" — the quill release
chiff_len = min(int(SAMPLE_RATE * 0.003), n_samples)
chiff = rng.uniform(-0.5, 0.5, chiff_len) * numpy.exp(-numpy.linspace(0, 15, chiff_len))
out[:chiff_len] += chiff
mx = numpy.abs(out).max()
if mx > 0:
out /= mx
return (peak * out).astype(numpy.int16)
[docs]
def cello_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Cello — deep bowed string with large body resonance.
Like strings_wave but with stronger low-frequency body resonance
(the cello body is much larger than violin) and a darker, warmer
harmonic profile.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Delayed vibrato
vib_rate = 5.0 + rng.uniform(-0.3, 0.3)
vib_depth = hz * 0.001
vib_onset = numpy.clip(t / 0.25, 0.0, 1.0)
vibrato = vib_depth * vib_onset * numpy.sin(2 * numpy.pi * vib_rate * t)
nyquist = SAMPLE_RATE / 2.0
n_harmonics = min(25, int(nyquist / hz))
wave = numpy.zeros(n_samples, dtype=numpy.float64)
# Cello body resonance: strong peaks at ~250Hz and ~500Hz
def body(f):
r = 1.0
r += 0.8 * numpy.exp(-((f - 250) / 80) ** 2) # main resonance
r += 0.5 * numpy.exp(-((f - 500) / 120) ** 2) # second peak
r += 0.2 * numpy.exp(-((f - 1200) / 200) ** 2) # bridge
return r
for n in range(1, n_harmonics + 1):
f_n = hz * n
if f_n >= nyquist:
break
amp = (1.0 / n) * body(f_n)
if n % 2 == 0:
amp *= 0.85
phi = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * (f_n * t + vibrato * n / hz) + phi)
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
# Bow pressure variation
wave *= 1.0 + 0.04 * numpy.sin(2 * numpy.pi * 3.5 * t)
return (peak * wave).astype(numpy.int16)
[docs]
def harp_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Harp — pure, singing tone with gentle pluck and long sustain.
Nylon/gut strings on a large resonant frame. The tone is warm
and clean — mostly fundamental with gentle upper harmonics that
decay faster, leaving a pure singing sustain.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Long, gentle decay
decay = numpy.exp(-1.5 * t)
wave = numpy.zeros(n_samples, dtype=numpy.float64)
# Clean harmonics — strong fundamental, gentle upper partials
n_harmonics = min(10, int((SAMPLE_RATE / 2) / hz))
for n in range(1, n_harmonics + 1):
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
# Fundamental dominates, upper partials gentle and fast-decaying
if n == 1:
amp = 1.0
elif n == 2:
amp = 0.3
elif n == 3:
amp = 0.15
else:
amp = 0.06 / n
h_decay = decay * numpy.exp(-1.5 * (n - 1) * t)
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * f_n * t + phase) * h_decay
# Karplus-Strong pluck transient — just the first ~50ms
# Gives the finger-on-string attack, then the pure tone takes over
period = max(2, int(SAMPLE_RATE / hz))
ks_rng = numpy.random.default_rng(int(hz * 77) % 2**31)
ks_buf = ks_rng.uniform(-0.3, 0.3, period).astype(numpy.float64)
# Pre-filter for soft finger pluck
for _ in range(4):
for k in range(period - 1):
ks_buf[k] = 0.6 * ks_buf[k] + 0.4 * ks_buf[k + 1]
pluck_len = min(int(SAMPLE_RATE * 0.05), n_samples)
pluck = numpy.zeros(pluck_len, dtype=numpy.float64)
for i in range(pluck_len):
pluck[i] = ks_buf[i % period]
nxt = (i + 1) % period
ks_buf[i % period] = 0.5 * (ks_buf[i % period] + ks_buf[nxt])
# Fade the KS pluck out quickly so the pure tone takes over
pluck *= numpy.exp(-numpy.linspace(0, 8, pluck_len)) * 0.4
wave[:pluck_len] += pluck
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def upright_bass_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Upright bass — thick gut/steel string pizzicato with wooden body.
Deep, round, woody. The large hollow body gives a warm resonance
that electric bass can't match. Pizzicato (plucked) by default.
"""
period = int(SAMPLE_RATE / hz)
if period < 2:
period = 2
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Thick string — very warm initial noise
buf = rng.uniform(-0.6, 0.6, period).astype(numpy.float64)
for _ in range(5):
for k in range(period - 1):
buf[k] = 0.5 * buf[k] + 0.5 * buf[k + 1]
out = numpy.zeros(n_samples, dtype=numpy.float64)
for i in range(n_samples):
out[i] = buf[i % period]
next_idx = (i + 1) % period
buf[i % period] = 0.5 * (buf[i % period] + buf[next_idx]) * 0.9985
# Wooden body resonance — big, round
for center, bw, gain in [(80, 40, 0.4), (200, 60, 0.3), (400, 100, 0.15)]:
lo = max(20, center - bw)
hi = min(SAMPLE_RATE // 2 - 1, center + bw)
if lo < hi:
bp, ap = scipy.signal.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
out += scipy.signal.lfilter(bp, ap, out) * gain
# Dark rolloff — upright bass is not bright
bl, al = scipy.signal.butter(2, min(1500, hz * 6), btype='low', fs=SAMPLE_RATE)
out = scipy.signal.lfilter(bl, al, out)
mx = numpy.abs(out).max()
if mx > 0:
out /= mx
return (peak * out).astype(numpy.int16)
[docs]
def timpani_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Timpani — large kettle drum with definite pitch.
The copper kettle creates a tuned resonance with inharmonic
overtones. The head modes are at ratios 1.0, 1.5, 1.99, 2.44
(not integer multiples like strings). The felt mallet gives a
soft attack with a deep, booming body.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
# Timpani head modes — inharmonic but definite pitch
# Mode ratios from vibrating circular membrane physics
wave = numpy.sin(2 * numpy.pi * hz * t) * 0.8
wave += numpy.sin(2 * numpy.pi * hz * 1.5 * t) * 0.35 * numpy.exp(-6 * t)
wave += numpy.sin(2 * numpy.pi * hz * 1.99 * t) * 0.2 * numpy.exp(-10 * t)
wave += numpy.sin(2 * numpy.pi * hz * 2.44 * t) * 0.1 * numpy.exp(-15 * t)
# Two-stage decay: thump fades, but fundamental SUSTAINS long
# This is what makes the "oooh" — the fundamental rings and rings,
# so rapid hits in a roll stack into a singing resonance
thump_decay = numpy.exp(-6 * t) # upper modes die fast
fund_decay = numpy.exp(-0.35 * t) # fundamental sustains very long
# Apply different decays: fundamental gets long sustain
fund = numpy.sin(2 * numpy.pi * hz * t) * 0.8 * fund_decay
upper = (numpy.sin(2 * numpy.pi * hz * 1.5 * t) * 0.35 * numpy.exp(-6 * t) +
numpy.sin(2 * numpy.pi * hz * 1.99 * t) * 0.2 * numpy.exp(-10 * t) +
numpy.sin(2 * numpy.pi * hz * 2.44 * t) * 0.1 * numpy.exp(-15 * t))
upper *= thump_decay
wave = fund + upper
# Felt mallet impact — warm, not sharp
mallet_len = min(int(SAMPLE_RATE * 0.02), n_samples)
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
mallet = rng.uniform(-0.3, 0.3, mallet_len)
mallet *= numpy.exp(-numpy.linspace(0, 8, mallet_len))
wave[:mallet_len] += mallet
# Copper kettle resonance — boosts the fundamental ring
lo, hi = max(20, int(hz * 0.7)), min(SAMPLE_RATE // 2 - 1, int(hz * 1.3))
if lo < hi:
bp, ap = scipy.signal.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
kettle = scipy.signal.lfilter(bp, ap, wave) * 0.4
wave += kettle
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def saxophone_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Saxophone — single reed driving a conical brass bore.
Models the key acoustic properties of a saxophone:
1. Reed-bore interaction — nonlinear clipping creates the characteristic
bright, edgy tone (not just additive sines)
2. Conical bore formants — vocal-like resonances at ~500, ~1400, ~2300,
~3200 Hz that give sax its singing quality
3. Breath noise — turbulent airflow through the mouthpiece, strongest
at attack and blending into sustained tone
4. Sub-harmonic warmth — the conical bore's coupling creates warmth
below the fundamental
5. Vibrato — delayed onset, ~5 Hz, characteristic of jazz/classical sax
"""
import scipy.signal as _sig
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# --- Vibrato: delayed onset, subtle depth ---
vib_onset = numpy.clip((t - 0.3) / 0.3, 0.0, 1.0)
vib_rate = 5.0 + 0.15 * numpy.sin(2 * numpy.pi * 0.4 * t)
vib = hz * 0.0006 * vib_onset * numpy.sin(2 * numpy.pi * vib_rate * t)
# --- Core tone: sawtooth-like waveform with reed clipping ---
# Real sax reed creates a quasi-sawtooth pressure wave, not pure sines.
# Build from harmonics with sax-specific spectral envelope, then clip.
wave = numpy.zeros(n_samples, dtype=numpy.float64)
n_harmonics = min(25, int((SAMPLE_RATE / 2) / hz))
for n in range(1, n_harmonics + 1):
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
# Saxophone spectral envelope from acoustic measurements:
# Strong fundamental, nearly-as-strong 2nd and 3rd harmonics,
# broad energy peak around harmonics 4-8 (the "body"), then
# gradual rolloff — but slower than other woodwinds (brass bore
# sustains upper partials).
if n == 1:
amp = 1.0
elif n == 2:
amp = 0.85
elif n == 3:
amp = 0.7
elif n <= 8:
# Broad mid peak — this is the sax "meat"
amp = 0.55 * numpy.exp(-0.06 * (n - 5) ** 2)
else:
# Slower rolloff than oboe/clarinet
amp = 0.35 / (n ** 0.7)
# Slight even/odd asymmetry — conical bore has all harmonics
# but evens are ~10% weaker (midway between cylinder and cone)
if n % 2 == 0:
amp *= 0.9
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * (f_n + vib * n) * t + phase)
# --- Reed nonlinearity: soft clipping ---
# The reed closes against the mouthpiece, creating asymmetric clipping
# that adds brightness and "edge". This is what makes sax sound like
# sax and not a flute.
wave_max = numpy.abs(wave).max()
if wave_max > 0:
wave /= wave_max
# Asymmetric soft clip: positive peaks clip harder (reed closure)
wave = numpy.tanh(1.8 * wave) * 0.7 + numpy.tanh(2.5 * wave) * 0.3
# --- Formant resonances: conical bore creates vocal quality ---
# These fixed resonances are what make sax sound "vocal" — they
# emphasize certain frequency bands regardless of the note played.
formant_freqs = [520, 1380, 2300, 3200]
formant_bws = [120, 200, 280, 350]
formant_gains = [0.25, 0.18, 0.12, 0.08]
formant_sum = numpy.zeros(n_samples, dtype=numpy.float64)
for fc, bw, gain in zip(formant_freqs, formant_bws, formant_gains):
lo = max(20, int(fc - bw))
hi = min(SAMPLE_RATE // 2 - 1, int(fc + bw))
if lo < hi:
bf, af = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
formant_sum += _sig.lfilter(bf, af, wave) * gain
wave = wave * 0.7 + formant_sum
# --- Breath noise: turbulent air through the mouthpiece ---
# Strongest at the attack, then settles to a subtle constant hiss
# that gives the tone "life" and prevents it from sounding synthetic.
breath = rng.normal(0, 1.0, n_samples)
# Shape breath noise into the sax's "hiss" band (2-6 kHz)
breath_lo = max(20, 2000)
breath_hi = min(SAMPLE_RATE // 2 - 1, 6000)
if breath_lo < breath_hi:
bb, ab = _sig.butter(2, [breath_lo, breath_hi], btype='band', fs=SAMPLE_RATE)
breath = _sig.lfilter(bb, ab, breath)
# Attack envelope for breath — strong at onset, then quiet
breath_env = 0.15 * numpy.exp(-8.0 * t) + 0.03
wave += breath * breath_env
# --- Reed buzz: low-frequency interaction noise ---
# Different from breath — this is the "buzz" from reed vibration
# against the mouthpiece, centered around the playing frequency.
reed_noise = rng.normal(0, 1.0, n_samples)
reed_lo = max(20, int(hz * 0.8))
reed_hi = min(SAMPLE_RATE // 2 - 1, int(hz * 4))
if reed_lo < reed_hi:
br, ar = _sig.butter(2, [reed_lo, reed_hi], btype='band', fs=SAMPLE_RATE)
reed_noise = _sig.lfilter(br, ar, reed_noise) * 0.06
wave += reed_noise
# --- Attack transient: key click + breath burst ---
attack_len = min(int(SAMPLE_RATE * 0.015), n_samples)
if attack_len > 0:
click = rng.uniform(-1.0, 1.0, attack_len)
click *= numpy.exp(-numpy.linspace(0, 8, attack_len))
wave[:attack_len] += click * 0.12
# --- Sub-harmonic warmth ---
# Conical bore coupling produces energy slightly below fundamental
if hz > 80: # only if there's room
sub = numpy.sin(2 * numpy.pi * (hz * 0.5) * t) * 0.04
sub *= numpy.clip(t / 0.1, 0.0, 1.0) # fade in gently
wave += sub
# --- Final shaping ---
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def vocal_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE, lyric="ah"):
"""Vocal/formant synthesis — sings vowel sounds at a given pitch.
Models the human voice with:
1. LF glottal model — asymmetric pulse with sharp closure (not just sines)
2. 5 parallel resonant formant filters (real voice has 5 formant peaks)
3. Jitter + shimmer (natural pitch/amplitude irregularity)
4. Aspiration noise mixed with the glottal source
5. Consonant onsets (plosives, sibilants, nasals, etc.)
"""
import scipy.signal as _sig
# 5-formant table: (F1, F2, F3, F4, F5) frequencies and bandwidths
# Based on Peterson & Barney (1952) measurements, male voice
FORMANTS = {
'a': [(800, 130), (1200, 100), (2500, 140), (3300, 250), (3750, 300)],
'e': [(530, 80), (1850, 100), (2500, 130), (3300, 250), (3750, 300)],
'i': [(280, 60), (2250, 100), (2900, 120), (3350, 250), (3750, 300)],
'o': [(500, 100), (1000, 80), (2500, 140), (3300, 250), (3750, 300)],
'u': ((325, 70), (700, 60), (2530, 140), (3300, 250), (3750, 300)),
}
# Formant gains (relative amplitude per formant)
FGAINS = [1.0, 0.8, 0.5, 0.25, 0.15]
rng = numpy.random.default_rng(int(hz * 100 + len(lyric) * 7) % 2**31)
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
# Parse vowels from lyric
vowels_in_lyric = [c.lower() for c in lyric if c.lower() in FORMANTS]
if not vowels_in_lyric:
vowels_in_lyric = ['a']
# ── Glottal source: LF model approximation ──
# Asymmetric pulse: slow open phase, sharp closure, then closed phase.
# Much more "voice-like" than a sine or sawtooth.
# Jitter (pitch irregularity) + shimmer (amplitude irregularity)
jitter = rng.normal(0, hz * 0.001, n_samples) # ~0.1% pitch jitter
shimmer = 1.0 + rng.normal(0, 0.008, n_samples) # ~0.8% amp shimmer
# Vibrato
vib = hz * 0.001 * numpy.sin(2 * numpy.pi * 5.5 * t)
inst_freq = hz + vib + jitter
phase = numpy.cumsum(2 * numpy.pi * inst_freq / SAMPLE_RATE)
# LF glottal shape: sharper falling edge via phase shaping
saw = (phase / (2 * numpy.pi)) % 1.0 # 0 to 1 sawtooth
# Asymmetric: slow rise (60%), fast fall (40%)
glottal = numpy.where(saw < 0.6,
numpy.sin(numpy.pi * saw / 0.6), # smooth rise
-numpy.sin(numpy.pi * (saw - 0.6) / 0.4) * 0.8) # sharp fall
glottal *= shimmer
# Aspiration noise (breathiness) — subtle
breath = rng.normal(0, 0.04, n_samples)
source = glottal * 0.92 + breath * 0.08
# ── Formant filtering ──
n_vowels = len(vowels_in_lyric)
out = numpy.zeros(n_samples, dtype=numpy.float64)
if n_vowels == 1:
# Single vowel — filter the whole thing
formants = FORMANTS[vowels_in_lyric[0]]
for (fc, bw), gain in zip(formants, FGAINS):
lo = max(20, fc - bw)
hi = min(SAMPLE_RATE // 2 - 1, fc + bw)
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
out += _sig.lfilter(bp, ap, source).astype(numpy.float64) * gain
else:
# Multiple vowels — crossfade formants
samples_per_vowel = n_samples // n_vowels
for vi, vowel in enumerate(vowels_in_lyric):
formants = FORMANTS[vowel]
start = vi * samples_per_vowel
end = n_samples if vi == n_vowels - 1 else start + samples_per_vowel
seg = source[start:end].copy()
seg_out = numpy.zeros_like(seg)
for (fc, bw), gain in zip(formants, FGAINS):
lo = max(20, fc - bw)
hi = min(SAMPLE_RATE // 2 - 1, fc + bw)
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
seg_out += _sig.lfilter(bp, ap, seg).astype(numpy.float64) * gain
# Crossfade
fade = min(int(SAMPLE_RATE * 0.02), len(seg_out) // 4)
if vi > 0 and fade > 0:
seg_out[:fade] *= numpy.linspace(0, 1, fade)
if vi < n_vowels - 1 and fade > 0:
seg_out[-fade:] *= numpy.linspace(1, 0, fade)
out[start:end] += seg_out[:end - start]
# ── Consonant onsets ──
lyric_lower = lyric.lower()
if lyric_lower and lyric_lower[0] not in 'aeiou':
c = lyric_lower[0]
cl = min(int(SAMPLE_RATE * 0.035), n_samples)
if c in 'tdkpb':
burst = rng.uniform(-0.5, 0.5, cl) * numpy.exp(-numpy.linspace(0, 18, cl))
out[:cl] = burst + out[:cl] * 0.2
elif c in 'sz':
sib = rng.uniform(-0.4, 0.4, cl)
if cl > 20:
bl, al = _sig.butter(2, [3000, min(8000, SAMPLE_RATE//2-1)], btype='band', fs=SAMPLE_RATE)
sib = _sig.lfilter(bl, al, numpy.pad(sib, (0, max(0, n_samples-cl))))[:cl]
sib *= numpy.exp(-numpy.linspace(0, 10, cl))
out[:cl] = sib * 0.6 + out[:cl] * 0.4
elif c in 'mn':
nl = min(int(SAMPLE_RATE * 0.06), n_samples)
nasal = numpy.sin(2*numpy.pi*250*t[:nl]) * 0.4 * numpy.exp(-numpy.linspace(0, 4, nl))
out[:nl] = nasal + out[:nl] * 0.4
elif c in 'fv':
fric = rng.uniform(-0.25, 0.25, cl) * numpy.exp(-numpy.linspace(0, 12, cl))
out[:cl] = fric * 0.5 + out[:cl] * 0.5
elif c in 'lr':
gl = min(int(SAMPLE_RATE * 0.05), n_samples)
ghz = hz * 0.7 + hz * 0.3 * numpy.linspace(0, 1, gl)
glide = numpy.sin(numpy.cumsum(2*numpy.pi*ghz/SAMPLE_RATE)) * 0.35
out[:gl] = glide + out[:gl] * 0.65
elif c == 'h':
hl = min(int(SAMPLE_RATE * 0.05), n_samples)
asp = rng.uniform(-0.4, 0.4, hl) * numpy.exp(-numpy.linspace(0, 5, hl))
out[:hl] = asp * 0.6 + out[:hl] * 0.4
elif c == 'w':
wl = min(int(SAMPLE_RATE * 0.06), n_samples)
ws = numpy.sin(numpy.cumsum(2*numpy.pi*hz/SAMPLE_RATE*numpy.ones(wl)))
if wl > 20:
bp, ap = _sig.butter(2, [max(20,300), min(800, SAMPLE_RATE//2-1)], btype='band', fs=SAMPLE_RATE)
ws = _sig.lfilter(bp, ap, ws)
ws *= numpy.linspace(0.5, 0, wl)
out[:wl] = ws * 0.4 + out[:wl] * 0.6
# Soft edges — prevent clicks at note boundaries
fade_samples = min(int(SAMPLE_RATE * 0.01), n_samples // 4)
if fade_samples > 0:
out[:fade_samples] *= numpy.linspace(0, 1, fade_samples)
out[-fade_samples:] *= numpy.linspace(1, 0, fade_samples)
mx = numpy.abs(out).max()
if mx > 0:
out /= mx
return (peak * out).astype(numpy.int16)
[docs]
def granular_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
grain_size=0.04, density=50, scatter=0.5,
pitch_var=12, source="saw"):
"""Granular synthesis — clouds of tiny sound grains.
Chops a source waveform into overlapping micro-grains (10-200ms),
each independently windowed and optionally pitch/time scattered.
Creates textures impossible with other synthesis: frozen tones,
shimmering clouds, evolving pads, glitchy stutters.
Args:
hz: Base frequency.
grain_size: Duration of each grain in seconds (default 0.05 = 50ms).
density: Grains per second (default 20). Higher = denser cloud.
scatter: Random position jitter 0-1 (default 0.3). How much each
grain's read position varies from sequential order.
pitch_var: Random pitch variation per grain in cents (default 5).
source: Base waveform — ``"saw"``, ``"sine"``, ``"triangle"``,
``"square"``, ``"noise"`` (default ``"saw"``).
"""
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Generate source material — longer than needed for scatter headroom
src_len = n_samples + int(SAMPLE_RATE * scatter * 2)
src_fns = {
"saw": sawtooth_wave, "sine": sine_wave, "triangle": triangle_wave,
"square": square_wave, "noise": noise_wave,
}
src_fn = src_fns.get(source, sawtooth_wave)
src = src_fn(hz, n_samples=src_len).astype(numpy.float64) / SAMPLE_PEAK
# Grain parameters
grain_samples = max(64, int(grain_size * SAMPLE_RATE))
n_grains = max(1, int(n_samples / SAMPLE_RATE * density))
# Hanning window for each grain (smooth fade in/out, no clicks)
window = numpy.hanning(grain_samples).astype(numpy.float64)
out = numpy.zeros(n_samples, dtype=numpy.float64)
for i in range(n_grains):
# Output position — evenly spaced with jitter
base_pos = int(i * n_samples / n_grains)
jitter = int(rng.uniform(-0.5, 0.5) * n_samples / n_grains * 0.3)
out_pos = max(0, min(n_samples - grain_samples, base_pos + jitter))
# Source read position — sequential with scatter
src_pos = int(base_pos * src_len / n_samples)
src_jitter = int(rng.uniform(-scatter, scatter) * grain_samples * 4)
src_pos = max(0, min(src_len - grain_samples, src_pos + src_jitter))
# Per-grain pitch variation via resampling
if pitch_var > 0:
cents = rng.uniform(-pitch_var, pitch_var)
rate = 2 ** (cents / 1200)
read_len = max(2, min(int(grain_samples * rate), src_len - src_pos))
grain_src = src[src_pos:src_pos + read_len]
x_old = numpy.linspace(0, 1, len(grain_src))
x_new = numpy.linspace(0, 1, grain_samples)
grain = numpy.interp(x_new, x_old, grain_src)
else:
end = min(src_pos + grain_samples, src_len)
grain = src[src_pos:end]
if len(grain) < grain_samples:
grain = numpy.pad(grain, (0, grain_samples - len(grain)))
# Apply window and mix
grain *= window[:len(grain)]
end = min(out_pos + len(grain), n_samples)
out[out_pos:end] += grain[:end - out_pos]
mx = numpy.abs(out).max()
if mx > 0:
out /= mx
return (peak * out).astype(numpy.int16)
[docs]
def pedal_steel_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Pedal steel guitar — the Nashville crying sound.
Sustained steel string with natural portamento character,
very smooth, lots of harmonics, and a singing quality from
the bar sliding on the strings.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Slow, singing vibrato — the bar wobbling on the strings
vib = hz * 0.002 * numpy.sin(2 * numpy.pi * 4.0 * t)
# Rich harmonics — steel bar gives a clear, singing tone
wave = numpy.zeros(n_samples, dtype=numpy.float64)
for n in range(1, 12):
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
amp = 1.0 / n * numpy.exp(-0.08 * n)
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * (f_n + vib * n) * t + phase)
# Long sustain envelope
wave *= numpy.exp(-0.8 * t)
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def theremin_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Theremin — pure sine with natural wobble.
The theremin's sound is a nearly pure sine wave with slight
pitch instability from hand position. The eerie, sci-fi sound
comes from this purity combined with continuous pitch.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
# Natural hand wobble — slightly irregular vibrato
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
wobble = hz * 0.004 * numpy.sin(2 * numpy.pi * 5.8 * t)
wobble += hz * 0.001 * rng.normal(0, 1, n_samples)
wave = numpy.sin(2 * numpy.pi * (hz + wobble) * t)
# Slight 2nd harmonic — real theremins aren't perfectly pure
wave += 0.08 * numpy.sin(2 * numpy.pi * (hz * 2 + wobble * 2) * t)
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def kalimba_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Kalimba/thumb piano — metal tines on a wooden body.
Bright, bell-like attack with inharmonic overtones from the
metal tines. The wooden resonator gives warmth underneath.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
# Metal tine modes — slightly inharmonic like marimba
wave = numpy.sin(2 * numpy.pi * hz * t) * 0.8
wave += numpy.sin(2 * numpy.pi * hz * 2.92 * t) * 0.25 * numpy.exp(-12 * t)
wave += numpy.sin(2 * numpy.pi * hz * 5.4 * t) * 0.1 * numpy.exp(-20 * t)
# Two-stage decay: bright attack dies fast, fundamental rings
decay = numpy.where(t < 0.1,
numpy.exp(-3 * t),
numpy.exp(-3 * 0.1) * numpy.exp(-1.5 * (t - 0.1)))
wave *= decay
# Wooden body resonance
import scipy.signal as _sig
for center, bw, gain in [(300, 100, 0.2), (600, 120, 0.15)]:
lo, hi = max(20, center - bw), min(SAMPLE_RATE // 2 - 1, center + bw)
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
wave += _sig.lfilter(bp, ap, wave) * gain
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def steel_drum_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Steel drum/pan — hammered metal with bright, ringing tone.
The steel pan has specific inharmonic partials from the
hand-hammered notes. Bright, tropical, bell-like.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
# Steel pan modes — distinctly metallic
wave = numpy.sin(2 * numpy.pi * hz * t) * 0.7
wave += numpy.sin(2 * numpy.pi * hz * 2.0 * t) * 0.4 * numpy.exp(-5 * t)
wave += numpy.sin(2 * numpy.pi * hz * 3.01 * t) * 0.25 * numpy.exp(-8 * t)
wave += numpy.sin(2 * numpy.pi * hz * 4.1 * t) * 0.15 * numpy.exp(-12 * t)
wave += numpy.sin(2 * numpy.pi * hz * 5.3 * t) * 0.08 * numpy.exp(-18 * t)
# Mallet impact
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
hit_len = min(int(SAMPLE_RATE * 0.008), n_samples)
hit = rng.uniform(-0.2, 0.2, hit_len) * numpy.exp(-numpy.linspace(0, 12, hit_len))
wave[:hit_len] += hit
# Two-stage decay
decay = numpy.where(t < 0.15,
numpy.exp(-4 * t),
numpy.exp(-4 * 0.15) * numpy.exp(-1.2 * (t - 0.15)))
wave *= decay
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def harmonium_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Harmonium — Indian pump organ, single free reed per note.
Unlike accordion (doubled musette reeds), the harmonium has one
reed per note — no beating, just a pure, nasal, reedy tone.
Constant bellows pressure, warm but slightly buzzy. The sound
of kirtan, qawwali, and devotional music.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Single reed — odd harmonics stronger (like clarinet but warmer)
wave = numpy.zeros(n_samples, dtype=numpy.float64)
for n in range(1, 12):
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
amp = (1.0 / n) * (1.0 if n % 2 == 1 else 0.5)
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * f_n * t + phase)
# Bellows pressure — gentle swell, slower than accordion
bellows = 0.9 + 0.1 * numpy.sin(2 * numpy.pi * 0.5 * t)
wave *= bellows
# Nasal character — slight midrange boost
import scipy.signal as _sig
center = min(1200, hz * 3)
lo = max(20, int(center - 300))
hi = min(SAMPLE_RATE // 2 - 1, int(center + 300))
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
nasal = _sig.lfilter(bp, ap, wave) * 0.2
wave += nasal
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def accordion_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Accordion — bellows-driven free reeds.
Two reeds per note slightly detuned (musette tuning) create
the characteristic beating/tremolo. Rich in harmonics from
the reed vibration.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Two reeds slightly detuned — musette beating
detune_cents = 8
hz2 = hz * (2 ** (detune_cents / 1200))
# Reed harmonics — rich, like a square-ish wave but warmer
wave = numpy.zeros(n_samples, dtype=numpy.float64)
for reed_hz in [hz, hz2]:
for n in range(1, 10):
f_n = reed_hz * n
if f_n >= SAMPLE_RATE / 2:
break
# Odd harmonics stronger (reed character)
amp = (1.0 / n) * (1.2 if n % 2 == 1 else 0.6)
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * f_n * t + phase)
wave *= 0.5 # normalize for two reeds
# Bellows pressure variation — slow amplitude swell
bellows = 0.85 + 0.15 * numpy.sin(2 * numpy.pi * 0.8 * t)
wave *= bellows
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def didgeridoo_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Didgeridoo — circular breathing drone through a wooden tube.
Deep fundamental with strong odd harmonics from the cylindrical
bore. The overtone singing technique creates shifting formants.
Buzzy, droning, primal.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Lip buzz source — rich, raw
phase = numpy.cumsum(2 * numpy.pi * hz / SAMPLE_RATE * numpy.ones(n_samples))
buzz = numpy.zeros(n_samples, dtype=numpy.float64)
for n in range(1, 15):
if hz * n >= SAMPLE_RATE / 2:
break
# Odd harmonics stronger (cylindrical bore, like clarinet)
amp = (1.0 / n) * (1.0 if n % 2 == 1 else 0.4)
buzz += amp * numpy.sin(phase * n + rng.uniform(0, 2 * numpy.pi))
# Shifting formant — the overtone singing effect
# Sweeps slowly between 500Hz and 1500Hz
formant_center = 800 + 400 * numpy.sin(2 * numpy.pi * 0.3 * t)
import scipy.signal as _sig
# Block-process the formant sweep
block = 2048
out = numpy.zeros(n_samples, dtype=numpy.float64)
for i in range(0, n_samples, block):
end = min(i + block, n_samples)
fc = formant_center[(i + end) // 2]
lo = max(20, int(fc - 300))
hi = min(SAMPLE_RATE // 2 - 1, int(fc + 300))
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
seg = _sig.lfilter(bp, ap, buzz[i:end])
out[i:end] = buzz[i:end] * 0.5 + seg * 0.5
else:
out[i:end] = buzz[i:end]
# Breath noise
breath = rng.normal(0, 0.04, n_samples)
out += breath
mx = numpy.abs(out).max()
if mx > 0:
out /= mx
return (peak * out).astype(numpy.int16)
[docs]
def bagpipe_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Bagpipes — chanter reed with constant drone pressure.
The chanter (melody pipe) uses a double reed like an oboe
but with more buzz and brightness. The constant air pressure
from the bag means no dynamics — always ff.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Chanter — all harmonics, bright and reedy
wave = numpy.zeros(n_samples, dtype=numpy.float64)
for n in range(1, 18):
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
# Peaked around harmonics 3-7 (the piercing brightness)
amp = (1.0 / n) * numpy.exp(-0.03 * (n - 5) ** 2)
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * f_n * t + phase)
# Reed buzz — more than oboe
reed = rng.normal(0, 0.08, n_samples)
import scipy.signal as _sig
lo = max(20, int(hz * 2))
hi = min(SAMPLE_RATE // 2 - 1, int(hz * 8))
if lo < hi:
br, ar = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
reed = _sig.lfilter(br, ar, reed).astype(numpy.float64) * 1.5
wave += reed
# Bag pressure wobble — very subtle
bag = 1.0 + 0.02 * numpy.sin(2 * numpy.pi * 1.5 * t)
wave *= bag
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def banjo_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Banjo — steel strings on a drum-head body.
The banjo's distinctive twang comes from the membrane head
(like a drum skin) instead of a wooden soundboard. This gives
a sharp attack, bright tone, and fast decay with a nasal,
metallic quality. The 5th string drone adds shimmer.
"""
period = int(SAMPLE_RATE / hz)
if period < 2:
period = 2
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Steel string — bright, sharp attack
buf = rng.uniform(-0.9, 0.9, period).astype(numpy.float64)
# Minimal filtering — banjo keeps the brightness
for k in range(period - 1):
buf[k] = 0.7 * buf[k] + 0.3 * buf[k + 1]
out = numpy.zeros(n_samples, dtype=numpy.float64)
for i in range(n_samples):
out[i] = buf[i % period]
next_idx = (i + 1) % period
# Moderate decay — drum head rings but shorter than guitar
buf[i % period] = 0.5 * (buf[i % period] + buf[next_idx]) * 0.9988
# Drum-head resonance — nasal, ringy, mid-frequency peaks
# The membrane head rings more than wood — that's the twang
import scipy.signal as _sig
for center, bw, gain in [(600, 200, 0.5), (1500, 300, 0.4), (3000, 500, 0.25)]:
lo = max(20, center - bw)
hi = min(SAMPLE_RATE // 2 - 1, center + bw)
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
out += _sig.lfilter(bp, ap, out) * gain
mx = numpy.abs(out).max()
if mx > 0:
out /= mx
return (peak * out).astype(numpy.int16)
[docs]
def mandolin_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Mandolin — paired steel strings, bright and ringing.
The mandolin has 4 courses of paired strings, tuned in unison.
The doubled strings create natural chorus. Bright attack from
the plectrum, small body with high-frequency resonance.
"""
period = int(SAMPLE_RATE / hz)
if period < 2:
period = 2
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Two strings per course — slightly detuned for natural chorus
buf1 = rng.uniform(-0.8, 0.8, period).astype(numpy.float64)
period2 = max(2, period + rng.integers(-1, 2))
buf2 = rng.uniform(-0.8, 0.8, period2).astype(numpy.float64)
# Light filtering — steel is brighter than nylon
for k in range(period - 1):
buf1[k] = 0.65 * buf1[k] + 0.35 * buf1[k + 1]
for k in range(period2 - 1):
buf2[k] = 0.65 * buf2[k] + 0.35 * buf2[k + 1]
out = numpy.zeros(n_samples, dtype=numpy.float64)
for i in range(n_samples):
s1 = buf1[i % period]
s2 = buf2[i % period2]
out[i] = s1 * 0.55 + s2 * 0.45
next1 = (i + 1) % period
buf1[i % period] = 0.5 * (s1 + buf1[next1]) * 0.9988
next2 = (i + 1) % period2
buf2[i % period2] = 0.5 * (s2 + buf2[next2]) * 0.9988
# Small bright body — higher resonance than guitar
import scipy.signal as _sig
for center, bw, gain in [(500, 120, 0.3), (1000, 200, 0.25), (2000, 300, 0.15)]:
lo = max(20, center - bw)
hi = min(SAMPLE_RATE // 2 - 1, center + bw)
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
out += _sig.lfilter(bp, ap, out) * gain
mx = numpy.abs(out).max()
if mx > 0:
out /= mx
return (peak * out).astype(numpy.int16)
[docs]
def ukulele_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Ukulele — nylon strings on a small resonant body.
Brighter and thinner than guitar, shorter sustain. The small
body gives a mid-heavy resonance (no deep bass). Nylon strings
have a softer, warmer attack than steel.
"""
period = int(SAMPLE_RATE / hz)
if period < 2:
period = 2
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Nylon string — soft noise
buf = rng.uniform(-0.5, 0.5, period).astype(numpy.float64)
for _ in range(5):
for k in range(period - 1):
buf[k] = 0.55 * buf[k] + 0.45 * buf[k + 1]
out = numpy.zeros(n_samples, dtype=numpy.float64)
for i in range(n_samples):
out[i] = buf[i % period]
next_idx = (i + 1) % period
buf[i % period] = 0.5 * (buf[i % period] + buf[next_idx]) * 0.998
# Small body resonance — mid-heavy, no deep bass
import scipy.signal as _sig
for center, bw, gain in [(350, 100, 0.35), (700, 150, 0.25), (1200, 200, 0.15)]:
lo = max(20, center - bw)
hi = min(SAMPLE_RATE // 2 - 1, center + bw)
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
out += _sig.lfilter(bp, ap, out) * gain
bl, al = _sig.butter(2, min(6000, hz * 12), btype='low', fs=SAMPLE_RATE)
out = _sig.lfilter(bl, al, out)
mx = numpy.abs(out).max()
if mx > 0:
out /= mx
return (peak * out).astype(numpy.int16)
[docs]
def acoustic_guitar_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Acoustic guitar — Karplus-Strong with wooden body resonance.
Models a steel string exciting a resonant wooden body:
1. Karplus-Strong plucked string (softer initial noise than pure KS)
2. Body resonance — bandpass filters at the guitar body's natural
frequencies (~100Hz air cavity, ~250Hz top plate, ~500Hz back)
3. Warmer, rounder attack than electric (fingers vs pickup)
"""
period = int(SAMPLE_RATE / hz)
if period < 2:
period = 2
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Softer initial noise — nylon/steel string, not a harsh burst
buf = rng.uniform(-0.8, 0.8, period).astype(numpy.float64)
# Warm the initial burst — lowpass the noise slightly
for j in range(2):
for k in range(period - 1):
buf[k] = 0.6 * buf[k] + 0.4 * buf[k + 1]
out = numpy.zeros(n_samples, dtype=numpy.float64)
# Karplus-Strong with moderate decay
for i in range(n_samples):
out[i] = buf[i % period]
next_idx = (i + 1) % period
buf[i % period] = 0.5 * (buf[i % period] + buf[next_idx]) * 0.9988
# Body resonance — three formant peaks modeling the guitar body
# These interact with the string harmonics to create the "woody" tone
resonances = numpy.zeros(n_samples, dtype=numpy.float64)
for center, bw, gain in [(110, 60, 0.4), (250, 80, 0.3), (500, 120, 0.2)]:
lo = max(20, center - bw)
hi = min(SAMPLE_RATE // 2 - 1, center + bw)
if lo < hi:
bp, ap = scipy.signal.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
resonances += scipy.signal.lfilter(bp, ap, out) * gain
out = out * 0.6 + resonances
# Gentle rolloff above 5kHz (no brightness of electric pickup)
bl, al = scipy.signal.butter(2, 5000, btype='low', fs=SAMPLE_RATE)
out = scipy.signal.lfilter(bl, al, out)
mx = numpy.abs(out).max()
if mx > 0:
out /= mx
return (peak * out).astype(numpy.int16)
[docs]
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)
[docs]
def sitar_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Sitar — Karplus-Strong with jawari bridge buzz and sympathetic strings.
The sitar's distinctive sound comes from three things:
1. The jawari (bridge) — a wide, curved bridge where the string
buzzes against the surface, creating rich harmonics and that
characteristic "zzz" tone. Modeled as a soft-clip nonlinearity
applied inside the delay loop.
2. Sympathetic strings (taraf) — strings that resonate in sympathy
with the played note, adding a shimmering halo.
3. Sharp mizrab (plectrum) attack with moderate decay — plucky,
not sustained like a bowed instrument.
"""
period = int(SAMPLE_RATE / hz)
if period < 2:
period = 2
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Initial pluck — bright metallic mizrab strike
buf = rng.uniform(-1.0, 1.0, period).astype(numpy.float64)
out = numpy.zeros(n_samples, dtype=numpy.float64)
# Two-pass Karplus-Strong for richer timbre:
# The sitar string has two decay rates — high frequencies die fast
# (like the initial "tang" of the mizrab), while the fundamental
# rings longer. We model this with a variable damping factor.
for i in range(n_samples):
sample = buf[i % period]
# Jawari bridge: gentle one-sided soft clip.
# String grazes the curved bridge on positive excursions,
# adding subtle even harmonics (the sitar "buzz").
if sample > 0.25:
sample = 0.25 + (sample - 0.25) * 0.5
out[i] = sample
# Variable damping: higher damping early (brightness fades),
# lower damping later (fundamental sustains).
# The 0.4/0.6 averaging weights give a brighter initial tone
# than the standard 0.5/0.5 Karplus-Strong.
next_idx = (i + 1) % period
decay = 0.9992 if i < SAMPLE_RATE * 0.3 else 0.9996
buf[i % period] = (0.4 * sample + 0.6 * buf[next_idx]) * decay
# Chikari shimmer — the 3 high drone strings that ring sympathetically
# These are tuned to the tonic (Sa) and its octave
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
chikari = (numpy.sin(2 * numpy.pi * hz * 2 * t) * 0.04 +
numpy.sin(2 * numpy.pi * hz * 3 * t) * 0.025)
chikari *= numpy.exp(-2.0 * t) # gentle fade
# Sympathetic taraf strings — very quiet harmonic halo
for harmonic in [2, 3, 4, 5]:
sym_hz = hz * harmonic
if sym_hz > SAMPLE_RATE / 2:
break
sym_period = max(2, int(SAMPLE_RATE / sym_hz))
if sym_period < n_samples:
chikari[sym_period:] += out[:-sym_period] * 0.04
out = out + chikari
# Normalize
mx = numpy.abs(out).max()
if mx > 0:
out /= mx
return (peak * out).astype(numpy.int16)
[docs]
def crotales_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Crotales — small tuned bronze discs struck with brass mallets.
Antique cymbals. Bright, crystalline, bell-like tone that rings
for a very long time. The partials are nearly harmonic (closer
to a bell than a bar) with strong upper harmonics that give
crotales their penetrating brilliance. Played in the octave
above written — they cut through any orchestra.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
wave = numpy.zeros(n_samples, dtype=numpy.float64)
# Bronze disc modes — nearly harmonic, very bright.
# Higher partials are stronger than in most percussion,
# which is what gives crotales their cutting brilliance.
# (ratio, amplitude, decay_rate)
disc_modes = [
(1.0, 1.0, 0.3), # fundamental — rings for ages
(2.0, 0.6, 0.4), # octave — strong
(3.01, 0.35, 0.6), # near-12th — slight inharmonicity
(4.03, 0.25, 0.9), # double octave
(5.06, 0.15, 1.3), # bright
(6.1, 0.08, 2.0), # shimmer
(8.15, 0.04, 3.0), # sparkle at the top
]
for ratio, amp, decay_rate in disc_modes:
f = hz * ratio
if f >= SAMPLE_RATE / 2:
break
phase = rng.uniform(0, 2 * numpy.pi)
mode_decay = numpy.exp(-decay_rate * t)
wave += amp * numpy.sin(2 * numpy.pi * f * t + phase) * mode_decay
# Hard mallet strike — brass on bronze, bright transient
strike_len = min(int(SAMPLE_RATE * 0.002), n_samples)
strike_t = numpy.linspace(0, 1, strike_len)
strike = 0.5 * numpy.sin(2 * numpy.pi * hz * 8 * strike_t) * numpy.exp(-strike_t * 25)
wave[:strike_len] += strike
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def tingsha_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Tingsha — two small Tibetan cymbals clashed together on a cord.
When the pair strikes, both discs ring simultaneously at slightly
different frequencies (no two are identical), producing a bright
ping with pronounced beating. The sound is thinner and higher
than a singing bowl — a clear, cutting tone that fades over a
few seconds. The two-disc interference is the whole character.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Two discs at slightly different pitches — this IS the tingsha sound
detune = hz * 0.008 # ~14 cents apart, creates ~3-4 Hz beat at middle C
disc_a = numpy.sin(2 * numpy.pi * (hz - detune) * t)
disc_b = numpy.sin(2 * numpy.pi * (hz + detune) * t + rng.uniform(0, 2 * numpy.pi))
wave = (disc_a + disc_b) * 0.5
# Upper partials — both discs, slightly different inharmonicity
for ratio, amp, dec in [(2.72, 0.3, 5.0), (5.1, 0.12, 10.0), (8.3, 0.05, 18.0)]:
if hz * ratio >= SAMPLE_RATE / 2:
break
p1 = rng.uniform(0, 2 * numpy.pi)
p2 = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * hz * ratio * 0.998 * t + p1) * numpy.exp(-dec * t)
wave += amp * numpy.sin(2 * numpy.pi * hz * ratio * 1.002 * t + p2) * numpy.exp(-dec * t)
# Decay — medium ring, not as long as a singing bowl
decay = numpy.exp(-1.8 * t)
wave *= decay
# Clash transient — metal on metal, sharper than a mallet hit
clash_len = min(int(SAMPLE_RATE * 0.003), n_samples)
clash = rng.uniform(-0.4, 0.4, clash_len).astype(numpy.float64)
clash *= numpy.exp(-numpy.linspace(0, 20, clash_len))
wave[:clash_len] += clash
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def singing_bowl_strike_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Singing bowl strike — mallet hit that excites all modes at once.
The initial hit produces a bright chirp as the higher partials
ring momentarily, then the sound settles into the fundamental
with slow beating. Higher modes decay fast, the fundamental
rings for seconds.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
wave = numpy.zeros(n_samples, dtype=numpy.float64)
# Bowl modal ratios — measured from real Himalayan bowls.
# (ratio, amplitude, decay_rate, beat_hz)
# The beat_hz is the frequency split between near-degenerate
# mode pairs — this is what makes the bowl shimmer.
bowl_modes = [
(1.0, 1.0, 0.4, 0.3), # fundamental — very slow beat, long ring
(2.71, 0.45, 0.8, 0.6), # second partial
(5.12, 0.22, 1.6, 0.9), # third — prominent in the chirp
(8.26, 0.12, 3.5, 1.2), # fourth — fast decay, bright
(12.1, 0.06, 6.0, 1.5), # fifth — just a flash
]
for ratio, amp, decay_rate, beat_hz in bowl_modes:
f = hz * ratio
if f >= SAMPLE_RATE / 2:
break
phase1 = rng.uniform(0, 2 * numpy.pi)
phase2 = rng.uniform(0, 2 * numpy.pi)
f_split = beat_hz / 2.0
mode_decay = numpy.exp(-decay_rate * t)
tone1 = numpy.sin(2 * numpy.pi * (f - f_split) * t + phase1)
tone2 = numpy.sin(2 * numpy.pi * (f + f_split) * t + phase2)
wave += amp * (tone1 + tone2) * 0.5 * mode_decay
# Strike chirp — higher partials ring briefly on impact
chirp_len = min(int(SAMPLE_RATE * 0.04), n_samples)
chirp_t = numpy.linspace(0, 1, chirp_len)
chirp_freq = hz * (3.0 - 2.0 * chirp_t)
chirp_phase = numpy.cumsum(chirp_freq) / SAMPLE_RATE * 2 * numpy.pi
chirp = 0.6 * numpy.sin(chirp_phase) * numpy.exp(-chirp_t * 8)
wave[:chirp_len] += chirp
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
[docs]
def singing_bowl_ring_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Singing bowl ring — sustained rubbing around the rim with a mallet.
When you rub the rim, the bowl builds up slowly as the mallet
continuously feeds energy into the resonance. The fundamental
dominates with strong beating. Higher partials come and go as
the mallet catches different modes. The sound has a pulsing,
breathing quality from the slow amplitude modulation.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
wave = numpy.zeros(n_samples, dtype=numpy.float64)
# Rim rubbing excites the fundamental most, but upper modes
# shimmer in as the mallet catches them
bowl_modes = [
(1.0, 1.0, 0.15, 0.4), # fundamental — very slow decay, gentle beat
(2.71, 0.35, 0.35, 0.7), # second — stronger presence
(5.12, 0.18, 0.7, 1.0), # third — audible shimmer
(8.26, 0.08, 1.2, 1.3), # fourth — bright ring
]
for ratio, amp, decay_rate, beat_hz in bowl_modes:
f = hz * ratio
if f >= SAMPLE_RATE / 2:
break
phase1 = rng.uniform(0, 2 * numpy.pi)
phase2 = rng.uniform(0, 2 * numpy.pi)
f_split = beat_hz / 2.0
# Slow build-up envelope — rim rubbing takes time to excite
buildup = 1.0 - numpy.exp(-2.0 * t / (ratio * 0.5))
# Gentle decay after the buildup
sustain_env = buildup * numpy.exp(-decay_rate * numpy.maximum(0, t - 0.5))
tone1 = numpy.sin(2 * numpy.pi * (f - f_split) * t + phase1)
tone2 = numpy.sin(2 * numpy.pi * (f + f_split) * t + phase2)
wave += amp * (tone1 + tone2) * 0.5 * sustain_env
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
def _apply_envelope(samples, attack, decay, sustain, release, sample_rate=SAMPLE_RATE):
"""Apply an ADSR amplitude envelope to a sample array.
Args:
samples: NumPy array of audio samples.
attack: Attack time in seconds.
decay: Decay time in seconds.
sustain: Sustain level (0.0 to 1.0).
release: Release time in seconds.
sample_rate: Sample rate in Hz.
Returns:
NumPy float32 array with envelope applied.
"""
n = len(samples)
envelope = numpy.ones(n, dtype=numpy.float32)
a_samples = int(attack * sample_rate)
d_samples = int(decay * sample_rate)
r_samples = int(release * sample_rate)
# Clamp to available length
a_samples = min(a_samples, n)
r_samples = min(r_samples, max(0, n - a_samples))
d_samples = min(d_samples, max(0, n - a_samples - r_samples))
# Attack: 0 → 1
if a_samples > 0:
envelope[:a_samples] = numpy.linspace(0.0, 1.0, a_samples)
# Decay: 1 → sustain
if d_samples > 0:
d_start = a_samples
envelope[d_start:d_start + d_samples] = numpy.linspace(1.0, sustain, d_samples)
# Sustain: hold at sustain level
s_start = a_samples + d_samples
s_end = n - r_samples
if s_end > s_start:
envelope[s_start:s_end] = sustain
# Release: sustain → 0
if r_samples > 0:
envelope[n - r_samples:] = numpy.linspace(sustain, 0.0, r_samples)
return samples.astype(numpy.float32) * envelope
[docs]
class Envelope(Enum):
"""ADSR envelope presets for shaping note amplitude over time.
Each preset is a tuple of ``(attack, decay, sustain, release)``
in seconds (sustain is a 0–1 level, not a time).
Example::
>>> play(tone, envelope=Envelope.PIANO)
>>> play(chord, envelope=Envelope.PAD, t=3_000)
"""
NONE = (0.0, 0.0, 1.0, 0.0)
PIANO = (0.005, 0.1, 0.4, 0.15)
ORGAN = (0.02, 0.0, 1.0, 0.02)
PLUCK = (0.002, 0.15, 0.0, 0.1)
PAD = (0.4, 0.2, 0.7, 0.5)
STRINGS = (0.15, 0.1, 0.8, 0.3)
BOWED = (0.04, 0.08, 0.75, 0.25)
BELL = (0.001, 0.3, 0.0, 0.5)
MALLET = (0.002, 0.05, 0.6, 0.8)
STACCATO = (0.005, 0.05, 0.0, 0.02)
def _play_for(sample_wave, ms):
"""Play the given NumPy sample array through the speakers."""
normalized_wave = sample_wave.astype(numpy.float32) / SAMPLE_PEAK
_sd = _get_sd()
try:
_sd.play(normalized_wave, SAMPLE_RATE)
_sd.wait()
except KeyboardInterrupt:
_sd.stop()
[docs]
class Synth(Enum):
"""Waveform types for synthesis.
Each waveform has a distinct timbre based on its harmonic content:
- **SINE** — pure tone, no harmonics. Smooth and clean.
- **SAW** — all harmonics at 1/n amplitude. Bright, buzzy, aggressive.
- **TRIANGLE** — odd harmonics at 1/n². Mellow, woody, hollow.
- **SQUARE** — odd harmonics at 1/n. Hollow, chiptune, 8-bit.
- **PULSE** — variable duty cycle square. Nasal, reedy (NES sound).
- **FM** — frequency modulation synthesis. Bell-like, metallic, DX7.
- **NOISE** — white noise, unpitched. Percussion, wind, texture.
- **SUPERSAW** — 7 detuned saws. Fat, shimmery, trance/EDM pads.
"""
SINE = "sine"
SAW = "saw"
TRIANGLE = "triangle"
SQUARE = "square"
PULSE = "pulse"
FM = "fm"
NOISE = "noise"
SUPERSAW = "supersaw"
PWM_SLOW = "pwm_slow"
PWM_FAST = "pwm_fast"
PLUCK = "pluck_synth"
ORGAN = "organ_synth"
STRINGS = "strings_synth"
PIANO = "piano_synth"
BASS_GUITAR = "bass_guitar_synth"
FLUTE = "flute_synth"
TRUMPET = "trumpet_synth"
CLARINET = "clarinet_synth"
MARIMBA = "marimba_synth"
OBOE = "oboe_synth"
HARPSICHORD = "harpsichord_synth"
CELLO = "cello_synth"
HARP = "harp_synth"
UPRIGHT_BASS = "upright_bass_synth"
TIMPANI = "timpani_synth"
SAXOPHONE = "saxophone_synth"
GRANULAR = "granular_synth"
VOCAL = "vocal_synth"
PEDAL_STEEL = "pedal_steel_synth"
THEREMIN = "theremin_synth"
KALIMBA = "kalimba_synth"
STEEL_DRUM = "steel_drum_synth"
HARMONIUM = "harmonium_synth"
ACCORDION = "accordion_synth"
DIDGERIDOO = "didgeridoo_synth"
BAGPIPE = "bagpipe_synth"
BANJO = "banjo_synth"
MANDOLIN = "mandolin_synth"
UKULELE = "ukulele_synth"
ACOUSTIC_GUITAR = "acoustic_guitar_synth"
SITAR = "sitar_synth"
ELECTRIC_GUITAR = "electric_guitar_synth"
CROTALES = "crotales_synth"
TINGSHA = "tingsha_synth"
SINGING_BOWL_STRIKE = "singing_bowl_strike_synth"
SINGING_BOWL_RING = "singing_bowl_ring_synth"
RHODES = "rhodes_synth"
WURLITZER = "wurlitzer_synth"
VIBRAPHONE = "vibraphone_synth"
PIPE_ORGAN = "pipe_organ_synth"
CHOIR = "choir_synth"
MELLOTRON = "mellotron_synth"
HARD_SYNC = "hard_sync"
RING_MOD = "ring_mod"
WAVEFOLD = "wavefold"
DRIFT = "drift"
def __call__(self, hz, **kwargs):
"""Make Synth members callable — dispatches to the wave function."""
return _SYNTH_FUNCTIONS[self.value](hz, **kwargs)
_SYNTH_FUNCTIONS = {
"sine": sine_wave, "saw": sawtooth_wave, "triangle": triangle_wave,
"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, "piano_synth": piano_wave, "rhodes_synth": rhodes_wave,
"wurlitzer_synth": wurlitzer_wave, "vibraphone_synth": vibraphone_wave,
"pipe_organ_synth": pipe_organ_wave, "choir_synth": choir_wave,
"bass_guitar_synth": bass_guitar_wave, "flute_synth": flute_wave,
"trumpet_synth": trumpet_wave, "clarinet_synth": clarinet_wave,
"marimba_synth": marimba_wave, "oboe_synth": oboe_wave,
"harpsichord_synth": harpsichord_wave, "cello_synth": cello_wave,
"harp_synth": harp_wave, "upright_bass_synth": upright_bass_wave,
"timpani_synth": timpani_wave, "saxophone_synth": saxophone_wave,
"granular_synth": granular_wave, "vocal_synth": vocal_wave,
"pedal_steel_synth": pedal_steel_wave, "theremin_synth": theremin_wave,
"kalimba_synth": kalimba_wave, "steel_drum_synth": steel_drum_wave,
"harmonium_synth": harmonium_wave, "accordion_synth": accordion_wave, "didgeridoo_synth": didgeridoo_wave,
"bagpipe_synth": bagpipe_wave,
"banjo_synth": banjo_wave, "mandolin_synth": mandolin_wave,
"ukulele_synth": ukulele_wave,
"acoustic_guitar_synth": acoustic_guitar_wave,
"sitar_synth": sitar_wave, "electric_guitar_synth": electric_guitar_wave,
"crotales_synth": crotales_wave,
"tingsha_synth": tingsha_wave,
"singing_bowl_strike_synth": singing_bowl_strike_wave,
"singing_bowl_ring_synth": singing_bowl_ring_wave,
"mellotron_synth": mellotron_wave,
"hard_sync": hard_sync_wave, "ring_mod": ring_mod_wave,
"wavefold": wavefold_wave, "drift": drift_wave,
}
def _render(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
envelope=Envelope.PIANO, **synth_kw):
"""Render a tone or chord to a NumPy sample array.
Args:
tone_or_chord: A :class:`Tone` or :class:`Chord` to render.
temperament: Tuning temperament (``"equal"``, ``"pythagorean"``,
or ``"meantone"``).
synth: Waveform type — ``Synth.SINE``, ``Synth.SAW``, or
``Synth.TRIANGLE``.
t: Duration in milliseconds.
envelope: ADSR envelope preset. Use ``Envelope.NONE`` for raw
output (old behavior).
**synth_kw: Extra keyword arguments forwarded to the synth wave
function (e.g. ``tape="flute"`` for Mellotron,
``slave_ratio=2.0`` for Hard Sync).
Returns:
A NumPy int16 array of audio samples.
"""
n_samples = int(SAMPLE_RATE * t / 1_000)
if isinstance(tone_or_chord, Tone):
waves = [synth(tone_or_chord.pitch(temperament=temperament), n_samples=n_samples, **synth_kw)]
else:
waves = [
synth(tone.pitch(temperament=temperament), n_samples=n_samples, **synth_kw)
for tone in tone_or_chord.tones
]
mixed = sum(waves)
# Apply ADSR envelope
attack, decay, sustain, release = envelope.value
if attack > 0 or decay > 0 or sustain < 1.0 or release > 0:
mixed = _apply_envelope(mixed, attack, decay, sustain, release)
return mixed
[docs]
def play(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
envelope=Envelope.PIANO, **synth_kw):
"""Play a tone or chord through the speakers.
Args:
tone_or_chord: A :class:`Tone` or :class:`Chord` to play.
temperament: Tuning temperament (``"equal"``, ``"pythagorean"``,
or ``"meantone"``).
synth: Waveform type — ``Synth.SINE``, ``Synth.SAW``, or
``Synth.TRIANGLE``.
t: Duration in milliseconds (default 1000).
envelope: ADSR envelope preset (default ``Envelope.PIANO``).
Use ``Envelope.NONE`` for raw waveform.
**synth_kw: Extra keyword arguments forwarded to the synth wave
function (e.g. ``tape="flute"`` for Mellotron).
Example::
>>> play(Tone.from_string("A4"), t=1_000)
>>> play(Chord.from_name("Am7"), synth=Synth.TRIANGLE, t=2_000)
>>> play(tone, envelope=Envelope.PAD, t=3_000)
>>> play(tone, synth=Synth.MELLOTRON, tape="choir", t=2_000)
"""
_play_for(_render(tone_or_chord, temperament=temperament, synth=synth,
t=t, envelope=envelope, **synth_kw), ms=t)
[docs]
def save(tone_or_chord, path, temperament="equal", synth=Synth.SINE, t=1_000,
envelope=Envelope.PIANO, **synth_kw):
"""Render a tone or chord and save it as a WAV file.
Args:
tone_or_chord: A :class:`Tone` or :class:`Chord` to render.
path: Output file path (e.g. ``"chord.wav"``).
temperament: Tuning temperament.
synth: Waveform type.
t: Duration in milliseconds (default 1000).
envelope: ADSR envelope preset (default ``Envelope.PIANO``).
**synth_kw: Extra keyword arguments forwarded to the synth wave
function.
Example::
>>> save(Chord.from_name("C"), "c_major.wav", t=2_000)
>>> save(tone, "bell.wav", envelope=Envelope.BELL, t=3_000)
"""
import scipy.io.wavfile
samples = _render(tone_or_chord, temperament=temperament, synth=synth,
t=t, envelope=envelope, **synth_kw)
normalized = samples.astype(numpy.float32) / SAMPLE_PEAK
# Convert to 16-bit PCM
pcm = (normalized * 32767).astype(numpy.int16)
scipy.io.wavfile.write(path, SAMPLE_RATE, pcm)
[docs]
def play_progression(chords, *, t=1000, synth=Synth.SINE, gap=100,
envelope=Envelope.PIANO):
"""Play a list of chords in sequence.
Args:
chords: List of Chord objects to play in order.
t: Duration of each chord in milliseconds.
synth: Waveform type (Synth.SINE, etc). Defaults to sine.
gap: Silence between chords in milliseconds.
envelope: ADSR envelope preset (default ``Envelope.PIANO``).
Example::
>>> from pytheory import Key, play_progression
>>> chords = Key("C", "major").progression("I", "V", "vi", "IV")
>>> play_progression(chords, t=800)
>>> play_progression(chords, t=2000, envelope=Envelope.PAD)
"""
for i, chord in enumerate(chords):
play(chord, synth=synth, t=t, envelope=envelope)
if gap > 0 and i < len(chords) - 1:
time.sleep(gap / 1000.0)
# ── Drum synthesis ──────────────────────────────────────────────────────────
def _noise(n_samples):
"""White noise array."""
return numpy.random.uniform(-1.0, 1.0, n_samples).astype(numpy.float32)
# ── Cached helpers for hot paths ──────────────────────────────────────────
_time_cache = {}
def _get_time_array(n_samples):
"""Cached time array — avoids reallocation on every synth call."""
if n_samples not in _time_cache:
_time_cache[n_samples] = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
return _time_cache[n_samples]
def _sine_f32(hz, n_samples):
"""Float32 sine wave, normalized to ±1."""
return numpy.sin(2 * numpy.pi * hz * _get_time_array(n_samples))
_decay_cache = {}
def _exp_decay(n_samples, decay_rate):
"""Exponential decay envelope from 1→0. Cached."""
key = (n_samples, decay_rate)
if key not in _decay_cache:
_decay_cache[key] = numpy.exp(-decay_rate * _get_time_array(n_samples))
return _decay_cache[key]
def _synth_kick(n_samples):
"""Synthesize a kick drum: 808-style sine with pitch sweep + transient punch."""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Pitch sweeps from 200 Hz down to 45 Hz — fast sweep for punch
freq = 45 + 155 * numpy.exp(-50 * t)
phase = 2 * numpy.pi * numpy.cumsum(freq) / SAMPLE_RATE
# Main body with longer sustain
body = numpy.sin(phase) * _exp_decay(n_samples, 6)
# Hard transient click — the "beater" hitting the head
click_len = min(300, n_samples)
click = _noise(click_len) * _exp_decay(click_len, 100)
body[:click_len] += click * 0.5
# Sub thump — a brief low sine for chest punch
sub_len = min(int(SAMPLE_RATE * 0.08), n_samples)
sub = _sine_f32(50, sub_len) * _exp_decay(sub_len, 20)
body[:sub_len] += sub * 0.4
# Soft saturation for warmth and presence
body = numpy.tanh(body * 1.5) / 1.5
return body
def _synth_snare(n_samples):
"""Synthesize a snare: pitched body + noise snap + transient click."""
# Body: 220 Hz (was 180) — brighter, more present
body = _sine_f32(220, n_samples) * _exp_decay(n_samples, 25) * 0.5
# Noise rattle: faster decay for snap (was 12)
noise = _noise(n_samples) * _exp_decay(n_samples, 20) * 0.8
# Transient click — the stick hitting the head
click_len = min(150, n_samples)
click = _noise(click_len) * _exp_decay(click_len, 120)
body[:click_len] += click * 0.4
# Soft saturation for presence and density
return numpy.tanh(body + noise)
def _synth_hat_closed(n_samples):
"""Closed hi-hat: short, crisp, metallic."""
n = min(n_samples, int(SAMPLE_RATE * 0.03)) # 30ms (was 50ms)
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
# Metallic harmonics — inharmonic frequencies that make cymbals shimmer
metallic = (numpy.sin(2 * numpy.pi * 6000 * t) * 0.3 +
numpy.sin(2 * numpy.pi * 8500 * t) * 0.2 +
numpy.sin(2 * numpy.pi * 12000 * t) * 0.15)
noise = _noise(n) * 0.6
wave = (metallic + noise) * _exp_decay(n, 100) # fast decay (was 60)
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = wave
return out
def _synth_hat_open(n_samples):
"""Open hi-hat: bright, metallic, controlled decay."""
n = min(n_samples, int(SAMPLE_RATE * 0.15)) # 150ms (was 250ms)
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
metallic = (numpy.sin(2 * numpy.pi * 6000 * t) * 0.3 +
numpy.sin(2 * numpy.pi * 8500 * t) * 0.2 +
numpy.sin(2 * numpy.pi * 12000 * t) * 0.15)
noise = _noise(n) * 0.5
wave = (metallic + noise) * _exp_decay(n, 18) # tighter (was 12)
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = wave
return out
def _synth_clap(n_samples):
"""Handclap: 808-style layered bursts with filtered tail."""
wave = numpy.zeros(n_samples, dtype=numpy.float32)
# Multiple hands hitting slightly apart — the 808 clap sound
for offset_ms in [0, 8, 16, 24, 30]:
start = int(offset_ms * SAMPLE_RATE / 1000)
burst_len = min(int(SAMPLE_RATE * 0.015), n_samples - start)
if burst_len > 0:
burst = _noise(burst_len) * _exp_decay(burst_len, 60)
wave[start:start + burst_len] += burst * 0.5
# Filtered noise tail — bandpassed for that snappy clap character
tail_len = min(int(SAMPLE_RATE * 0.12), n_samples)
tail = _noise(tail_len) * _exp_decay(tail_len, 22) * 0.4
wave[:tail_len] += tail
# Slight saturation for presence
return numpy.tanh(wave * 1.3)
def _synth_rimshot(n_samples):
"""Rimshot: bright attack + pitched ring, like a stick hitting the rim and head."""
n = min(n_samples, int(SAMPLE_RATE * 0.05))
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
# Two pitched components — the rim and the head resonate together
rim = _sine_f32(1200, n) * _exp_decay(n, 50) * 0.6
head = _sine_f32(400, n) * _exp_decay(n, 35) * 0.4
# Bright transient click
click = _noise(min(80, n)) * _exp_decay(min(80, n), 150) * 0.5
wave = rim + head
wave[:len(click)] += click
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = numpy.tanh(wave * 1.2)
return out
def _synth_tom(hz, n_samples):
"""Tom: pitched membrane with body resonance and attack transient."""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Pitch sweep — higher pitch drops to target (stick impact)
freq = hz + 60 * numpy.exp(-35 * t)
phase = 2 * numpy.pi * numpy.cumsum(freq) / SAMPLE_RATE
body = numpy.sin(phase) * _exp_decay(n_samples, 5) * 0.8
# Second harmonic for fullness
body += numpy.sin(phase * 1.5) * _exp_decay(n_samples, 8) * 0.2
# Attack transient — the stick hitting the head
click_len = min(200, n_samples)
click = _noise(click_len) * _exp_decay(click_len, 80) * 0.35
body[:click_len] += click
return numpy.tanh(body * 1.1)
def _synth_crash(n_samples):
"""Crash cymbal: complex metallic noise with inharmonic partials."""
n = min(n_samples, int(SAMPLE_RATE * 2.0))
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
# Metallic partials — inharmonic frequencies that make cymbals shimmer
wave = (numpy.sin(2 * numpy.pi * 4200 * t) * 0.15 +
numpy.sin(2 * numpy.pi * 5800 * t) * 0.12 +
numpy.sin(2 * numpy.pi * 7300 * t) * 0.10 +
numpy.sin(2 * numpy.pi * 9100 * t) * 0.08 +
numpy.sin(2 * numpy.pi * 11500 * t) * 0.05)
# Noise layer for body
noise = _noise(n) * 0.4
wave = (wave + noise) * _exp_decay(n, 2.5)
# Bright attack burst
attack_len = min(int(SAMPLE_RATE * 0.01), n)
wave[:attack_len] += _noise(attack_len) * 0.6
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = wave
return out
def _synth_ride(n_samples):
"""Ride cymbal: sustained metallic ring with stick definition."""
n = min(n_samples, int(SAMPLE_RATE * 0.8))
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
# Inharmonic partials — the shimmer
ring = (numpy.sin(2 * numpy.pi * 3200 * t) * 0.2 +
numpy.sin(2 * numpy.pi * 4800 * t) * 0.15 +
numpy.sin(2 * numpy.pi * 6700 * t) * 0.1 +
numpy.sin(2 * numpy.pi * 9200 * t) * 0.06)
ring *= _exp_decay(n, 5)
# Stick click — the initial "ting"
click_len = min(int(SAMPLE_RATE * 0.005), n)
click = _noise(click_len) * 0.5
ring[:click_len] += click
# Subtle noise wash
noise = _noise(n) * _exp_decay(n, 12) * 0.1
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = ring + noise
return out
def _synth_ride_bell(n_samples):
"""Ride bell: brighter, more sustain, pronounced ping."""
n = min(n_samples, int(SAMPLE_RATE * 1.0))
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
# Stronger fundamental + harmonics
ring = (numpy.sin(2 * numpy.pi * 2800 * t) * 0.35 +
numpy.sin(2 * numpy.pi * 4100 * t) * 0.25 +
numpy.sin(2 * numpy.pi * 5600 * t) * 0.15 +
numpy.sin(2 * numpy.pi * 7800 * t) * 0.08)
ring *= _exp_decay(n, 3.5)
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = ring
return out
def _synth_cowbell(n_samples):
"""Cowbell: 808-style — two detuned square-ish tones with bandpass character."""
n = min(n_samples, int(SAMPLE_RATE * 0.25))
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
# Two inharmonic tones — the 808 cowbell frequencies
tone1 = numpy.tanh(numpy.sin(2 * numpy.pi * 540 * t) * 2) * 0.5
tone2 = numpy.tanh(numpy.sin(2 * numpy.pi * 800 * t) * 2) * 0.4
wave = (tone1 + tone2) * _exp_decay(n, 14)
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = wave
return out
def _synth_clave(n_samples):
"""Clave: sharp wooden click — two resonant frequencies."""
n = min(n_samples, int(SAMPLE_RATE * 0.02))
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
# Two wood resonances
wave = (_sine_f32(2500, n) * 0.6 + _sine_f32(3800, n) * 0.3)
wave *= _exp_decay(n, 120)
# Hard transient
click = _noise(min(30, n)) * _exp_decay(min(30, n), 200) * 0.4
wave[:len(click)] += click
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = wave
return out
def _synth_conga(hz, n_samples):
"""Conga/bongo: pitched membrane with slap transient and body resonance."""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Pitch drops from impact
freq = hz + 80 * numpy.exp(-30 * t)
phase = 2 * numpy.pi * numpy.cumsum(freq) / SAMPLE_RATE
body = numpy.sin(phase) * _exp_decay(n_samples, 8) * 0.7
# Second mode — the shell resonance
body += numpy.sin(phase * 1.6) * _exp_decay(n_samples, 12) * 0.2
# Slap — the hand hitting the skin
slap_len = min(int(SAMPLE_RATE * 0.008), n_samples)
slap = _noise(slap_len) * _exp_decay(slap_len, 100) * 0.5
body[:slap_len] += slap
return numpy.tanh(body * 1.1)
def _synth_shaker(n_samples):
"""Shaker/maracas: filtered noise with attack transient."""
n = min(n_samples, int(SAMPLE_RATE * 0.06))
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
# Noise shaped with an attack bump
env = numpy.exp(-40 * t) + 0.3 * numpy.exp(-8 * t)
wave = _noise(n) * env * 0.5
# Add high-frequency content for sparkle
wave += numpy.sin(2 * numpy.pi * 8000 * t) * _exp_decay(n, 60) * 0.15
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = wave
return out
def _synth_tambourine(n_samples):
"""Tambourine: jingle metal + noise body."""
n = min(n_samples, int(SAMPLE_RATE * 0.2))
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
# Multiple jingle frequencies — each zil is slightly different
jingle = (numpy.sin(2 * numpy.pi * 6500 * t) * 0.15 +
numpy.sin(2 * numpy.pi * 7800 * t) * 0.12 +
numpy.sin(2 * numpy.pi * 9200 * t) * 0.1 +
numpy.sin(2 * numpy.pi * 11000 * t) * 0.08)
jingle *= _exp_decay(n, 12)
# Noise body — the shake
noise = _noise(n) * _exp_decay(n, 18) * 0.3
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = jingle + noise
return out
def _synth_timbale(hz, n_samples):
"""Timbale: bright metallic shell ring with sharp attack."""
n = min(n_samples, int(SAMPLE_RATE * 0.25))
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
# Fundamental + inharmonic overtones (metal shell)
wave = (_sine_f32(hz, n) * 0.5 +
numpy.sin(2 * numpy.pi * hz * 2.3 * t) * 0.25 +
numpy.sin(2 * numpy.pi * hz * 3.7 * t) * 0.12)
wave *= _exp_decay(n, 12)
# Sharp stick attack
click_len = min(60, n)
wave[:click_len] += _noise(click_len) * _exp_decay(click_len, 150) * 0.4
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = wave
return out
def _synth_agogo(hz, n_samples):
"""Agogo bell: two-tone metallic ring with sustain."""
n = min(n_samples, int(SAMPLE_RATE * 0.4))
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
# Two resonant modes — the bell shape creates inharmonic partials
wave = (numpy.sin(2 * numpy.pi * hz * t) * 0.5 +
numpy.sin(2 * numpy.pi * hz * 1.48 * t) * 0.3 +
numpy.sin(2 * numpy.pi * hz * 2.15 * t) * 0.12)
wave *= _exp_decay(n, 7)
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = wave
return out
def _synth_tabla_na(n_samples):
"""Tabla Na — sharp dayan (wooden shell) rim strike.
The goatskin head struck near the rim + syahi edge. Wooden shell
gives a dry, snappy resonance. Membrane thump is the foundation.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Goatskin membrane thump — bandpass filtered noise for drum body
thump_len = min(int(SAMPLE_RATE * 0.05), n_samples)
thump_raw = _noise(thump_len)
# Bandpass 200-800 Hz — goatskin membrane character
if thump_len > 20:
bl, al = scipy.signal.butter(2, [200, 800], btype='band', fs=SAMPLE_RATE)
thump_padded = numpy.pad(thump_raw, (0, max(0, n_samples - thump_len)))
thump = scipy.signal.lfilter(bl, al, thump_padded)[:thump_len]
else:
thump = thump_raw
thump *= _exp_decay(thump_len, 40) * 0.8
# Wooden shell resonance — short, dry
wood = numpy.sin(2 * numpy.pi * 800 * t[:thump_len]) * _exp_decay(thump_len, 50) * 0.2
thump[:len(wood)] += wood
# Syahi pitch ring on top
ring = numpy.sin(2 * numpy.pi * 330 * t) * _exp_decay(n_samples, 9) * 0.6
ring2 = numpy.sin(2 * numpy.pi * 680 * t) * 0.3 * _exp_decay(n_samples, 12)
ring3 = numpy.sin(2 * numpy.pi * 1050 * t) * 0.15 * _exp_decay(n_samples, 16)
# Sharp finger attack
click_len = min(100, n_samples)
click = _noise(click_len) * _exp_decay(click_len, 250) * 0.7
result = ring + ring2 + ring3
result[:thump_len] += thump
result[:click_len] += click
return numpy.tanh(result * 1.4)
def _synth_tabla_tin(n_samples):
"""Tabla Tin/Tun — open dayan ring.
Full open stroke on the wooden dayan. Goatskin membrane body with
long syahi ring. The most "singing" dayan sound.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Membrane body — fuller than Na
thump_len = min(int(SAMPLE_RATE * 0.06), n_samples)
thump_raw = _noise(thump_len)
if thump_len > 20:
bl, al = scipy.signal.butter(2, [150, 600], btype='band', fs=SAMPLE_RATE)
thump_padded = numpy.pad(thump_raw, (0, max(0, n_samples - thump_len)))
thump = scipy.signal.lfilter(bl, al, thump_padded)[:thump_len]
else:
thump = thump_raw
thump *= _exp_decay(thump_len, 30) * 0.6
# Long singing ring
ring = numpy.sin(2 * numpy.pi * 340 * t) * _exp_decay(n_samples, 5) * 0.7
ring2 = numpy.sin(2 * numpy.pi * 700 * t) * 0.35 * _exp_decay(n_samples, 6)
ring3 = numpy.sin(2 * numpy.pi * 1060 * t) * 0.2 * _exp_decay(n_samples, 8)
click_len = min(80, n_samples)
click = _noise(click_len) * _exp_decay(click_len, 180) * 0.35
result = ring + ring2 + ring3
result[:thump_len] += thump
result[:click_len] += click
return numpy.tanh(result * 1.1)
def _synth_tabla_ge(n_samples):
"""Tabla Ge/Ghe — deep bayan (metal/copper shell) bass stroke.
Palm strike on the metal bayan. The copper shell gives a rounder,
more resonant bass with metallic sustain. Goatskin membrane provides
the initial thud, then the metal body resonates.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Goatskin membrane thud — heavy, bassy
thump_len = min(int(SAMPLE_RATE * 0.07), n_samples)
thump_raw = _noise(thump_len)
if thump_len > 20:
bl, al = scipy.signal.butter(2, [40, 250], btype='band', fs=SAMPLE_RATE)
thump_padded = numpy.pad(thump_raw, (0, max(0, n_samples - thump_len)))
thump = scipy.signal.lfilter(bl, al, thump_padded)[:thump_len]
else:
thump = thump_raw
thump *= _exp_decay(thump_len, 20) * 0.8
# Metal shell resonance — longer, rounder than wooden dayan
metal_len = min(int(SAMPLE_RATE * 0.1), n_samples)
metal = numpy.sin(2 * numpy.pi * 120 * t[:metal_len]) * _exp_decay(metal_len, 12) * 0.3
# Pitch sweep body (hand modulates the head)
freq = 55 + 100 * numpy.exp(-10 * t)
phase = 2 * numpy.pi * numpy.cumsum(freq) / SAMPLE_RATE
body = numpy.sin(phase) * _exp_decay(n_samples, 5) * 0.7
# Sub boom from the large cavity
sub = _sine_f32(40, n_samples) * _exp_decay(n_samples, 6) * 0.5
# Palm attack
click_len = min(250, n_samples)
click = _noise(click_len) * _exp_decay(click_len, 35) * 0.3
result = body + sub
result[:thump_len] += thump
result[:metal_len] += metal
result[:click_len] += click
return numpy.tanh(result * 1.2)
def _synth_tabla_dha(n_samples):
"""Tabla Dha — both drums (Na + Ge). The most common stroke."""
na = _synth_tabla_na(n_samples) * 0.5
ge = _synth_tabla_ge(n_samples) * 0.6
return numpy.tanh(na + ge)
def _synth_tabla_tit(n_samples):
"""Tabla Ti/Tit — super fast light finger tap.
The rapid-fire dayan sound for tiri-kita, taka-dina patterns.
Very short, snappy, mostly attack with brief pitch.
"""
n = min(n_samples, int(SAMPLE_RATE * 0.06)) # 60ms max
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
# Quick membrane pop
pop = _noise(min(80, n)) * _exp_decay(min(80, n), 250) * 0.9
# Brief pitched ring
ring = numpy.sin(2 * numpy.pi * 500 * t) * _exp_decay(n, 35) * 0.5
ring2 = numpy.sin(2 * numpy.pi * 1100 * t) * 0.25 * _exp_decay(n, 45)
result = ring + ring2
result[:min(80, n)] += pop
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = numpy.tanh(result * 1.8)
return out
def _synth_tabla_ke(n_samples):
"""Tabla Ke/Ka — muted bayan slap. Dead thud, no ring."""
n = min(n_samples, int(SAMPLE_RATE * 0.08)) # 80ms max
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
# Muted membrane thud
body = numpy.sin(2 * numpy.pi * 80 * t) * _exp_decay(n, 25) * 0.7
thump = _noise(min(200, n)) * _exp_decay(min(200, n), 50) * 0.7
result = body
result[:min(200, n)] += thump
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = numpy.tanh(result * 1.3)
return out
def _synth_dhol_dagga(n_samples):
"""Dhol dagga — thunderous bass side hit with thick stick.
The dhol's bass head is thick goatskin, hit with a heavy curved
stick (dagga). Massive, thunderous low-end — the kind of hit
you feel in your chest before you hear it.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Heavy membrane thud — longer, wider band
thump_len = min(int(SAMPLE_RATE * 0.12), n_samples)
thump_raw = _noise(thump_len)
if thump_len > 20:
bl, al = scipy.signal.butter(2, [25, 150], btype='band', fs=SAMPLE_RATE)
thump = scipy.signal.lfilter(bl, al, numpy.pad(thump_raw, (0, max(0, n_samples - thump_len))))[:thump_len]
else:
thump = thump_raw
thump *= _exp_decay(thump_len, 10) * 1.2
# Deep pitched body with pitch sweep — thunderous boom
freq = 45 + 80 * numpy.exp(-15 * t)
phase = 2 * numpy.pi * numpy.cumsum(freq) / SAMPLE_RATE
body = numpy.sin(phase) * _exp_decay(n_samples, 5) * 1.0
# Massive sub boom — sustained
sub = _sine_f32(30, n_samples) * _exp_decay(n_samples, 4) * 0.8
sub2 = _sine_f32(45, n_samples) * _exp_decay(n_samples, 6) * 0.5
# Heavy stick impact
click_len = min(300, n_samples)
click = _noise(click_len) * _exp_decay(click_len, 40) * 0.6
result = body + sub + sub2
result[:thump_len] += thump
result[:click_len] += click
return numpy.tanh(result * 1.8)
def _synth_dhol_tilli(n_samples):
"""Dhol tilli — thin treble side hit with light stick.
The treble head is thinner goatskin, hit with a thin bamboo stick
(tilli). Bright, cutting, high-pitched crack.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Thin membrane snap
thump_len = min(int(SAMPLE_RATE * 0.03), n_samples)
thump_raw = _noise(thump_len)
if thump_len > 20:
bl, al = scipy.signal.butter(2, [400, 2000], btype='band', fs=SAMPLE_RATE)
thump = scipy.signal.lfilter(bl, al, numpy.pad(thump_raw, (0, max(0, n_samples - thump_len))))[:thump_len]
else:
thump = thump_raw
thump *= _exp_decay(thump_len, 60) * 0.9
# Higher pitched ring
ring = numpy.sin(2 * numpy.pi * 500 * t) * _exp_decay(n_samples, 20) * 0.5
ring2 = numpy.sin(2 * numpy.pi * 1200 * t) * 0.3 * _exp_decay(n_samples, 30)
# Sharp stick crack
click_len = min(80, n_samples)
click = _noise(click_len) * _exp_decay(click_len, 300) * 0.9
result = ring + ring2
result[:thump_len] += thump
result[:click_len] += click
return numpy.tanh(result * 1.6)
def _synth_dhol_both(n_samples):
"""Dhol both sides — full power bhangra hit. Thunderous."""
dagga = _synth_dhol_dagga(n_samples) * 0.7
tilli = _synth_dhol_tilli(n_samples) * 0.45
return numpy.tanh((dagga + tilli) * 1.2)
def _synth_dholak_ge(n_samples):
"""Dholak Ge — bass side open palm hit.
The dholak is lighter and higher-pitched than the dhol, used
in folk music and qawwali. Bass side has cotton/thread tuning.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
thump_len = min(int(SAMPLE_RATE * 0.05), n_samples)
thump_raw = _noise(thump_len)
if thump_len > 20:
bl, al = scipy.signal.butter(2, [60, 300], btype='band', fs=SAMPLE_RATE)
thump = scipy.signal.lfilter(bl, al, numpy.pad(thump_raw, (0, max(0, n_samples - thump_len))))[:thump_len]
else:
thump = thump_raw
thump *= _exp_decay(thump_len, 25) * 0.7
body = numpy.sin(2 * numpy.pi * 90 * t) * _exp_decay(n_samples, 8) * 0.7
sub = _sine_f32(55, n_samples) * _exp_decay(n_samples, 10) * 0.4
click_len = min(150, n_samples)
click = _noise(click_len) * _exp_decay(click_len, 50) * 0.3
result = body + sub
result[:thump_len] += thump
result[:click_len] += click
return numpy.tanh(result * 1.3)
def _synth_dholak_na(n_samples):
"""Dholak Na — treble side finger strike."""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
thump_len = min(int(SAMPLE_RATE * 0.03), n_samples)
thump_raw = _noise(thump_len)
if thump_len > 20:
bl, al = scipy.signal.butter(2, [250, 1200], btype='band', fs=SAMPLE_RATE)
thump = scipy.signal.lfilter(bl, al, numpy.pad(thump_raw, (0, max(0, n_samples - thump_len))))[:thump_len]
else:
thump = thump_raw
thump *= _exp_decay(thump_len, 40) * 0.7
ring = numpy.sin(2 * numpy.pi * 400 * t) * _exp_decay(n_samples, 12) * 0.5
ring2 = numpy.sin(2 * numpy.pi * 850 * t) * 0.3 * _exp_decay(n_samples, 18)
click_len = min(80, n_samples)
click = _noise(click_len) * _exp_decay(click_len, 200) * 0.6
result = ring + ring2
result[:thump_len] += thump
result[:click_len] += click
return numpy.tanh(result * 1.4)
def _synth_dholak_tit(n_samples):
"""Dholak light tap — fast finger pattern sound."""
n = min(n_samples, int(SAMPLE_RATE * 0.05))
pop = _noise(min(60, n)) * _exp_decay(min(60, n), 300) * 0.8
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
ring = numpy.sin(2 * numpy.pi * 500 * t) * _exp_decay(n, 30) * 0.4
result = ring
result[:min(60, n)] += pop
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = numpy.tanh(result * 1.6)
return out
def _synth_mridangam_tham(n_samples):
"""Mridangam Tham — bass stroke on the left head (thoppi).
The mridangam's left head is tuned with wet wheat paste, giving
a darker, more muted bass than tabla's bayan. Clay body resonance.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Membrane with wheat paste — darker character
thump_len = min(int(SAMPLE_RATE * 0.06), n_samples)
thump_raw = _noise(thump_len)
if thump_len > 20:
bl, al = scipy.signal.butter(2, [40, 200], btype='band', fs=SAMPLE_RATE)
thump = scipy.signal.lfilter(bl, al, numpy.pad(thump_raw, (0, max(0, n_samples - thump_len))))[:thump_len]
else:
thump = thump_raw
thump *= _exp_decay(thump_len, 18) * 0.8
# Clay body resonance — warmer than metal bayan
body = numpy.sin(2 * numpy.pi * 70 * t) * _exp_decay(n_samples, 6) * 0.8
clay = numpy.sin(2 * numpy.pi * 140 * t) * 0.25 * _exp_decay(n_samples, 9)
click_len = min(200, n_samples)
click = _noise(click_len) * _exp_decay(click_len, 40) * 0.25
result = body + clay
result[:thump_len] += thump
result[:click_len] += click
return numpy.tanh(result * 1.2)
def _synth_mridangam_nam(n_samples):
"""Mridangam Nam — treble ring on the right head (valanthalai).
The right head has a permanent syahi (called soru) that gives
a clear, bell-like pitch. More overtones than tabla dayan.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
thump_len = min(int(SAMPLE_RATE * 0.04), n_samples)
thump_raw = _noise(thump_len)
if thump_len > 20:
bl, al = scipy.signal.butter(2, [200, 900], btype='band', fs=SAMPLE_RATE)
thump = scipy.signal.lfilter(bl, al, numpy.pad(thump_raw, (0, max(0, n_samples - thump_len))))[:thump_len]
else:
thump = thump_raw
thump *= _exp_decay(thump_len, 35) * 0.7
# Rich overtone ring — more harmonics than tabla
ring = numpy.sin(2 * numpy.pi * 300 * t) * _exp_decay(n_samples, 7) * 0.6
ring2 = numpy.sin(2 * numpy.pi * 600 * t) * 0.4 * _exp_decay(n_samples, 9)
ring3 = numpy.sin(2 * numpy.pi * 920 * t) * 0.25 * _exp_decay(n_samples, 12)
ring4 = numpy.sin(2 * numpy.pi * 1250 * t) * 0.15 * _exp_decay(n_samples, 16)
click_len = min(100, n_samples)
click = _noise(click_len) * _exp_decay(click_len, 200) * 0.5
result = ring + ring2 + ring3 + ring4
result[:thump_len] += thump
result[:click_len] += click
return numpy.tanh(result * 1.3)
def _synth_mridangam_din(n_samples):
"""Mridangam Din — both heads simultaneously."""
nam = _synth_mridangam_nam(n_samples) * 0.5
tham = _synth_mridangam_tham(n_samples) * 0.6
return numpy.tanh(nam + tham)
def _synth_mridangam_tha(n_samples):
"""Mridangam Tha — muted treble stroke."""
n = min(n_samples, int(SAMPLE_RATE * 0.07))
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
thump_len = min(int(SAMPLE_RATE * 0.03), n)
thump = _noise(thump_len) * _exp_decay(thump_len, 50) * 0.7
body = numpy.sin(2 * numpy.pi * 280 * t) * _exp_decay(n, 22) * 0.5
result = body
result[:thump_len] += thump
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = numpy.tanh(result * 1.4)
return out
def _synth_doumbek_dum(n_samples):
"""Doumbek Dum — open center strike, deep and round."""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
freq = 80 + 40 * numpy.exp(-25 * t)
phase = 2 * numpy.pi * numpy.cumsum(freq) / SAMPLE_RATE
body = numpy.sin(phase) * _exp_decay(n_samples, 8) * 0.8
thump_len = min(int(SAMPLE_RATE * 0.04), n_samples)
import scipy.signal as _sig
thump = _noise(thump_len)
if thump_len > 20:
bl, al = _sig.butter(2, [50, 250], btype='band', fs=SAMPLE_RATE)
thump = _sig.lfilter(bl, al, numpy.pad(thump, (0, max(0, n_samples - thump_len))))[:thump_len].astype(numpy.float32)
thump *= _exp_decay(thump_len, 22) * 0.7
body[:thump_len] += thump
return numpy.tanh(body * 1.3).astype(numpy.float32)
def _synth_doumbek_tek(n_samples):
"""Doumbek Tek — sharp edge strike, bright and cutting."""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
ring = numpy.sin(2 * numpy.pi * 400 * t) * _exp_decay(n_samples, 22) * 0.5
ring2 = numpy.sin(2 * numpy.pi * 900 * t) * 0.3 * _exp_decay(n_samples, 30)
click_len = min(int(SAMPLE_RATE * 0.005), n_samples)
click = _noise(click_len) * _exp_decay(click_len, 300) * 0.9
import scipy.signal as _sig
if click_len > 10:
bl, al = _sig.butter(2, [2000, min(8000, SAMPLE_RATE // 2 - 1)], btype='band', fs=SAMPLE_RATE)
click = _sig.lfilter(bl, al, numpy.pad(click, (0, max(0, n_samples - click_len))))[:click_len].astype(numpy.float32)
result = ring + ring2
result[:click_len] += click
return numpy.tanh(result * 1.8).astype(numpy.float32)
def _synth_doumbek_ka(n_samples):
"""Doumbek Ka — muted edge slap, short and dry."""
n = min(n_samples, int(SAMPLE_RATE * 0.04))
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
body = numpy.sin(2 * numpy.pi * 350 * t) * _exp_decay(n, 30) * 0.4
slap = _noise(min(80, n)) * _exp_decay(min(80, n), 200) * 0.7
result = body
result[:min(80, n)] += slap
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = numpy.tanh(result * 1.5)
return out
def _synth_cajon_bass(n_samples):
"""Cajón bass — open palm slaps the center of the plywood face.
Two sounds happening at once: the fleshy THUD of the palm
hitting wood, then the box resonating. The hand sound is
a soft, round impact — skin on plywood — followed by the
hollow chamber boom.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# HAND IMPACT — fleshy palm on wood, round and thuddy
hand_len = min(int(SAMPLE_RATE * 0.015), n_samples)
hand_raw = _noise(hand_len)
if hand_len > 10:
# Lowpassed — palm is soft, not bright
bl, al = scipy.signal.butter(2, 1500 / (SAMPLE_RATE / 2), btype='low')
hand = scipy.signal.lfilter(bl, al, numpy.pad(hand_raw, (0, max(0, n_samples - hand_len))))[:hand_len].astype(numpy.float32)
else:
hand = hand_raw
hand *= _exp_decay(hand_len, 100) * 1.0
# BOX RESONANCE — hollow chamber thud
body = numpy.sin(2 * numpy.pi * 75 * t) * _exp_decay(n_samples, 5) * 0.7
box1 = numpy.sin(2 * numpy.pi * 170 * t) * _exp_decay(n_samples, 10) * 0.4
box2 = numpy.sin(2 * numpy.pi * 300 * t) * _exp_decay(n_samples, 16) * 0.2
# Panel flex — deep sub thud
sub = _sine_f32(45, n_samples) * _exp_decay(n_samples, 6) * 0.5
# Broader thump from the air cavity
thump_len = min(int(SAMPLE_RATE * 0.1), n_samples)
thump_raw = _noise(thump_len)
if thump_len > 20:
bl, al = scipy.signal.butter(2, [40, 350], btype='band', fs=SAMPLE_RATE)
thump = scipy.signal.lfilter(bl, al, numpy.pad(thump_raw, (0, max(0, n_samples - thump_len))))[:thump_len].astype(numpy.float32)
else:
thump = thump_raw
thump *= _exp_decay(thump_len, 12) * 0.6
result = body + box1 + box2 + sub
result[:thump_len] += thump
result[:hand_len] += hand
return numpy.tanh(result * 1.5).astype(numpy.float32)
def _synth_cajon_slap(n_samples):
"""Cajón slap — fingers near the top edge, pure wood crack.
No snare wires. Just the sharp crack of fingers on the plywood
edge with the box resonance underneath. Dry and woody.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Wood panel resonance — boxy mid
body = numpy.sin(2 * numpy.pi * 240 * t) * _exp_decay(n_samples, 28) * 0.4
box = numpy.sin(2 * numpy.pi * 400 * t) * _exp_decay(n_samples, 38) * 0.2
box2 = numpy.sin(2 * numpy.pi * 600 * t) * _exp_decay(n_samples, 50) * 0.1
# Sharp edge slap — fingers on plywood
slap_len = min(int(SAMPLE_RATE * 0.004), n_samples)
slap = _noise(slap_len) * _exp_decay(slap_len, 300) * 1.0
result = body + box + box2
result[:slap_len] += slap
return numpy.tanh(result * 1.8).astype(numpy.float32)
def _synth_cajon_slap_snare(n_samples):
"""Cajón slap with snare wires — the buzzy version.
Same edge slap but with internal snare wires rattling.
"""
wood = _synth_cajon_slap(n_samples)
# Add snare wire buzz on top
wire_len = min(int(SAMPLE_RATE * 0.06), n_samples)
wire = _noise(wire_len) * _exp_decay(wire_len, 20) * 0.45
if wire_len > 20:
bl, al = scipy.signal.butter(2, [1500, 5000], btype='band', fs=SAMPLE_RATE)
wire = scipy.signal.lfilter(bl, al, numpy.pad(wire, (0, max(0, n_samples - wire_len))))[:wire_len].astype(numpy.float32)
result = wood.copy()
result[:wire_len] += wire
return numpy.tanh(result * 1.2).astype(numpy.float32)
def _synth_cajon_tap(n_samples):
"""Cajón tap — light fingertip on the plywood face. Ghost note."""
n = min(n_samples, int(SAMPLE_RATE * 0.05))
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
# Finger on wood — hollow tap
tap = numpy.sin(2 * numpy.pi * 280 * t) * _exp_decay(n, 40) * 0.3
box = numpy.sin(2 * numpy.pi * 450 * t) * _exp_decay(n, 55) * 0.12
pop = _noise(min(60, n)) * _exp_decay(min(60, n), 200) * 0.4
result = tap + box
result[:min(60, n)] += pop
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = numpy.tanh(result * 1.5)
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_march_snare(n_samples):
"""Marching snare — ultra-tight kevlar head, high and crisp.
Higher pitched than a kit snare. Very short decay — all attack,
no sustain. Tight snare wires give a brief sizzle.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Higher-pitched body — tight kevlar pops high
body = numpy.sin(2 * numpy.pi * 450 * t) * _exp_decay(n_samples, 60) * 0.4
body2 = numpy.sin(2 * numpy.pi * 700 * t) * _exp_decay(n_samples, 75) * 0.2
# Sharp stick pop
click_len = min(int(SAMPLE_RATE * 0.001), n_samples)
click = _noise(click_len) * _exp_decay(click_len, 400) * 1.2
# Very tight snare sizzle — higher band, shorter
buzz_len = min(int(SAMPLE_RATE * 0.025), n_samples)
buzz_raw = _noise(buzz_len)
if buzz_len > 20:
bl, al = scipy.signal.butter(2, [3500, 8000], btype='band', fs=SAMPLE_RATE)
buzz = scipy.signal.lfilter(bl, al, numpy.pad(buzz_raw, (0, max(0, n_samples - buzz_len))))[:buzz_len]
else:
buzz = buzz_raw
buzz *= _exp_decay(buzz_len, 50) * 0.35
result = body + body2
result[:click_len] += click
result[:buzz_len] += buzz
return numpy.tanh(result * 2.8)
def _synth_march_rimshot(n_samples):
"""Marching rimshot — woody metallic crack.
The stick catches the rim — you get the full snare hit plus
a bright, woody-metallic crack from the aluminum rim. Short
ring that dies fast but gives it that cutting edge.
"""
wave = _synth_march_snare(n_samples)
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Rim crack — bright but short, woody-metallic character
rim = numpy.sin(2 * numpy.pi * 1100 * t) * _exp_decay(n_samples, 45) * 0.35
rim2 = numpy.sin(2 * numpy.pi * 2200 * t) * _exp_decay(n_samples, 55) * 0.2
# Hard transient pop
pop_len = min(int(SAMPLE_RATE * 0.002), n_samples)
pop = _noise(pop_len) * _exp_decay(pop_len, 350) * 1.5
# Extra body punch
punch = numpy.sin(2 * numpy.pi * 500 * t) * _exp_decay(n_samples, 65) * 0.3
result = wave * 1.4 + rim + rim2 + punch
result[:pop_len] += pop
return numpy.tanh(result * 2.0)
def _synth_march_click(n_samples):
"""Stick click — taped hickory sticks clocked together.
Bright wood-on-wood with a slightly dampened attack from the
electrical tape. Not as ringy as a clave — the tape absorbs
some of the high overtones — but still bright and snappy.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Wood resonance — brighter than before, but tape dampens ring
body = numpy.sin(2 * numpy.pi * 1100 * t) * _exp_decay(n_samples, 65) * 0.45
body2 = numpy.sin(2 * numpy.pi * 1800 * t) * _exp_decay(n_samples, 80) * 0.25
# Woody overtone — gives it that hickory character
body3 = numpy.sin(2 * numpy.pi * 2600 * t) * _exp_decay(n_samples, 95) * 0.12
# Bright but slightly muffled transient (tape on wood)
click_len = min(int(SAMPLE_RATE * 0.001), n_samples)
click_raw = _noise(click_len)
if click_len > 10:
bl, al = scipy.signal.butter(2, [800, 7000], btype='band', fs=SAMPLE_RATE)
click = scipy.signal.lfilter(bl, al, numpy.pad(click_raw, (0, max(0, n_samples - click_len))))[:click_len]
else:
click = click_raw
click *= _exp_decay(click_len, 350) * 0.9
result = body + body2 + body3
result[:click_len] += click
return numpy.tanh(result * 2.8)
def _synth_quad(n_samples, pitch=300):
"""Marching tenor/quad drum — tuned mylar head, bright and ringy.
Quads have a distinctive metallic ting from the high-tension
mylar head and aluminum shell. More ring than a kit tom,
brighter attack, clear pitch.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Pitched body — more ring/sustain than snare
body = numpy.sin(2 * numpy.pi * pitch * t) * _exp_decay(n_samples, 22) * 0.5
# Metallic overtones — the ting
ting = numpy.sin(2 * numpy.pi * pitch * 2.3 * t) * _exp_decay(n_samples, 35) * 0.25
ting2 = numpy.sin(2 * numpy.pi * pitch * 3.1 * t) * _exp_decay(n_samples, 45) * 0.12
# Shell ring
shell = numpy.sin(2 * numpy.pi * pitch * 4.7 * t) * _exp_decay(n_samples, 55) * 0.06
# Sharp stick attack
click_len = min(int(SAMPLE_RATE * 0.001), n_samples)
click = _noise(click_len) * _exp_decay(click_len, 400) * 0.8
result = body + ting + ting2 + shell
result[:click_len] += click
return numpy.tanh(result * 2.5)
def _synth_quad_spock(n_samples):
"""Quad spock — rim shot on the tenor shell. Bright, ringy, cutting."""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
ring = numpy.sin(2 * numpy.pi * 1400 * t) * _exp_decay(n_samples, 40) * 0.5
ring2 = numpy.sin(2 * numpy.pi * 2100 * t) * _exp_decay(n_samples, 55) * 0.25
click_len = min(int(SAMPLE_RATE * 0.001), n_samples)
click = _noise(click_len) * _exp_decay(click_len, 400) * 1.0
result = ring + ring2
result[:click_len] += click
return numpy.tanh(result * 2.8)
def _synth_march_bass(n_samples, pitch=60):
"""Marching bass drum — deep, boomy, pitched, felt beater thwack.
The beater hitting the head is a big part of the sound — a round,
pillowy thwack followed by the deep pitched boom. More beater
sound than a kit bass drum because marching bass drums project
outward.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Deep pitched body — sustains and rings
body = numpy.sin(2 * numpy.pi * pitch * t) * _exp_decay(n_samples, 10) * 0.7
body2 = numpy.sin(2 * numpy.pi * pitch * 2 * t) * _exp_decay(n_samples, 16) * 0.2
# Sub thump
sub = numpy.sin(2 * numpy.pi * pitch * 0.5 * t) * _exp_decay(n_samples, 8) * 0.3
# BIG beater thwack — dominant part of the attack
thwack_len = min(int(SAMPLE_RATE * 0.025), n_samples)
thwack_raw = _noise(thwack_len)
if thwack_len > 10:
bl, al = scipy.signal.butter(2, [150, 2500], btype='band', fs=SAMPLE_RATE)
thwack = scipy.signal.lfilter(bl, al, numpy.pad(thwack_raw, (0, max(0, n_samples - thwack_len))))[:thwack_len]
else:
thwack = thwack_raw
thwack *= _exp_decay(thwack_len, 55) * 1.5
# Head slap — the mylar flexing on impact
slap_len = min(int(SAMPLE_RATE * 0.008), n_samples)
slap = numpy.sin(2 * numpy.pi * pitch * 3 * numpy.arange(slap_len, dtype=numpy.float32) / SAMPLE_RATE)
slap *= _exp_decay(slap_len, 90) * 0.4
result = body + body2 + sub
result[:thwack_len] += thwack
result[:slap_len] += slap
return numpy.tanh(result * 2.0)
def _synth_tabla_ge_bend(n_samples):
"""Tabla Ge with upward pitch bend — palm pressing into bayan head.
The player strikes the bayan and then presses their palm into the
head, raising the pitch dramatically. The signature bayan sound
in Bollywood and fusion music.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Membrane thud
thump_len = min(int(SAMPLE_RATE * 0.07), n_samples)
thump_raw = _noise(thump_len)
if thump_len > 20:
bl, al = scipy.signal.butter(2, [40, 250], btype='band', fs=SAMPLE_RATE)
thump = scipy.signal.lfilter(bl, al, numpy.pad(thump_raw, (0, max(0, n_samples - thump_len))))[:thump_len]
else:
thump = thump_raw
thump *= _exp_decay(thump_len, 20) * 0.8
# Pitch sweep UP — 60 Hz rising to 200+ Hz as palm presses
# Gets quieter as pitch rises (palm mutes the head as it presses)
freq = 60 + 180 * (1 - numpy.exp(-4 * t))
phase = 2 * numpy.pi * numpy.cumsum(freq) / SAMPLE_RATE
body = numpy.sin(phase) * _exp_decay(n_samples, 6) * 0.9
# Metal shell resonance
metal_len = min(int(SAMPLE_RATE * 0.1), n_samples)
metal = numpy.sin(2 * numpy.pi * 150 * t[:metal_len]) * _exp_decay(metal_len, 8) * 0.3
# Sub
sub = _sine_f32(50, n_samples) * _exp_decay(n_samples, 5) * 0.4
click_len = min(250, n_samples)
click = _noise(click_len) * _exp_decay(click_len, 35) * 0.3
result = body + sub
result[:thump_len] += thump
result[:metal_len] += metal
result[:click_len] += click
return numpy.tanh(result * 1.3).astype(numpy.float32)
def _synth_djembe_bass(n_samples):
"""Djembe bass — open palm strike in center of goatskin head.
Deep, warm, round bass. The goblet-shaped wooden body amplifies
the low frequencies. Played with a flat palm in the center.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Goatskin membrane — prominent, round
thump_len = min(int(SAMPLE_RATE * 0.08), n_samples)
thump_raw = _noise(thump_len)
if thump_len > 20:
bl, al = scipy.signal.butter(2, [50, 250], btype='band', fs=SAMPLE_RATE)
thump = scipy.signal.lfilter(bl, al, numpy.pad(thump_raw, (0, max(0, n_samples - thump_len))))[:thump_len]
else:
thump = thump_raw
thump *= _exp_decay(thump_len, 15) * 0.9
# Round bass body — goblet resonance
body = numpy.sin(2 * numpy.pi * 65 * t) * _exp_decay(n_samples, 6) * 0.9
sub = _sine_f32(45, n_samples) * _exp_decay(n_samples, 8) * 0.5
# Warm palm attack
click_len = min(200, n_samples)
click = _noise(click_len) * _exp_decay(click_len, 35) * 0.25
result = body + sub
result[:thump_len] += thump
result[:click_len] += click
return numpy.tanh(result * 1.3)
def _synth_djembe_tone(n_samples):
"""Djembe tone — open edge strike, fingers together.
Clear, pitched, ringing. Fingers strike the edge of the head with
the palm off the surface so the head rings freely.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Goatskin membrane at the edge — brighter than center
thump_len = min(int(SAMPLE_RATE * 0.04), n_samples)
thump_raw = _noise(thump_len)
if thump_len > 20:
bl, al = scipy.signal.butter(2, [150, 800], btype='band', fs=SAMPLE_RATE)
thump = scipy.signal.lfilter(bl, al, numpy.pad(thump_raw, (0, max(0, n_samples - thump_len))))[:thump_len]
else:
thump = thump_raw
thump *= _exp_decay(thump_len, 35) * 0.7
# Clear pitched ring
ring = numpy.sin(2 * numpy.pi * 250 * t) * _exp_decay(n_samples, 8) * 0.6
ring2 = numpy.sin(2 * numpy.pi * 500 * t) * 0.3 * _exp_decay(n_samples, 12)
click_len = min(100, n_samples)
click = _noise(click_len) * _exp_decay(click_len, 150) * 0.5
result = ring + ring2
result[:thump_len] += thump
result[:click_len] += click
return numpy.tanh(result * 1.3)
def _synth_djembe_slap(n_samples):
"""Djembe slap — edge strike with fingers spread, sharp crack.
The highest, sharpest djembe sound. A dry, high-pitched pop from
goatskin membrane — NOT a snare. Tight attack, very short decay,
skin character rather than wire rattle.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# High membrane pop — goatskin resonance, much higher than snare
pop = numpy.sin(2 * numpy.pi * 900 * t) * _exp_decay(n_samples, 50) * 0.5
pop2 = numpy.sin(2 * numpy.pi * 1600 * t) * _exp_decay(n_samples, 60) * 0.25
pop3 = numpy.sin(2 * numpy.pi * 2400 * t) * _exp_decay(n_samples, 80) * 0.12
# Very short filtered click — hand-on-skin transient, not noise rattle
click_len = min(int(SAMPLE_RATE * 0.008), n_samples)
click_raw = _noise(click_len)
if click_len > 20:
bl, al = scipy.signal.butter(2, 1800 / (SAMPLE_RATE / 2), btype='high')
click = scipy.signal.lfilter(bl, al, numpy.pad(click_raw, (0, max(0, n_samples - click_len))))[:click_len]
else:
click = click_raw
click *= _exp_decay(click_len, 150) * 0.6
result = pop + pop2 + pop3
result[:click_len] += click
return numpy.tanh(result * 1.5)
def _synth_guiro(n_samples):
"""Guiro: scraped ridged surface — rhythmic noise bursts."""
wave = numpy.zeros(n_samples, dtype=numpy.float32)
total = min(n_samples, int(SAMPLE_RATE * 0.18))
scrape_len = min(int(SAMPLE_RATE * 0.006), n_samples)
gap = int(SAMPLE_RATE * 0.004)
pos = 0
loudness = 0.7
while pos < total:
end = min(pos + scrape_len, n_samples)
# Each scrape is slightly different
wave[pos:end] += _noise(end - pos) * loudness
# Subtle pitched component — the ridges
ridge_len = end - pos
t = numpy.arange(ridge_len, dtype=numpy.float32) / SAMPLE_RATE
wave[pos:end] += numpy.sin(2 * numpy.pi * 3000 * t) * loudness * 0.2
pos += scrape_len + gap
loudness *= 0.95 # slight fade
wave *= _exp_decay(n_samples, 6)
return wave
def _synth_rainstick_slow(n_samples):
"""Rain stick (shallow angle): slow trickle, longer cascade, sparser impacts."""
wave = numpy.zeros(n_samples, dtype=numpy.float32)
rng = numpy.random.default_rng(77)
cascade_len = min(n_samples, int(SAMPLE_RATE * 4.0))
n_pebbles = 800
# More uniform distribution — shallow angle means steadier flow
positions = rng.beta(1.2, 1.8, n_pebbles) * cascade_len
positions = positions.astype(int)
for pos in positions:
if pos >= n_samples - 100:
continue
peb_len = rng.integers(25, 90)
end = min(pos + peb_len, n_samples)
actual = end - pos
click = rng.uniform(-1.0, 1.0, actual).astype(numpy.float32)
click *= numpy.exp(-numpy.linspace(0, 10, actual).astype(numpy.float32))
click *= rng.uniform(0.03, 0.18)
wave[pos:end] += click
t = numpy.arange(cascade_len, dtype=numpy.float32) / SAMPLE_RATE
body = numpy.sin(2 * numpy.pi * 160 * t) * 0.04
body *= numpy.exp(-0.8 * t)
wave[:cascade_len] += body
full_env = numpy.ones(n_samples, dtype=numpy.float32)
fade_len = min(int(SAMPLE_RATE * 1.2), n_samples)
if fade_len > 0 and cascade_len > fade_len:
full_env[cascade_len - fade_len:cascade_len] = numpy.linspace(
1.0, 0.0, fade_len).astype(numpy.float32)
full_env[cascade_len:] = 0.0
wave *= full_env
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx * 1.5
return wave
def _synth_ocean_drum(n_samples):
"""Ocean drum: steel beads rolling inside a frame drum — surf wash.
Tilt the drum and the beads cascade across the internal head,
producing a smooth wash that sounds like ocean waves.
"""
wave = numpy.zeros(n_samples, dtype=numpy.float32)
rng = numpy.random.default_rng(55)
wash_len = min(n_samples, int(SAMPLE_RATE * 2.5))
t = numpy.arange(wash_len, dtype=numpy.float32) / SAMPLE_RATE
# Dense bead noise — smoother than rain stick (steel beads on drum head)
noise = rng.standard_normal(wash_len).astype(numpy.float32)
# Bandpass to ~1-6kHz — beads on mylar head
import scipy.signal as _sig
bp, ap = _sig.butter(2, [1000, 6000], btype='band', fs=SAMPLE_RATE)
noise = _sig.lfilter(bp, ap, noise).astype(numpy.float32)
# Swell envelope — wave comes in, peaks, recedes
swell = numpy.abs(numpy.sin(numpy.pi * t / t[-1])) ** 0.7 if wash_len > 0 else noise
noise *= swell * 0.5
# Drum body resonance
body = numpy.sin(2 * numpy.pi * 120 * t) * 0.08 * swell
wave[:wash_len] = noise + body
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx * 1.3
return wave
def _synth_cabasa(n_samples):
"""Cabasa: metal bead chain scraped against a cylinder.
Brighter and more metallic than a shaker — the beads are steel
chain wrapped around a textured metal cylinder.
"""
n = min(n_samples, int(SAMPLE_RATE * 0.08))
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
rng = numpy.random.default_rng(33)
# Metallic noise — brighter than shaker
noise = rng.standard_normal(n).astype(numpy.float32)
# High-pass to emphasize the metallic chain sound
env = numpy.exp(-25 * t) + 0.4 * numpy.exp(-6 * t)
wave = noise * env * 0.5
# Metal bead resonances
wave += numpy.sin(2 * numpy.pi * 7500 * t) * 0.12 * numpy.exp(-30 * t)
wave += numpy.sin(2 * numpy.pi * 9200 * t) * 0.08 * numpy.exp(-35 * t)
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = wave
return out
def _synth_wind_chimes(n_samples):
"""Wind chimes: multiple suspended metal tubes ringing at random intervals.
Each tube has its own pitch and decay. A hand strike or breeze
sets several ringing at once with slight time offsets.
"""
wave = numpy.zeros(n_samples, dtype=numpy.float32)
rng = numpy.random.default_rng(22)
chime_len = min(n_samples, int(SAMPLE_RATE * 3.0))
t = numpy.arange(chime_len, dtype=numpy.float32) / SAMPLE_RATE
# 6-8 tubes at different pitches — pentatonic-ish spread
tube_freqs = [1200, 1450, 1700, 2000, 2400, 2850, 3300]
for freq in tube_freqs:
# Each tube starts at a random offset (breeze hits them at different times)
offset = rng.integers(0, int(SAMPLE_RATE * 0.3))
if offset >= chime_len:
continue
tube_t = t[offset:]
tube_local = tube_t - tube_t[0]
# Tube mode with slight inharmonicity
tone = numpy.sin(2 * numpy.pi * freq * tube_local) * 0.2
tone += numpy.sin(2 * numpy.pi * freq * 2.73 * tube_local) * 0.06
# Each tube decays independently
decay = numpy.exp(-rng.uniform(2.0, 4.0) * tube_local)
tone *= decay
# Slight amplitude variation
tone *= rng.uniform(0.5, 1.0)
wave[offset:chime_len] += tone[:chime_len - offset].astype(numpy.float32)
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return wave
def _synth_finger_cymbal(n_samples):
"""Finger cymbal (zill): single small cymbal tap — bright metallic ping."""
n = min(n_samples, int(SAMPLE_RATE * 0.8))
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
rng = numpy.random.default_rng(11)
# High-pitched metallic modes
wave = numpy.sin(2 * numpy.pi * 3200 * t).astype(numpy.float32) * 0.5
wave += numpy.sin(2 * numpy.pi * 3210 * t).astype(numpy.float32) * 0.5 # beating pair
wave += numpy.sin(2 * numpy.pi * 7800 * t).astype(numpy.float32) * 0.15 * numpy.exp(-8 * t).astype(numpy.float32)
wave += numpy.sin(2 * numpy.pi * 12500 * t).astype(numpy.float32) * 0.06 * numpy.exp(-15 * t).astype(numpy.float32)
wave *= numpy.exp(-3.0 * t).astype(numpy.float32)
# Tap transient
tap_len = min(int(SAMPLE_RATE * 0.001), n)
wave[:tap_len] += rng.uniform(-0.2, 0.2, tap_len).astype(numpy.float32)
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = wave
mx = numpy.abs(out).max()
if mx > 0:
out /= mx
return out
def _synth_rainstick(n_samples):
"""Rain stick: cascading pebbles through a cactus tube with internal pins.
Hundreds of tiny seed/pebble impacts falling through the tube,
each one a brief high-frequency click with a hint of resonance
from the hollow body. The density tapers off as gravity runs out.
"""
wave = numpy.zeros(n_samples, dtype=numpy.float32)
rng = numpy.random.default_rng(42)
# Duration of the cascade — up to 2.5 seconds
cascade_len = min(n_samples, int(SAMPLE_RATE * 2.5))
# Generate random pebble impacts — denser at the start, sparse at the end
n_pebbles = 800
# Positions weighted toward the beginning (gravity)
positions = rng.beta(1.5, 3.0, n_pebbles) * cascade_len
positions = positions.astype(int)
for pos in positions:
if pos >= n_samples - 100:
continue
# Each pebble: tiny noise click with random pitch resonance
peb_len = rng.integers(20, 80)
end = min(pos + peb_len, n_samples)
actual = end - pos
# Noise click
click = rng.uniform(-1.0, 1.0, actual).astype(numpy.float32)
# Fast decay
click *= numpy.exp(-numpy.linspace(0, 12, actual).astype(numpy.float32))
# Random amplitude — some pebbles louder than others
click *= rng.uniform(0.05, 0.25)
wave[pos:end] += click
# Tube body resonance — hollow cactus, low rumble underneath
t = numpy.arange(cascade_len, dtype=numpy.float32) / SAMPLE_RATE
body = numpy.sin(2 * numpy.pi * 180 * t) * 0.06
body *= numpy.exp(-1.5 * t)
# Modulate body resonance by the cascade density
env = numpy.exp(-1.2 * t)
body *= env
wave[:cascade_len] += body
# Overall envelope — smooth fade
full_env = numpy.ones(n_samples, dtype=numpy.float32)
fade_len = min(int(SAMPLE_RATE * 0.8), n_samples)
if fade_len > 0 and cascade_len > fade_len:
full_env[cascade_len - fade_len:cascade_len] = numpy.linspace(
1.0, 0.0, fade_len).astype(numpy.float32)
full_env[cascade_len:] = 0.0
wave *= full_env
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx * 1.5 # leave headroom
return wave
def _render_drum_hit(sound_value, n_samples):
"""Render a single drum sound to a float32 array.
Args:
sound_value: A DrumSound enum value (MIDI note number).
n_samples: Number of samples to render.
Returns:
Float32 numpy array.
"""
from .rhythm import DrumSound
_dispatch = {
DrumSound.KICK.value: lambda n: _synth_kick(n),
DrumSound.SNARE.value: lambda n: _synth_snare(n),
DrumSound.RIMSHOT.value: lambda n: _synth_rimshot(n),
DrumSound.CLAP.value: lambda n: _synth_clap(n),
DrumSound.CLOSED_HAT.value: lambda n: _synth_hat_closed(n),
DrumSound.OPEN_HAT.value: lambda n: _synth_hat_open(n),
DrumSound.PEDAL_HAT.value: lambda n: _synth_hat_closed(n),
DrumSound.LOW_TOM.value: lambda n: _synth_tom(100, n),
DrumSound.MID_TOM.value: lambda n: _synth_tom(150, n),
DrumSound.HIGH_TOM.value: lambda n: _synth_tom(200, n),
DrumSound.CRASH.value: lambda n: _synth_crash(n),
DrumSound.RIDE.value: lambda n: _synth_ride(n),
DrumSound.RIDE_BELL.value: lambda n: _synth_ride_bell(n),
DrumSound.COWBELL.value: lambda n: _synth_cowbell(n),
DrumSound.CLAVE.value: lambda n: _synth_clave(n),
DrumSound.SHAKER.value: lambda n: _synth_shaker(n),
DrumSound.TAMBOURINE.value: lambda n: _synth_tambourine(n),
DrumSound.CONGA_HIGH.value: lambda n: _synth_conga(300, n),
DrumSound.CONGA_LOW.value: lambda n: _synth_conga(200, n),
DrumSound.BONGO_HIGH.value: lambda n: _synth_conga(450, n),
DrumSound.BONGO_LOW.value: lambda n: _synth_conga(350, n),
DrumSound.TIMBALE_HIGH.value: lambda n: _synth_timbale(800, n),
DrumSound.TIMBALE_LOW.value: lambda n: _synth_timbale(600, n),
DrumSound.AGOGO_HIGH.value: lambda n: _synth_agogo(900, n),
DrumSound.AGOGO_LOW.value: lambda n: _synth_agogo(700, n),
DrumSound.GUIRO.value: lambda n: _synth_guiro(n),
DrumSound.MARACAS.value: lambda n: _synth_shaker(n),
# Tabla
DrumSound.TABLA_NA.value: lambda n: _synth_tabla_na(n),
DrumSound.TABLA_TIN.value: lambda n: _synth_tabla_tin(n),
DrumSound.TABLA_GE.value: lambda n: _synth_tabla_ge(n),
DrumSound.TABLA_DHA.value: lambda n: _synth_tabla_dha(n),
DrumSound.TABLA_TIT.value: lambda n: _synth_tabla_tit(n),
DrumSound.TABLA_KE.value: lambda n: _synth_tabla_ke(n),
DrumSound.TABLA_GE_BEND.value: _synth_tabla_ge_bend,
# Dhol
DrumSound.DHOL_DAGGA.value: lambda n: _synth_dhol_dagga(n),
DrumSound.DHOL_TILLI.value: lambda n: _synth_dhol_tilli(n),
DrumSound.DHOL_BOTH.value: lambda n: _synth_dhol_both(n),
# Dholak
DrumSound.DHOLAK_GE.value: lambda n: _synth_dholak_ge(n),
DrumSound.DHOLAK_NA.value: lambda n: _synth_dholak_na(n),
DrumSound.DHOLAK_TIT.value: lambda n: _synth_dholak_tit(n),
# Mridangam
DrumSound.MRIDANGAM_THAM.value: lambda n: _synth_mridangam_tham(n),
DrumSound.MRIDANGAM_NAM.value: lambda n: _synth_mridangam_nam(n),
DrumSound.MRIDANGAM_DIN.value: lambda n: _synth_mridangam_din(n),
DrumSound.MRIDANGAM_THA.value: lambda n: _synth_mridangam_tha(n),
# Djembe
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),
# Doumbek
DrumSound.DOUMBEK_DUM.value: lambda n: _synth_doumbek_dum(n),
DrumSound.DOUMBEK_TEK.value: lambda n: _synth_doumbek_tek(n),
DrumSound.DOUMBEK_KA.value: lambda n: _synth_doumbek_ka(n),
# Cajon
DrumSound.CAJON_BASS.value: lambda n: _synth_cajon_bass(n),
DrumSound.CAJON_SLAP.value: lambda n: _synth_cajon_slap(n),
DrumSound.CAJON_SLAP_SNARE.value: lambda n: _synth_cajon_slap_snare(n),
DrumSound.CAJON_TAP.value: lambda n: _synth_cajon_tap(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),
# Marching
DrumSound.MARCH_SNARE.value: lambda n: _synth_march_snare(n),
DrumSound.MARCH_RIMSHOT.value: lambda n: _synth_march_rimshot(n),
DrumSound.MARCH_CLICK.value: lambda n: _synth_march_click(n),
# Quads (tenor drums) — pitched high to low
DrumSound.QUAD_1.value: lambda n: _synth_quad(n, pitch=400),
DrumSound.QUAD_2.value: lambda n: _synth_quad(n, pitch=330),
DrumSound.QUAD_3.value: lambda n: _synth_quad(n, pitch=270),
DrumSound.QUAD_4.value: lambda n: _synth_quad(n, pitch=220),
DrumSound.QUAD_SPOCK.value: lambda n: _synth_quad_spock(n),
# Marching bass drums — pitched high to low
DrumSound.BASS_1.value: lambda n: _synth_march_bass(n, pitch=90),
DrumSound.BASS_2.value: lambda n: _synth_march_bass(n, pitch=75),
DrumSound.BASS_3.value: lambda n: _synth_march_bass(n, pitch=62),
DrumSound.BASS_4.value: lambda n: _synth_march_bass(n, pitch=52),
DrumSound.BASS_5.value: lambda n: _synth_march_bass(n, pitch=42),
# Effects / world
DrumSound.RAINSTICK.value: lambda n: _synth_rainstick(n),
DrumSound.RAINSTICK_SLOW.value: lambda n: _synth_rainstick_slow(n),
DrumSound.OCEAN_DRUM.value: lambda n: _synth_ocean_drum(n),
DrumSound.CABASA.value: lambda n: _synth_cabasa(n),
DrumSound.WIND_CHIMES.value: lambda n: _synth_wind_chimes(n),
DrumSound.FINGER_CYMBAL.value: lambda n: _synth_finger_cymbal(n),
}
renderer = _dispatch.get(sound_value, lambda n: _synth_clave(n))
result = renderer(n_samples)
# Override for ge_bend — dispatch closure has stale reference
if sound_value == 108:
result = _synth_tabla_ge_bend(n_samples)
return result
# Drum hit cache — same sound at same length sounds identical
_drum_cache = {}
def _render_drum_hit_cached(sound_value, n_samples):
"""Cached version of _render_drum_hit for pattern playback."""
key = (sound_value, n_samples)
if key not in _drum_cache:
_drum_cache[key] = _render_drum_hit(sound_value, n_samples)
return _drum_cache[key].copy() # copy so callers can mutate
def _render_pattern(pattern, bpm=120):
"""Render a drum Pattern to a float32 audio buffer.
Args:
pattern: A Pattern object from rhythm module.
bpm: Tempo in beats per minute.
Returns:
Float32 numpy array of mixed audio.
"""
samples_per_beat = int(SAMPLE_RATE * 60.0 / bpm)
total_samples = int(pattern.beats * samples_per_beat)
buf = numpy.zeros(total_samples, dtype=numpy.float32)
for hit in pattern.hits:
start = int(hit.position * samples_per_beat)
if start >= total_samples:
continue
remaining = total_samples - start
# Render each hit for up to 0.5 seconds
hit_len = min(int(SAMPLE_RATE * 0.5), remaining)
wave = _render_drum_hit_cached(hit.sound.value, hit_len)
vel_scale = hit.velocity / 127.0
buf[start:start + hit_len] += wave * vel_scale
# Normalize to prevent clipping
peak = numpy.max(numpy.abs(buf))
if peak > 0:
buf = buf / peak * 0.9
return buf
[docs]
def play_pattern(pattern, repeats=1, bpm=120):
"""Play a drum pattern through the speakers.
Synthesizes each drum sound in real-time and mixes them into a
single audio buffer. Every ``DrumSound`` has its own synthesized
voice — kicks have pitch sweeps, snares have noise bursts, hats
are filtered noise, etc.
Args:
pattern: A :class:`Pattern` object.
repeats: Number of times to loop the pattern (default 1).
bpm: Tempo in beats per minute (default 120).
Example::
>>> from pytheory import Pattern
>>> play_pattern(Pattern.preset("rock"), repeats=4, bpm=120)
>>> play_pattern(Pattern.preset("bossa nova"), repeats=4, bpm=140)
"""
rendered = _render_pattern(pattern, bpm=bpm)
if repeats > 1:
rendered = numpy.tile(rendered, repeats)
_sd = _get_sd()
_sd.play(rendered, SAMPLE_RATE)
_sd.wait()
# ── Audio effects ───────────────────────────────────────────────────────────
def _apply_sidechain(samples, trigger_samples, amount=0.8, attack=0.001, release=0.1, sample_rate=SAMPLE_RATE):
"""Apply sidechain compression — duck the signal when the trigger is loud.
Args:
samples: The signal to duck (float32 array).
trigger_samples: The trigger signal, usually kick drum (float32 array).
amount: How much to duck, 0.0-1.0 (1.0 = full duck to silence).
attack: How fast the duck kicks in, in seconds.
release: How fast the volume comes back, in seconds.
Returns:
Float32 array with sidechain applied.
"""
# Match lengths
min_len = min(len(samples), len(trigger_samples))
trigger = trigger_samples[:min_len]
out = samples.copy()
# Compute trigger envelope
trigger_env = numpy.abs(trigger)
# Smooth the envelope
alpha_attack = 1.0 - numpy.exp(-1.0 / (attack * sample_rate))
alpha_release = 1.0 - numpy.exp(-1.0 / (release * sample_rate))
smoothed = numpy.zeros(len(trigger_env), dtype=numpy.float32)
for i in range(1, len(trigger_env)):
if trigger_env[i] > smoothed[i - 1]:
smoothed[i] = alpha_attack * trigger_env[i] + (1 - alpha_attack) * smoothed[i - 1]
else:
smoothed[i] = alpha_release * trigger_env[i] + (1 - alpha_release) * smoothed[i - 1]
# Normalize envelope to 0-1
peak = numpy.max(smoothed)
if peak > 0:
smoothed /= peak
# Apply ducking
gain = 1.0 - smoothed * amount
out[:min_len] = out[:min_len] * gain
return out
# ── 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_convolution_reverb_stereo(samples, preset="taj_mahal", mix=0.3,
sample_rate=SAMPLE_RATE):
"""Stereo convolution reverb — different IR per channel.
Generates two impulse responses with different random seeds,
producing different noise tails and early reflection jitter for
L and R. The result is a reverb that occupies real stereo space.
Args:
samples: Float32 mono array.
preset: IR preset name.
mix: Wet/dry ratio 0.0–1.0.
sample_rate: Sample rate in Hz.
Returns:
Float32 (N, 2) stereo array.
"""
n = len(samples)
if mix <= 0:
stereo = numpy.zeros((n, 2), dtype=numpy.float32)
stereo[:, 0] = samples
stereo[:, 1] = samples
return stereo
# Generate two different IRs with different seeds
key_l = f"{preset}_{sample_rate}_L"
key_r = f"{preset}_{sample_rate}_R"
if key_l not in _IR_CACHE:
# Temporarily override numpy random state for different IRs
_IR_CACHE[key_l] = _generate_ir(preset, sample_rate)
# Generate second IR — different random noise = different tail
_IR_CACHE[key_r] = _generate_ir(preset, sample_rate)
ir_l = _IR_CACHE[key_l]
ir_r = _IR_CACHE[key_r]
# Convolve separately
wet_l = scipy.signal.fftconvolve(samples, ir_l, mode='full')[:n].astype(numpy.float32)
wet_r = scipy.signal.fftconvolve(samples, ir_r, mode='full')[:n].astype(numpy.float32)
# Normalize to match dry RMS
dry_rms = numpy.sqrt(numpy.mean(samples ** 2)) + 1e-10
for wet in (wet_l, wet_r):
wet_rms = numpy.sqrt(numpy.mean(wet ** 2)) + 1e-10
wet *= dry_rms / wet_rms
stereo = numpy.zeros((n, 2), dtype=numpy.float32)
stereo[:, 0] = samples * (1 - mix) + wet_l * mix
stereo[:, 1] = samples * (1 - mix) + wet_r * mix
return stereo
def _apply_reverb(samples, mix=0.3, decay=1.0, sample_rate=SAMPLE_RATE):
"""Apply a simple Schroeder reverb to a float32 buffer.
Uses 4 parallel comb filters + 2 series allpass filters —
the classic algorithmic reverb topology from Manfred Schroeder (1962).
Args:
samples: Float32 numpy array.
mix: Wet/dry ratio 0.0–1.0.
decay: Tail length in seconds.
sample_rate: Sample rate in Hz.
Returns:
Float32 array with reverb applied.
"""
if mix <= 0:
return samples
n = len(samples)
out = numpy.zeros(n, dtype=numpy.float32)
# Comb filter delays (in samples) — tuned to avoid coloration
comb_delays = [int(d * sample_rate) for d in [0.0297, 0.0371, 0.0411, 0.0437]]
# Feedback gains based on decay time
comb_gains = [0.001 ** (d / sample_rate / decay) for d in comb_delays]
for delay, gain in zip(comb_delays, comb_gains):
buf = numpy.zeros(n + delay, dtype=numpy.float32)
for i in range(n):
buf[i + delay] += samples[i] + gain * buf[i]
out += buf[:n]
out /= len(comb_delays)
# Allpass filters for diffusion
for delay_sec in [0.005, 0.0017]:
delay = int(delay_sec * sample_rate)
gain = 0.7
buf = numpy.zeros(n + delay, dtype=numpy.float32)
result = numpy.zeros(n, dtype=numpy.float32)
for i in range(n):
buf[i + delay] = out[i] + gain * buf[i]
result[i] = -gain * buf[i + delay] + buf[i]
out = result
return samples * (1 - mix) + out * mix
def _apply_reverb_stereo(samples, mix=0.3, decay=1.0, width=0.8,
sample_rate=SAMPLE_RATE):
"""Stereo reverb — different early reflections for L and R channels.
Creates natural stereo width by using slightly different comb filter
delay times for each channel. The result is a reverb tail that
occupies space in the stereo field rather than sitting dead center.
Args:
samples: Float32 mono array (the dry signal).
mix: Wet/dry ratio 0.0–1.0.
decay: Tail length in seconds.
width: Stereo width of the reverb 0.0–1.0.
sample_rate: Sample rate in Hz.
Returns:
Float32 (N, 2) stereo array.
"""
if mix <= 0:
stereo = numpy.zeros((len(samples), 2), dtype=numpy.float32)
stereo[:, 0] = samples
stereo[:, 1] = samples
return stereo
n = len(samples)
# Different comb delays for L and R — creates stereo width
comb_delays_L = [int(d * sample_rate) for d in [0.0297, 0.0371, 0.0411, 0.0437]]
comb_delays_R = [int(d * sample_rate) for d in [0.0313, 0.0389, 0.0427, 0.0453]]
def _run_reverb(comb_delays):
out = numpy.zeros(n, dtype=numpy.float32)
comb_gains = [0.001 ** (d / sample_rate / decay) for d in comb_delays]
for delay, gain in zip(comb_delays, comb_gains):
buf = numpy.zeros(n + delay, dtype=numpy.float32)
for i in range(n):
buf[i + delay] += samples[i] + gain * buf[i]
out += buf[:n]
out /= len(comb_delays)
# Allpass diffusion
for delay_sec in [0.005, 0.0017]:
delay = int(delay_sec * sample_rate)
gain = 0.7
buf = numpy.zeros(n + delay, dtype=numpy.float32)
result = numpy.zeros(n, dtype=numpy.float32)
for i in range(n):
buf[i + delay] = out[i] + gain * buf[i]
result[i] = -gain * buf[i + delay] + buf[i]
out = result
return out
wet_L = _run_reverb(comb_delays_L)
wet_R = _run_reverb(comb_delays_R)
# Crossfeed based on width (0 = mono, 1 = full stereo)
mid = (wet_L + wet_R) * 0.5
side_L = wet_L - mid
side_R = wet_R - mid
final_L = mid + side_L * width
final_R = mid + side_R * width
stereo = numpy.zeros((n, 2), dtype=numpy.float32)
stereo[:, 0] = samples * (1 - mix) + final_L * mix
stereo[:, 1] = samples * (1 - mix) + final_R * mix
return stereo
def _apply_delay(samples, mix=0.25, time=0.375, feedback=0.4,
sample_rate=SAMPLE_RATE):
"""Apply a tempo-synced delay effect.
Args:
samples: Float32 numpy array.
mix: Wet/dry ratio 0.0–1.0.
time: Delay time in seconds (0.375 = dotted 8th at 120bpm).
feedback: How much each echo feeds back (0.0–1.0).
sample_rate: Sample rate in Hz.
Returns:
Float32 array with delay applied.
"""
if mix <= 0:
return samples
delay_samples = int(time * sample_rate)
if delay_samples <= 0:
return samples
n = len(samples)
wet = numpy.zeros(n, dtype=numpy.float32)
buf = numpy.zeros(n, dtype=numpy.float32)
buf[:] = samples
# Generate echo taps
max_echoes = 8
gain = 1.0
for _ in range(max_echoes):
gain *= feedback
if gain < 0.01:
break
shifted = numpy.zeros(n, dtype=numpy.float32)
offset = delay_samples * (_ + 1)
if offset >= n:
break
end = min(n, n - offset)
if end > 0:
shifted[offset:offset + end] = buf[:end] * gain
wet += shifted
return samples * (1 - mix) + wet * mix
def _apply_lowpass(samples, cutoff, q=0.707, sample_rate=SAMPLE_RATE):
"""Apply a 2nd-order Butterworth lowpass filter (12 dB/octave).
A resonant lowpass filter — the sound of analog synthesizers.
At Q=0.707 (Butterworth), the response is maximally flat. Higher
Q values add a resonant peak at the cutoff frequency, emphasizing
that frequency range before rolling off.
Args:
samples: Float32 numpy array.
cutoff: Cutoff frequency in Hz.
q: Resonance / Q factor (default 0.707 = Butterworth flat).
1.0 = slight peak, 2.0 = pronounced peak, 5.0+ = aggressive.
sample_rate: Sample rate in Hz.
Returns:
Float32 array with filter applied.
"""
if cutoff <= 0 or cutoff >= sample_rate / 2:
return samples
# Biquad coefficient calculation
w0 = 2 * numpy.pi * cutoff / sample_rate
alpha = numpy.sin(w0) / (2 * q)
b0 = (1 - numpy.cos(w0)) / 2
b1 = 1 - numpy.cos(w0)
b2 = (1 - numpy.cos(w0)) / 2
a0 = 1 + alpha
a1 = -2 * numpy.cos(w0)
a2 = 1 - alpha
# Normalize
b = numpy.array([b0/a0, b1/a0, b2/a0])
a = numpy.array([1.0, a1/a0, a2/a0])
return scipy.signal.lfilter(b, a, samples).astype(numpy.float32)
def _apply_highpass(samples, cutoff, q=0.707, sample_rate=SAMPLE_RATE):
"""Apply a 2nd-order Butterworth highpass filter (12 dB/octave).
Removes low-frequency content below the cutoff. Useful for cleaning
up mud from pads, keeping bass parts from masking each other, or
thinning out a sound.
Args:
samples: Float32 numpy array.
cutoff: Cutoff frequency in Hz.
q: Resonance / Q factor (default 0.707 = Butterworth flat).
sample_rate: Sample rate in Hz.
Returns:
Float32 array with filter applied.
"""
if cutoff <= 0 or cutoff >= sample_rate / 2:
return samples
w0 = 2 * numpy.pi * cutoff / sample_rate
alpha = numpy.sin(w0) / (2 * q)
b0 = (1 + numpy.cos(w0)) / 2
b1 = -(1 + numpy.cos(w0))
b2 = (1 + numpy.cos(w0)) / 2
a0 = 1 + alpha
a1 = -2 * numpy.cos(w0)
a2 = 1 - alpha
b = numpy.array([b0/a0, b1/a0, b2/a0])
a = numpy.array([1.0, a1/a0, a2/a0])
return scipy.signal.lfilter(b, a, samples).astype(numpy.float32)
def _apply_chorus(samples, mix=0.5, rate=1.5, depth=0.003,
sample_rate=SAMPLE_RATE):
"""Apply a chorus effect — slightly detuned delayed copy mixed in.
Chorus works by duplicating the signal, modulating the copy's delay
time with an LFO, and mixing it back. The varying delay creates
pitch wobble that thickens the sound — like two musicians playing
the same part slightly out of sync.
This is the classic Roland Juno chorus, the Boss CE-1, and every
string ensemble synth ever made.
Args:
samples: Float32 numpy array.
mix: Wet/dry ratio 0.0–1.0.
rate: LFO speed in Hz (default 1.5). 0.5–1 = slow shimmer,
2–4 = fast vibrato, 5+ = Leslie speaker territory.
depth: Modulation depth in seconds (default 0.003 = 3ms).
Controls how far the pitch wobbles.
sample_rate: Sample rate in Hz.
Returns:
Float32 array with chorus applied.
"""
if mix <= 0:
return samples
n = len(samples)
t = numpy.arange(n, dtype=numpy.float32) / sample_rate
# LFO modulates the delay time
base_delay = 0.007 # 7ms base delay
lfo = depth * numpy.sin(2 * numpy.pi * rate * t)
delay_samples = ((base_delay + lfo) * sample_rate).astype(numpy.int32)
# Build the modulated delayed copy
wet = numpy.zeros(n, dtype=numpy.float32)
for i in range(n):
read_pos = i - delay_samples[i]
if 0 <= read_pos < n:
wet[i] = samples[read_pos]
return samples * (1 - mix * 0.5) + wet * mix * 0.5
def _apply_filter_envelope(samples, base_cutoff, amount, f_attack, f_decay,
f_sustain, q=0.707, vel_cutoff_boost=0.0,
sample_rate=SAMPLE_RATE):
"""Apply a per-note filter envelope — cutoff sweeps over time.
This is the core of subtractive synthesis: the filter opens on the
attack and closes during decay, giving notes a characteristic
"bwow" or "wah" shape.
Uses block-based processing (64-sample blocks) with biquad coefficient
interpolation for efficiency and smooth sweeps.
"""
n = len(samples)
if n == 0 or amount <= 0:
return samples
block_size = 64
out = numpy.empty_like(samples)
# Build the filter cutoff envelope
a_samps = int(f_attack * sample_rate)
d_samps = int(f_decay * sample_rate)
sustain_level = f_sustain * amount
cutoff_env = numpy.full(n, sustain_level + base_cutoff + vel_cutoff_boost,
dtype=numpy.float64)
# Attack ramp: 0 → amount
if a_samps > 0:
a_end = min(a_samps, n)
cutoff_env[:a_end] = (base_cutoff + vel_cutoff_boost +
numpy.linspace(0, amount, a_end))
# Decay ramp: amount → sustain_level
if d_samps > 0:
d_start = min(a_samps, n)
d_end = min(a_samps + d_samps, n)
if d_end > d_start:
cutoff_env[d_start:d_end] = (base_cutoff + vel_cutoff_boost +
numpy.linspace(amount, sustain_level,
d_end - d_start))
# Clamp cutoff to valid range
cutoff_env = numpy.clip(cutoff_env, 20.0, sample_rate / 2 - 1)
# Block-based biquad processing with varying cutoff
# State variables for the filter
x1 = x2 = y1 = y2 = 0.0
pos = 0
while pos < n:
end = min(pos + block_size, n)
block = samples[pos:end]
# Use cutoff at block midpoint
mid = (pos + end) // 2
fc = cutoff_env[mid]
# Compute biquad coefficients
w0 = 2 * numpy.pi * fc / sample_rate
sin_w0 = numpy.sin(w0)
cos_w0 = numpy.cos(w0)
alpha = sin_w0 / (2 * q)
b0 = (1 - cos_w0) / 2
b1 = 1 - cos_w0
b2 = (1 - cos_w0) / 2
a0 = 1 + alpha
a1 = -2 * cos_w0
a2 = 1 - alpha
# Normalize
b0 /= a0; b1 /= a0; b2 /= a0
a1 /= a0; a2 /= a0
# Process block sample by sample (maintaining state)
out_block = numpy.empty(len(block), dtype=numpy.float32)
for i in range(len(block)):
x0 = float(block[i])
y0 = b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
out_block[i] = y0
x2 = x1; x1 = x0
y2 = y1; y1 = y0
out[pos:end] = out_block
pos = end
return out
def _apply_saturation(samples, amount=0.5):
"""Apply tape/tube saturation — subtle even-harmonic warmth.
Unlike distortion (tanh, odd harmonics), saturation uses a
polynomial waveshaper that adds 2nd and 4th harmonics — the
warm, pleasing character of analog tape and tube preamps.
"""
if amount <= 0:
return samples
# Asymmetric polynomial: x + k*x^2 adds even harmonics
driven = samples + amount * samples * samples
# Normalize to prevent gain buildup
driven = driven / (1.0 + amount)
# Soft clip any overs
return numpy.clip(driven, -1.0, 1.0).astype(numpy.float32)
def _apply_tremolo(samples, depth=0.5, rate=5.0, sample_rate=SAMPLE_RATE):
"""Apply tremolo — amplitude modulation by a sine LFO.
The classic vibrating amp sound. Essential for vibraphone,
electric guitar, and organ Leslie speaker simulation.
"""
if depth <= 0:
return samples
t = numpy.arange(len(samples), dtype=numpy.float64) / sample_rate
lfo = 1.0 - depth * 0.5 * (1.0 + numpy.sin(2 * numpy.pi * rate * t))
return (samples * lfo).astype(numpy.float32)
def _apply_phaser(samples, mix=0.5, rate=0.5, stages=4,
sample_rate=SAMPLE_RATE):
"""Apply phaser — swept allpass filter chain.
Creates moving notches in the frequency spectrum by passing
the signal through a chain of allpass filters whose center
frequencies are modulated by an LFO. Classic effect for
electric piano, pads, and guitar.
"""
if mix <= 0:
return samples
n = len(samples)
block_size = 64
t = numpy.arange(n, dtype=numpy.float64) / sample_rate
# LFO sweeps center frequency between 200Hz and 4000Hz (log scale)
lfo = 0.5 + 0.5 * numpy.sin(2 * numpy.pi * rate * t)
center_freqs = 200.0 * (20.0 ** lfo) # 200Hz to 4000Hz
# Process through allpass stages
wet = samples.copy().astype(numpy.float64)
for _stage in range(stages):
out = numpy.empty(n, dtype=numpy.float64)
# Allpass state
x1 = x2 = y1 = y2 = 0.0
pos = 0
while pos < n:
end = min(pos + block_size, n)
mid = (pos + end) // 2
fc = center_freqs[mid]
# Allpass biquad coefficients
w0 = 2 * numpy.pi * fc / sample_rate
alpha = numpy.sin(w0) / 2.0 # Q=0.5 for wide sweep
cos_w0 = numpy.cos(w0)
b0 = 1 - alpha
b1 = -2 * cos_w0
b2 = 1 + alpha
a0 = 1 + alpha
a1 = -2 * cos_w0
a2 = 1 - alpha
b0 /= a0; b1 /= a0; b2 /= a0
a1 /= a0; a2 /= a0
for i in range(pos, end):
x0 = wet[i]
y0 = b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
out[i] = y0
x2 = x1; x1 = x0
y2 = y1; y1 = y0
pos = end
wet = out
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).
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.5–20.0.
0.5–2 = subtle warmth (tube preamp)
3–8 = overdrive (cranked amp)
10+ = fuzz/distortion
mix: Wet/dry ratio 0.0–1.0.
Returns:
Float32 array with distortion applied.
"""
if mix <= 0 or drive <= 0:
return samples
# Multi-stage gain + clipping like a real amp:
# Stage 1: preamp gain — push the signal hard
stage1 = numpy.tanh(samples * drive)
# Stage 2: power amp — clip again with more gain for sustain and grit
stage2 = numpy.tanh(stage1 * drive * 0.5)
# Stage 3: at high drive, add asymmetric clipping (tube rectifier sag)
if drive > 3.0:
# Positive peaks clip harder than negative — asymmetric harmonics
driven = numpy.where(stage2 > 0,
numpy.tanh(stage2 * 1.5),
numpy.tanh(stage2 * 1.2))
else:
driven = stage2
return samples * (1 - mix) + driven * mix
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 → 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:
samples = _apply_tremolo(samples, depth=params["tremolo_depth"],
rate=params.get("tremolo_rate", 5.0))
if params.get("distortion_mix", 0) > 0:
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"],
rate=params.get("chorus_rate", 1.5),
depth=params.get("chorus_depth", 0.003))
if params.get("phaser_mix", 0) > 0:
samples = _apply_phaser(samples, mix=params["phaser_mix"],
rate=params.get("phaser_rate", 0.5))
if params.get("highpass", 0) > 0:
samples = _apply_highpass(samples, params["highpass"],
params.get("highpass_q", 0.707))
if params.get("lowpass", 0) > 0:
samples = _apply_lowpass(samples, params["lowpass"],
params.get("lowpass_q", 0.707))
if params.get("delay_mix", 0) > 0:
samples = _apply_delay(samples, mix=params["delay_mix"],
time=params.get("delay_time", 0.375),
feedback=params.get("delay_feedback", 0.4))
if not skip_reverb and params.get("reverb_mix", 0) > 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
def _apply_part_effects(samples, part):
"""Apply all effects configured on a Part to a float32 buffer."""
params = {
"saturation": part.saturation,
"tremolo_depth": part.tremolo_depth,
"tremolo_rate": part.tremolo_rate,
"distortion_mix": part.distortion_mix,
"distortion_drive": part.distortion_drive,
"chorus_mix": part.chorus_mix,
"chorus_rate": part.chorus_rate,
"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,
"lowpass_q": part.lowpass_q,
"delay_mix": part.delay_mix,
"delay_time": part.delay_time,
"delay_feedback": part.delay_feedback,
"reverb_mix": part.reverb_mix,
"reverb_decay": part.reverb_decay,
"reverb_type": getattr(part, "reverb_type", "algorithmic"),
}
# Skip mono reverb — stereo reverb is applied in the mixer
return _apply_effects_with_params(samples, params, skip_reverb=True)
def _pan_to_stereo(mono, pan=0.0):
"""Pan a mono buffer into a stereo (N, 2) array.
Args:
mono: Float32 1D array.
pan: -1.0 (full left) to 1.0 (full right). 0.0 = center.
Returns:
Float32 (N, 2) array.
"""
# Constant-power panning (equal loudness across the field)
angle = (pan + 1.0) * 0.25 * numpy.pi # 0 to pi/2
left_gain = numpy.cos(angle)
right_gain = numpy.sin(angle)
stereo = numpy.zeros((len(mono), 2), dtype=numpy.float32)
stereo[:, 0] = mono * left_gain
stereo[:, 1] = mono * right_gain
return stereo
def _master_compress(samples, threshold=0.7, ratio=4.0, attack=0.002,
release=0.05, makeup=True, limiter=True,
sample_rate=SAMPLE_RATE):
"""Master bus compressor with brick-wall limiter.
Makes the mix louder, punchier, and more cohesive. Reduces the
dynamic range so quiet parts come up and loud parts are controlled —
the difference between a bedroom demo and a finished mix.
The compressor uses feed-forward gain reduction with envelope
following. The limiter is a brick-wall at 0.95 to prevent clipping.
Args:
samples: Float32 numpy array (the full mix).
threshold: Level above which compression kicks in (0.0–1.0).
Lower = more compression. 0.3–0.5 is typical for a master.
ratio: Compression ratio above threshold (default 4:1).
2:1 = gentle, 4:1 = moderate, 10:1 = heavy, inf = limiter.
attack: How fast the compressor reacts, in seconds.
release: How fast it lets go, in seconds.
makeup: If True, apply makeup gain to restore loudness.
limiter: If True, apply brick-wall limiter at 0.95.
sample_rate: Sample rate in Hz.
Returns:
Float32 array — compressed and limited.
"""
if len(samples) == 0:
return samples
# Compute envelope (absolute value, smoothed)
env = numpy.abs(samples)
alpha_a = 1.0 - numpy.exp(-1.0 / (attack * sample_rate))
alpha_r = 1.0 - numpy.exp(-1.0 / (release * sample_rate))
smoothed = numpy.zeros(len(env), dtype=numpy.float32)
smoothed[0] = env[0]
for i in range(1, len(env)):
if env[i] > smoothed[i - 1]:
smoothed[i] = alpha_a * env[i] + (1 - alpha_a) * smoothed[i - 1]
else:
smoothed[i] = alpha_r * env[i] + (1 - alpha_r) * smoothed[i - 1]
# Compute gain reduction
gain = numpy.ones(len(samples), dtype=numpy.float32)
above = smoothed > threshold
if numpy.any(above):
# dB domain compression
over = smoothed[above] / threshold
reduced = threshold * (over ** (1.0 / ratio))
gain[above] = reduced / smoothed[above]
# Apply gain
compressed = samples * gain
# Makeup gain — bring the level back up, but cap at 3x
# so sparse arrangements don't get over-amplified
if makeup:
peak = numpy.max(numpy.abs(compressed))
if peak > 0:
desired_gain = 0.9 / peak
compressed = compressed * min(desired_gain, 3.0)
# Brick-wall limiter — hard clip at 0.95
if limiter:
compressed = numpy.clip(compressed, -0.95, 0.95)
return compressed
def _resolve_synth(name):
"""Map synth name string to wave function."""
return _SYNTH_FUNCTIONS.get(name, sine_wave)
def _resolve_envelope(name):
"""Map envelope name string to Envelope enum value tuple."""
_map = {e.name.lower(): e.value for e in Envelope}
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,
swing=0.0, tempo_map=None, humanize=0.0,
detune=0.0, spread=0.0, stereo_buf=None,
sub_osc=0.0, noise_mix=0.0,
filter_attack=0.01, filter_decay=0.3,
filter_sustain=0.0, filter_amount=0.0,
vel_to_filter=0.0, filter_q=0.707,
synth_kwargs=None, temperament="equal",
reference_pitch=440.0, analog=0.0):
"""Render a list of Notes into an existing buffer at the correct positions."""
import random as _rnd
a, d, s, r = envelope_tuple
_skw = synth_kwargs or {}
_synth_cache = {} # (hz, n_samples) → waveform, avoids resynthesizing same note
beat_pos = 0.0
for note_index, note in enumerate(notes):
if note.tone is not None:
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
# Humanize: random timing offset (±fraction of a beat)
if humanize > 0.0:
max_offset = int(humanize * 0.05 * samples_per_beat)
start += _rnd.randint(-max_offset, max_offset)
start = max(0, start)
dur_ms = note.beats * 60_000 / bpm
# Articulation: adjust duration and velocity
art = getattr(note, 'articulation', '')
art_vel_mult = 1.0
art_attack_mult = 1.0 # multiplier for envelope attack
if art == 'staccato':
dur_ms *= 0.4 # short and bouncy
elif art == 'legato':
dur_ms *= 1.15 # slight overlap into next note
elif art == 'marcato':
art_vel_mult = 1.25 # heavier
art_attack_mult = 0.3 # sharper attack
elif art == 'tenuto':
art_attack_mult = 1.8 # softer attack, full duration
elif art == 'accent':
art_vel_mult = 1.2
elif art == 'fermata':
dur_ms *= 1.5 # held longer
n_samples = int(SAMPLE_RATE * dur_ms / 1000)
if start + n_samples > total_samples:
n_samples = total_samples - start
if n_samples > 0 and start >= 0:
# Drum hit via Part.hit() — use drum synth directly
from .rhythm import _DrumTone
if isinstance(note.tone, _DrumTone):
drum_wave = _render_drum_hit(note.tone.sound.value, n_samples)
mixed = drum_wave.astype(numpy.float32)
# Staccato fade-out for drums
if art == 'staccato':
fade_len = min(int(SAMPLE_RATE * 0.01), len(mixed))
if fade_len > 0:
mixed[-fade_len:] *= numpy.linspace(1.0, 0.0, fade_len).astype(numpy.float32)
vel = getattr(note, 'velocity', 100)
vel = min(127, int(vel * art_vel_mult))
if humanize > 0.0:
vel_jitter = int(humanize * 15)
vel = max(1, min(127, vel + _rnd.randint(-vel_jitter, vel_jitter)))
vel_scale = vel / 127.0
end = min(start + len(mixed), total_samples)
buf[start:end] += mixed[:end - start] * volume * vel_scale
if not getattr(note, '_hold', False):
beat_pos += note.beats
continue
# Get pitches
if hasattr(note.tone, 'tones'):
pitches = [t.pitch(temperament=temperament, reference_pitch=reference_pitch) for t in note.tone.tones]
else:
pitches = [note.tone.pitch(temperament=temperament, reference_pitch=reference_pitch)]
# Analog drift: slight random pitch offset per note,
# simulating analog oscillator instability. Each note
# gets a unique drift amount (±cents scaled by analog).
if analog > 0:
pitches = [hz * (2 ** (_rnd.gauss(0, analog * 5) / 1200))
for hz in pitches]
# Pitch bend: render at base pitch, then resample to shift
# pitch over time. Resampling preserves the synth's timbre
# perfectly — no sine waves, no retriggering.
bend_amt = getattr(note, 'bend', 0.0)
if bend_amt != 0:
bend_type = getattr(note, 'bend_type', 'smooth')
t_norm = numpy.linspace(0, 1, n_samples)
waves = []
for hz in pitches:
hz_end = hz * (2 ** (bend_amt / 12))
# Build pitch ratio curve (1.0 = no shift)
if bend_type == 'smooth':
ratio = (hz_end / hz) ** t_norm
elif bend_type == 'linear':
ratio = 1.0 + (hz_end / hz - 1.0) * t_norm
elif bend_type == 'late':
late_t = numpy.clip((t_norm - 0.6) / 0.4, 0.0, 1.0)
ratio = (hz_end / hz) ** late_t
else:
ratio = (hz_end / hz) ** t_norm
# Render a longer buffer at base pitch
max_ratio = max(ratio.max(), 1.0)
src_len = int(n_samples * max_ratio) + 100
src = synth_fn(hz, n_samples=src_len, **_skw)
src_f = src.astype(numpy.float64) / SAMPLE_PEAK
# Variable-rate resampling: read through source
# at speed determined by the ratio curve
read_pos = numpy.cumsum(ratio)
read_pos = (read_pos - read_pos[0]).astype(numpy.float64)
# Clamp to source bounds
read_pos = numpy.clip(read_pos, 0, src_len - 2)
# Linear interpolation
idx = read_pos.astype(numpy.int64)
frac = read_pos - idx
bent = src_f[idx] * (1 - frac) + src_f[numpy.minimum(idx + 1, src_len - 1)] * frac
waves.append((bent * SAMPLE_PEAK).astype(numpy.int16))
else:
# Per-note kwargs (e.g. lyric for vocal synth)
note_skw = dict(_skw)
note_lyric = getattr(note, 'lyric', '')
if note_lyric:
note_skw['lyric'] = note_lyric
# Render oscillators (cached per hz+n_samples)
waves = []
for hz in pitches:
cache_key = (hz, n_samples, tuple(sorted(note_skw.items())))
if cache_key in _synth_cache:
waves.append(_synth_cache[cache_key].copy())
else:
w = synth_fn(hz, n_samples=n_samples, **note_skw)
_synth_cache[cache_key] = w
waves.append(w)
# Sub-oscillator: octave-below sine
if sub_osc > 0:
for hz in pitches:
sub = sine_wave(hz / 2, n_samples=n_samples)
waves.append(sub)
# Detune: add oscillators shifted by ±cents
detune_up = None
detune_down = None
if detune > 0:
up_waves = []
down_waves = []
for hz in pitches:
hz_up = hz * (2 ** (detune / 1200))
hz_down = hz * (2 ** (-detune / 1200))
up_waves.append(synth_fn(hz_up, n_samples=n_samples, **_skw))
down_waves.append(synth_fn(hz_down, n_samples=n_samples, **_skw))
if spread > 0 and stereo_buf is not None:
# Spread: detuned oscillators go to opposite channels
detune_up = sum(w.astype(numpy.float32) for w in up_waves) / SAMPLE_PEAK
detune_down = sum(w.astype(numpy.float32) for w in down_waves) / SAMPLE_PEAK
else:
waves.extend(up_waves + down_waves)
n_osc = len(waves)
mixed = sum(w.astype(numpy.float32) for w in waves) / (SAMPLE_PEAK * max(1, n_osc))
# Mix sub-oscillator with appropriate gain
if sub_osc > 0:
# Sub was already included in waves; scale the mix
# Boost the sub contribution relative to main
sub_count = len(pitches)
main_count = n_osc - sub_count
if main_count > 0:
# Re-render: main only + sub at controlled level
main_waves = waves[:n_osc - sub_count]
sub_waves = waves[n_osc - sub_count:]
main_mix = sum(w.astype(numpy.float32) for w in main_waves) / (SAMPLE_PEAK * max(1, len(main_waves)))
sub_mix = sum(w.astype(numpy.float32) for w in sub_waves) / (SAMPLE_PEAK * max(1, len(sub_waves)))
mixed = main_mix * (1.0 - sub_osc * 0.3) + sub_mix * sub_osc * 0.3
# Noise layer: add noise following the note
if noise_mix > 0:
noise = numpy.random.uniform(-1, 1, n_samples).astype(numpy.float32)
mixed = mixed * (1.0 - noise_mix * 0.5) + noise * noise_mix * 0.5
# Amplitude envelope (articulation may adjust attack)
art_a = a * art_attack_mult
if art_a > 0 or d > 0 or s < 1.0 or r > 0:
mixed = _apply_envelope(mixed, art_a, d, s, r)
# Staccato: apply a quick fade-out at the end
if art == 'staccato':
fade_len = min(int(SAMPLE_RATE * 0.01), len(mixed))
if fade_len > 0:
mixed[-fade_len:] *= numpy.linspace(1.0, 0.0, fade_len).astype(numpy.float32)
# Per-note velocity (articulation may boost)
vel = getattr(note, 'velocity', 100)
vel = min(127, int(vel * art_vel_mult))
if humanize > 0.0:
vel_jitter = int(humanize * 15)
vel = max(1, min(127, vel + _rnd.randint(-vel_jitter, vel_jitter)))
vel_scale = vel / 127.0
# Filter envelope (per-note subtractive filter sweep)
if filter_amount > 0:
base_cut = 200.0 # base cutoff before envelope opens it
vel_boost = vel_to_filter * vel_scale if vel_to_filter > 0 else 0.0
mixed = _apply_filter_envelope(
mixed, base_cut, filter_amount,
filter_attack, filter_decay, filter_sustain,
q=filter_q, vel_cutoff_boost=vel_boost)
elif vel_to_filter > 0:
# Velocity brightness without filter envelope
vel_cutoff = vel_to_filter * vel_scale + 1000
mixed = _apply_lowpass(mixed, vel_cutoff, q=filter_q)
end = min(start + len(mixed), total_samples)
buf[start:end] += mixed[:end - start] * volume * vel_scale
# Spread detuned oscillators into stereo L/R
if detune_up is not None and stereo_buf is not None:
spread_amt = spread
up_env = detune_up[:end - start]
down_env = detune_down[:end - start]
if a > 0 or d > 0 or s < 1.0 or r > 0:
up_env = _apply_envelope(up_env.copy(), a, d, s, r)
down_env = _apply_envelope(down_env.copy(), a, d, s, r)
gain = volume * vel_scale * 0.5
# Right channel gets up-detuned, left gets down-detuned
stereo_buf[start:end, 1] += up_env * gain * spread_amt
stereo_buf[start:end, 0] += down_env * gain * spread_amt
# hold() notes don't advance the beat position
if not getattr(note, '_hold', False):
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, swing=0.0, tempo_map=None,
temperament="equal", reference_pitch=440.0):
"""Render notes as one continuous waveform with pitch glide.
Instead of rendering each note separately with its own envelope,
legato mode generates a single continuous oscillator whose
frequency changes at note boundaries. The envelope is applied
once over the entire phrase — attack at the start, release at
the end, sustain throughout.
When glide > 0, the frequency slides smoothly between consecutive
pitches using exponential interpolation (so slides sound linear
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, velocity)
beat_pos = 0.0
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(temperament=temperament, reference_pitch=reference_pitch)
else:
hz = note.tone.pitch(temperament=temperament, reference_pitch=reference_pitch)
events.append((start, end, hz, vel))
else:
events.append((start, end, 0, vel)) # rest
if not getattr(note, '_hold', False):
beat_pos += note.beats
if not events:
return
# Build the frequency curve with glide
glide_samples = int(glide_time * SAMPLE_RATE)
freq_curve = numpy.zeros(total_samples, dtype=numpy.float64)
amp_curve = numpy.zeros(total_samples, dtype=numpy.float32)
prev_hz = 0
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] = 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)
g_len = g_end - start
if g_len > 0:
t = numpy.linspace(0, 1, g_len)
# Log interpolation for perceptually linear pitch slide
freq_curve[start:g_end] = prev_hz * (hz / prev_hz) ** t
freq_curve[g_end:end] = hz
else:
freq_curve[start:end] = hz
else:
freq_curve[start:end] = hz
prev_hz = hz
else:
# Rest: silence but keep prev_hz for next glide
amp_curve[start:end] = 0.0
freq_curve[start:end] = prev_hz if prev_hz > 0 else 440
# Generate continuous waveform from frequency curve
# Use phase accumulation for smooth frequency changes
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, scaled by velocity)
wave *= amp_curve
# Apply single envelope over the entire active region
# Find first and last non-zero samples
active = numpy.nonzero(amp_curve)[0]
if len(active) == 0:
return
first = active[0]
last = active[-1] + 1
a, d, s, r = envelope_tuple
if a > 0 or d > 0 or s < 1.0 or r > 0:
env_buf = wave[first:last].copy()
env_buf = _apply_envelope(env_buf, a, d, s, r)
wave[first:last] = env_buf
end = min(len(wave), total_samples)
buf[:end] += wave[:end] * volume
[docs]
def render_score(score):
"""Render a Score to a float32 audio buffer.
Mixes all parts (named and default), plus drum hits, into a
single normalized buffer.
Args:
score: A :class:`Score` object.
Returns:
Float32 stereo numpy array (N, 2).
"""
# 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
if has_tempo_changes:
total_samples = _total_samples_from_tempo_map(total_beats, tempo_map)
else:
total_samples = int(total_beats * samples_per_beat)
# Stereo master buffer
stereo_buf = numpy.zeros((total_samples, 2), dtype=numpy.float32)
# Mono buffer for backwards-compat rendering
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,
swing=score.swing, tempo_map=tempo_map if has_tempo_changes else None)
# Named parts — each rendered to own buffer for per-part effects
_pending_sidechain = []
for part in score.parts.values():
if part.is_drums:
continue # drums are rendered separately via _drum_hits
if part.notes:
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
# Build synth-specific kwargs (e.g. FM ratio/index, tape, folds)
synth_kwargs = dict(getattr(part, 'synth_kw', None) or {})
if part.synth in ("fm",):
synth_kwargs["mod_ratio"] = part.fm_ratio
synth_kwargs["mod_index"] = part.fm_index
_temperament = getattr(score, 'temperament', 'equal')
_ref_pitch = getattr(score, 'reference_pitch', 440.0)
n_ensemble = max(1, getattr(part, 'ensemble', 1))
if n_ensemble > 1:
# FAST ENSEMBLE: render once, duplicate with time shifts
# Render the "reference" voice with light humanize
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, swing=effective_swing,
tempo_map=tempo_map if has_tempo_changes else None,
temperament=_temperament, reference_pitch=_ref_pitch)
else:
_render_notes_to_buf(
part.notes, part_buf, samples_per_beat, total_samples,
synth_fn, env_tuple, part.volume, score.bpm,
swing=effective_swing,
tempo_map=tempo_map if has_tempo_changes else None,
humanize=part.humanize,
detune=part.detune,
spread=part.spread,
stereo_buf=stereo_buf,
sub_osc=part.sub_osc,
noise_mix=part.noise_mix,
filter_attack=part.filter_attack,
filter_decay=part.filter_decay,
filter_sustain=part.filter_sustain,
filter_amount=part.filter_amount,
vel_to_filter=part.vel_to_filter,
filter_q=part.lowpass_q,
synth_kwargs=synth_kwargs,
temperament=_temperament,
reference_pitch=_ref_pitch,
analog=part.analog)
# Now duplicate with per-player offsets (cheap buffer ops)
import random as _ens_rnd
ref_buf = part_buf.copy()
part_buf *= 1.0 / n_ensemble # scale down the reference voice
for _ens_i in range(1, n_ensemble):
_ens_rnd.seed(42 + _ens_i * 7)
_player_tendency = _ens_rnd.gauss(0, 0.018)
shift_samples = int(_player_tendency * samples_per_beat)
voice = ref_buf.copy()
# Time shift — player rushes or drags
if shift_samples > 0 and shift_samples < total_samples:
voice[shift_samples:] = voice[:-shift_samples].copy()
voice[:shift_samples] = 0
elif shift_samples < 0 and abs(shift_samples) < total_samples:
voice[:shift_samples] = voice[-shift_samples:].copy()
voice[shift_samples:] = 0
# Slight velocity variation per voice
vel_var = 1.0 + _ens_rnd.gauss(0, 0.04)
voice *= vel_var
part_buf += voice / n_ensemble
else:
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, swing=effective_swing,
tempo_map=tempo_map if has_tempo_changes else None,
temperament=_temperament, reference_pitch=_ref_pitch)
else:
_render_notes_to_buf(
part.notes, part_buf, samples_per_beat, total_samples,
synth_fn, env_tuple, part.volume, score.bpm,
swing=effective_swing,
tempo_map=tempo_map if has_tempo_changes else None,
humanize=part.humanize,
detune=part.detune,
spread=part.spread,
stereo_buf=stereo_buf,
sub_osc=part.sub_osc,
noise_mix=part.noise_mix,
filter_attack=part.filter_attack,
filter_decay=part.filter_decay,
filter_sustain=part.filter_sustain,
filter_amount=part.filter_amount,
vel_to_filter=part.vel_to_filter,
filter_q=part.lowpass_q,
synth_kwargs=synth_kwargs,
temperament=_temperament,
reference_pitch=_ref_pitch,
analog=part.analog)
# Apply effects — segmented if automation exists
auto_points = part._get_automation_points()
if auto_points:
# Split buffer at automation boundaries, process each segment
boundaries = sorted(set([0.0] + auto_points + [total_beats]))
for i in range(len(boundaries) - 1):
seg_start_beat = boundaries[i]
seg_end_beat = boundaries[i + 1]
seg_start = int(seg_start_beat * samples_per_beat)
seg_end = min(int(seg_end_beat * samples_per_beat),
total_samples)
if seg_end <= seg_start:
continue
params = part._get_params_at(seg_start_beat)
segment = part_buf[seg_start:seg_end].copy()
has_fx = any(params.get(k, 0) > 0 for k in
["saturation", "tremolo_depth",
"distortion_mix", "chorus_mix", "phaser_mix",
"highpass", "lowpass", "delay_mix",
"reverb_mix"])
if has_fx:
segment = _apply_effects_with_params(segment, params)
# Apply volume automation
seg_vol = params.get("volume", part.volume)
if seg_vol != part.volume:
segment = segment * (seg_vol / part.volume) if part.volume > 0 else segment
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.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)
if has_fx:
part_buf = _apply_part_effects(part_buf, part)
# Apply sidechain compression if enabled
if getattr(part, 'sidechain', 0) > 0:
_pending_sidechain.append((part, part_buf))
else:
# Pan mono part into stereo, then apply stereo reverb
if part.reverb_mix > 0:
rev_type = getattr(part, 'reverb_type', 'algorithmic')
conv_presets = ('taj_mahal', 'cathedral', 'plate',
'spring', 'cave', 'parking_garage', 'canyon')
if rev_type in conv_presets:
# Stereo convolution reverb
rev_stereo = _apply_convolution_reverb_stereo(
part_buf, preset=rev_type,
mix=part.reverb_mix)
else:
# Stereo algorithmic reverb
rev_stereo = _apply_reverb_stereo(
part_buf, mix=part.reverb_mix,
decay=part.reverb_decay)
# Apply pan offset to the stereo reverb
if part.pan != 0:
angle = (part.pan + 1.0) * 0.25 * numpy.pi
rev_stereo[:, 0] *= numpy.cos(angle)
rev_stereo[:, 1] *= numpy.sin(angle)
stereo_buf += rev_stereo
else:
stereo_buf += _pan_to_stereo(part_buf, part.pan)
# Drum pan map — how a real kit is mic'd from the audience perspective
from .rhythm import DrumSound
_DRUM_PAN = {
DrumSound.KICK.value: 0.0, # center
DrumSound.SNARE.value: 0.0, # center
DrumSound.RIMSHOT.value: -0.1, # slightly left
DrumSound.CLAP.value: 0.0, # center
DrumSound.CLOSED_HAT.value: 0.3, # right
DrumSound.OPEN_HAT.value: 0.3, # right
DrumSound.PEDAL_HAT.value: 0.3, # right
DrumSound.LOW_TOM.value: 0.4, # right
DrumSound.MID_TOM.value: 0.0, # center
DrumSound.HIGH_TOM.value: -0.3, # left
DrumSound.CRASH.value: -0.4, # left
DrumSound.RIDE.value: 0.4, # right
DrumSound.RIDE_BELL.value: 0.4, # right
DrumSound.COWBELL.value: 0.2, # slightly right
DrumSound.CLAVE.value: -0.2, # slightly left
DrumSound.SHAKER.value: 0.35, # right
DrumSound.TAMBOURINE.value: -0.25, # slightly left
DrumSound.CONGA_HIGH.value: -0.3, # left
DrumSound.CONGA_LOW.value: 0.2, # slightly right
DrumSound.BONGO_HIGH.value: -0.2, # slightly left
DrumSound.BONGO_LOW.value: 0.15, # slightly right
DrumSound.TIMBALE_HIGH.value: -0.25,
DrumSound.TIMBALE_LOW.value: 0.2,
DrumSound.AGOGO_HIGH.value: -0.3,
DrumSound.AGOGO_LOW.value: 0.25,
DrumSound.GUIRO.value: -0.15,
DrumSound.MARACAS.value: 0.3,
# Tabla: dayan (right drum) slightly right, bayan slightly left
DrumSound.TABLA_NA.value: 0.2,
DrumSound.TABLA_TIN.value: 0.2,
DrumSound.TABLA_TIT.value: 0.25,
DrumSound.TABLA_GE.value: -0.2,
DrumSound.TABLA_KE.value: -0.2,
DrumSound.TABLA_DHA.value: 0.0, # both drums = center
DrumSound.TABLA_GE_BEND.value: -0.2,
# Dhol: bass left, treble right
DrumSound.DHOL_DAGGA.value: -0.2,
DrumSound.DHOL_TILLI.value: 0.2,
DrumSound.DHOL_BOTH.value: 0.0,
# Dholak: similar to dhol
DrumSound.DHOLAK_GE.value: -0.15,
DrumSound.DHOLAK_NA.value: 0.15,
DrumSound.DHOLAK_TIT.value: 0.2,
# Mridangam: bass left, treble right
DrumSound.MRIDANGAM_THAM.value: -0.2,
DrumSound.MRIDANGAM_NAM.value: 0.2,
DrumSound.MRIDANGAM_DIN.value: 0.0,
DrumSound.MRIDANGAM_THA.value: 0.15,
# Djembe: centered (single drum)
DrumSound.DJEMBE_BASS.value: 0.0,
DrumSound.DJEMBE_TONE.value: 0.1,
DrumSound.DJEMBE_SLAP.value: -0.1,
# Doumbek
DrumSound.DOUMBEK_DUM.value: 0.0,
DrumSound.DOUMBEK_TEK.value: 0.1,
DrumSound.DOUMBEK_KA.value: -0.1,
# Cajon — centered (single instrument)
DrumSound.CAJON_BASS.value: 0.0,
DrumSound.CAJON_SLAP.value: 0.0,
DrumSound.CAJON_SLAP_SNARE.value: 0.0,
DrumSound.CAJON_TAP.value: 0.1,
# Metal kit
DrumSound.METAL_KICK.value: 0.0,
DrumSound.METAL_SNARE.value: 0.0,
DrumSound.METAL_HAT.value: 0.3,
# Marching — centered
DrumSound.MARCH_SNARE.value: 0.0,
DrumSound.MARCH_RIMSHOT.value: 0.0,
DrumSound.MARCH_CLICK.value: 0.0,
# Quads — spread across the field
DrumSound.QUAD_1.value: -0.3,
DrumSound.QUAD_2.value: -0.1,
DrumSound.QUAD_3.value: 0.1,
DrumSound.QUAD_4.value: 0.3,
DrumSound.QUAD_SPOCK.value: 0.0,
# Bass drums — spread wide
DrumSound.BASS_1.value: -0.5,
DrumSound.BASS_2.value: -0.25,
DrumSound.BASS_3.value: 0.0,
DrumSound.BASS_4.value: 0.25,
DrumSound.BASS_5.value: 0.5,
}
# 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 (kick only)
drum_stereo = numpy.zeros((total_samples, 2), dtype=numpy.float32)
drum_swing = score.swing
drum_humanize = getattr(score, '_drum_humanize', 0.15)
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)
# Track last hit position per sound for choke (new hit dampens
# the previous ring on the same drum)
_last_hit_start = {}
_resonance = {} # sound_id → resonance level (0.0–1.0)
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
# Choke: if the same sound was hit recently, fade out
# the tail at this point (new hit dampens old resonance)
sound_id = hit.sound.value
if sound_id in _last_hit_start:
prev_start = _last_hit_start[sound_id]
# Quick 2ms fade-out at the new hit position
fade_len = min(int(SAMPLE_RATE * 0.002), max(0, start - prev_start))
if fade_len > 0 and start > 0:
fade = numpy.linspace(1.0, 0.0, fade_len).astype(numpy.float32)
fade_start = max(0, start - fade_len)
for ch in range(2):
part_stereo[fade_start:start, ch] *= fade
_last_hit_start[sound_id] = start
# Cross-choke: a new hit on one sound dampens the ring of
# related sounds on the same instrument (e.g. djembe slap
# kills the bass resonance, closed hat kills open hat).
_CHOKE_GROUPS = {
# Djembe — any strike dampens the others
DrumSound.DJEMBE_BASS.value: (DrumSound.DJEMBE_TONE.value, DrumSound.DJEMBE_SLAP.value),
DrumSound.DJEMBE_TONE.value: (DrumSound.DJEMBE_BASS.value, DrumSound.DJEMBE_SLAP.value),
DrumSound.DJEMBE_SLAP.value: (DrumSound.DJEMBE_BASS.value, DrumSound.DJEMBE_TONE.value),
# Hi-hats — closed chokes open
DrumSound.CLOSED_HAT.value: (DrumSound.OPEN_HAT.value,),
DrumSound.PEDAL_HAT.value: (DrumSound.OPEN_HAT.value,),
# Cajón — slap dampens bass ring
DrumSound.CAJON_SLAP.value: (DrumSound.CAJON_BASS.value,),
DrumSound.CAJON_TAP.value: (DrumSound.CAJON_BASS.value,),
# Doumbek — tek/ka dampen dum
DrumSound.DOUMBEK_TEK.value: (DrumSound.DOUMBEK_DUM.value,),
DrumSound.DOUMBEK_KA.value: (DrumSound.DOUMBEK_DUM.value,),
}
choke_targets = _CHOKE_GROUPS.get(sound_id, ())
for target_id in choke_targets:
if target_id in _last_hit_start:
prev_start = _last_hit_start[target_id]
fade_len = min(int(SAMPLE_RATE * 0.004), max(0, start - prev_start))
if fade_len > 0 and start > 0:
fade = numpy.linspace(1.0, 0.0, fade_len).astype(numpy.float32)
fade_start = max(0, start - fade_len)
for ch in range(2):
part_stereo[fade_start:start, ch] *= fade
remaining = total_samples - start
hit_len = min(int(SAMPLE_RATE * 0.5), remaining)
wave = _render_drum_hit_cached(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
# Sympathetic resonance: marching snare builds up buzz
# as hits accumulate. Each hit adds to a resonance counter
# that scales extra snare wire buzz into the sound.
_RESONANCE_SOUNDS = {
DrumSound.MARCH_SNARE.value, DrumSound.MARCH_RIMSHOT.value,
}
if sound_id in _RESONANCE_SOUNDS:
reso = _resonance.get(sound_id, 0.0)
# Decay based on gap since last hit
if sound_id in _last_hit_start:
gap_samples = start - _last_hit_start[sound_id]
gap_sec = gap_samples / SAMPLE_RATE
if gap_sec > 1.0:
reso *= 0.2
elif gap_sec > 0.5:
reso *= 0.5
elif gap_sec > 0.25:
reso *= 0.8
# Build up (caps at 0.6)
reso = min(0.6, reso + 0.08)
_resonance[sound_id] = reso
# Add sympathetic buzz proportional to resonance
if reso > 0.1:
buzz_len = min(int(SAMPLE_RATE * 0.06), hit_len)
buzz = _noise(buzz_len) * reso * 0.18
if buzz_len > 20:
bl, al = scipy.signal.butter(
2, [3000, 9000], btype='band', fs=SAMPLE_RATE)
buzz = scipy.signal.lfilter(bl, al, buzz)
buzz *= _exp_decay(buzz_len, 25)
wave[:buzz_len] = wave[:buzz_len] + buzz.astype(numpy.float32)
mono_hit = wave * vel_scale * 0.7 * drum_part.volume
# 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.saturation > 0 or drum_part.tremolo_depth > 0
or drum_part.phaser_mix > 0
or drum_part.highpass > 0 or 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):
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:
part_buf = _apply_sidechain(
part_buf, drum_buf,
amount=part.sidechain,
release=part.sidechain_release)
stereo_buf += _pan_to_stereo(part_buf, part.pan)
# Default notes (mono, center)
if score.notes:
stereo_buf += _pan_to_stereo(buf, 0.0)
# Drums: stereo panned (with effects already applied)
stereo_buf += drum_stereo
# Master bus compressor/limiter (per channel)
stereo_buf[:, 0] = _master_compress(stereo_buf[:, 0])
stereo_buf[:, 1] = _master_compress(stereo_buf[:, 1])
return stereo_buf
[docs]
def play_score(score):
"""Play an entire Score through the speakers.
Renders drums, default notes, and all named parts — each with
its own synth voice and envelope — mixed into one audio buffer.
Args:
score: A :class:`Score` object with notes, parts, and/or drum hits.
Example::
>>> from pytheory import Pattern, Key, Duration, Score
>>> key = Key("A", "minor")
>>> score = Score("4/4", bpm=140)
>>> score.add_pattern(Pattern.preset("bossa nova"), repeats=4)
>>> chords = score.part("chords", synth="sine", envelope="pad")
>>> lead = score.part("lead", synth="saw", envelope="pluck")
>>> for chord in key.progression("i", "iv", "V", "i"):
... chords.add(chord, Duration.WHOLE)
>>> lead.add("E5", Duration.QUARTER).add("D5", Duration.QUARTER)
>>> play_score(score)
"""
buf = render_score(score)
_sd = _get_sd()
try:
_sd.play(buf, SAMPLE_RATE)
_sd.wait()
except KeyboardInterrupt:
_sd.stop()
# ── MIDI export ─────────────────────────────────────────────────────────────
def _vlq(value):
"""Encode an integer as MIDI variable-length quantity bytes."""
result = []
result.append(value & 0x7F)
value >>= 7
while value:
result.append((value & 0x7F) | 0x80)
value >>= 7
return bytes(reversed(result))
[docs]
def save_midi(tone_or_chords, path, *, t=500, velocity=100, bpm=120, gap=0):
"""Save a tone, chord, or progression as a Standard MIDI File.
Writes a Type 0 (single-track) MIDI file that any DAW, notation
software, or MIDI player can open. Far more useful than WAV for
musicians — you can edit the notes, change the tempo, transpose,
and assign any instrument.
Args:
tone_or_chords: A Tone, Chord, or list of Tones/Chords.
A single Tone or Chord is written as one event.
A list is written as a sequence (progression).
path: Output file path (e.g. ``"progression.mid"``).
t: Duration of each note/chord in milliseconds (default 500).
velocity: MIDI velocity 1-127 (default 100).
bpm: Tempo in beats per minute (default 120).
gap: Silence between chords in milliseconds (default 0).
Example::
>>> from pytheory import Key, save_midi
>>> chords = Key("C", "major").progression("I", "V", "vi", "IV")
>>> save_midi(chords, "pop.mid", t=500, bpm=120)
>>> save_midi(Tone.from_string("C4"), "middle_c.mid", t=1000)
"""
import struct
ticks_per_beat = 480
us_per_beat = int(60_000_000 / bpm)
ticks_per_ms = ticks_per_beat * bpm / 60_000
# Normalize input to a list of items
if isinstance(tone_or_chords, list):
items = tone_or_chords
else:
items = [tone_or_chords]
# Build track events
events = bytearray()
# Tempo meta event: FF 51 03 <3 bytes of microseconds per beat>
events += _vlq(0) # delta time
events += b'\xFF\x51\x03'
events += struct.pack('>I', us_per_beat)[1:] # 3 bytes
duration_ticks = int(t * ticks_per_ms)
gap_ticks = int(gap * ticks_per_ms)
for item in items:
# Get MIDI note numbers
if hasattr(item, 'tones'):
notes = [tone.midi for tone in item.tones if tone.midi is not None]
else:
midi = item.midi
notes = [midi] if midi is not None else []
if not notes:
continue
# Note On events (delta=0 for all)
for note in notes:
events += _vlq(0)
events += bytes([0x90, note & 0x7F, velocity & 0x7F])
# Note Off events after duration
for i, note in enumerate(notes):
delta = duration_ticks if i == 0 else 0
events += _vlq(delta)
events += bytes([0x80, note & 0x7F, 0])
# Gap between chords
if gap_ticks > 0:
events += _vlq(gap_ticks)
events += bytes([0x90, 0, 0]) # silent note-on as spacer
events += _vlq(0)
events += bytes([0x80, 0, 0])
# End of track
events += _vlq(0)
events += b'\xFF\x2F\x00'
# Write MIDI file
with open(path, 'wb') as f:
# Header: MThd, length=6, format=0, tracks=1, ticks_per_beat
f.write(b'MThd')
f.write(struct.pack('>I', 6))
f.write(struct.pack('>HHH', 0, 1, ticks_per_beat))
# Track chunk
f.write(b'MTrk')
f.write(struct.pack('>I', len(events)))
f.write(events)