mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 06:46:14 +00:00
150c57ed3d
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
5603 lines
225 KiB
Python
5603 lines
225 KiB
Python
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
|
||
|
||
|
||
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)
|
||
|
||
|
||
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,))
|
||
|
||
|
||
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,))
|
||
|
||
|
||
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)
|
||
|
||
|
||
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,))
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
def pluck_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||
"""Karplus-Strong plucked string synthesis.
|
||
|
||
A burst of noise is fed into a short delay line with feedback —
|
||
the delay length determines the pitch, and the feedback filter
|
||
determines the decay. This is how every physical modeling synth
|
||
since 1983 does plucked strings. It sounds genuinely like a real
|
||
guitar, harp, or koto — not a synth approximation.
|
||
|
||
The algorithm: fill a buffer with random noise the length of one
|
||
period, then repeatedly average adjacent samples. The averaging
|
||
acts as a lowpass filter, gradually removing high harmonics —
|
||
exactly what a real vibrating string does as energy dissipates.
|
||
"""
|
||
period = int(SAMPLE_RATE / hz)
|
||
if period < 2:
|
||
period = 2
|
||
# Initial noise burst — the "pluck"
|
||
buf = numpy.random.uniform(-1.0, 1.0, period).astype(numpy.float64)
|
||
out = numpy.zeros(n_samples, dtype=numpy.float64)
|
||
for i in range(n_samples):
|
||
out[i] = buf[i % period]
|
||
# Averaging filter: smooth adjacent samples (Karplus-Strong)
|
||
buf[i % period] = 0.5 * (buf[i % period] + buf[(i + 1) % period]) * 0.998
|
||
return (peak * out).astype(numpy.int16)
|
||
|
||
|
||
def organ_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||
"""Hammond organ — additive synthesis with drawbar harmonics.
|
||
|
||
A real Hammond B3 has 9 drawbars that mix sine waves at different
|
||
harmonics. This models the classic "full" registration with all
|
||
drawbars pulled: fundamental, 2nd, 3rd, 4th, 5th, 6th, and 8th
|
||
harmonics at musical levels.
|
||
|
||
The result is warm, rich, and unmistakably organ — somewhere
|
||
between a sine wave and a square wave, with that characteristic
|
||
hollow roundness.
|
||
"""
|
||
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||
# Drawbar levels (inspired by 888800000 — full even harmonics)
|
||
wave = (numpy.sin(2 * numpy.pi * hz * t) * 1.0 + # 16' fundamental
|
||
numpy.sin(2 * numpy.pi * hz * 2 * t) * 0.8 + # 8'
|
||
numpy.sin(2 * numpy.pi * hz * 3 * t) * 0.6 + # 5 1/3'
|
||
numpy.sin(2 * numpy.pi * hz * 4 * t) * 0.5 + # 4'
|
||
numpy.sin(2 * numpy.pi * hz * 5 * t) * 0.3 + # 2 2/3'
|
||
numpy.sin(2 * numpy.pi * hz * 6 * t) * 0.25 + # 2'
|
||
numpy.sin(2 * numpy.pi * hz * 8 * t) * 0.15) # 1 3/5'
|
||
wave /= 3.5 # normalize
|
||
return (peak * wave).astype(numpy.int16)
|
||
|
||
|
||
def strings_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||
"""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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
def saxophone_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||
"""Saxophone — single reed through a conical brass bore.
|
||
|
||
The conical bore produces all harmonics (like oboe), but the
|
||
brass body and larger mouthpiece give a warmer, fatter, more
|
||
vocal quality. The reed adds a slight buzz. Saxophone is
|
||
between clarinet (odd harmonics) and oboe (nasal even+odd) —
|
||
it has everything, with a strong fundamental and rich mids.
|
||
"""
|
||
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
|
||
|
||
# Vibrato — develops after ~250ms, wider than flute
|
||
vib_onset = numpy.clip(t / 0.25, 0.0, 1.0)
|
||
vib = hz * 0.0012 * vib_onset * numpy.sin(2 * numpy.pi * 5.2 * t)
|
||
|
||
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
|
||
# Sax spectral shape: strong fundamental, broad mid peak (3-6),
|
||
# slower rolloff than oboe (brass body carries harmonics further)
|
||
if n == 1:
|
||
amp = 1.0
|
||
elif n <= 3:
|
||
amp = 0.6
|
||
elif n <= 6:
|
||
amp = 0.4 * numpy.exp(-0.1 * (n - 4) ** 2)
|
||
else:
|
||
amp = 0.2 / n
|
||
phase = rng.uniform(0, 2 * numpy.pi)
|
||
wave += amp * numpy.sin(2 * numpy.pi * (f_n + vib * n) * t + phase)
|
||
|
||
# Reed buzz — more present than oboe but still warm
|
||
reed = rng.normal(0, 0.07, n_samples)
|
||
# Bandpass the reed noise around 1-3kHz (the "honk" range)
|
||
import scipy.signal as _sig
|
||
reed_lo = max(20, int(hz * 2))
|
||
reed_hi = min(SAMPLE_RATE // 2 - 1, int(hz * 6))
|
||
if reed_lo < reed_hi:
|
||
br, ar = _sig.butter(2, [reed_lo, reed_hi], btype='band', fs=SAMPLE_RATE)
|
||
reed = _sig.lfilter(br, ar, reed).astype(numpy.float64) * 2.0
|
||
wave += reed
|
||
|
||
# Brass body warmth — low-mid boost
|
||
center = min(1500, hz * 4)
|
||
bw = 500
|
||
lo = max(20, int(center - bw))
|
||
hi = min(SAMPLE_RATE // 2 - 1, int(center + bw))
|
||
if lo < hi:
|
||
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
|
||
body = _sig.lfilter(bp, ap, wave) * 0.2
|
||
wave += body
|
||
|
||
mx = numpy.abs(wave).max()
|
||
if mx > 0:
|
||
wave /= mx
|
||
|
||
return (peak * wave).astype(numpy.int16)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
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)
|
||
|
||
|
||
def electric_guitar_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||
"""Electric guitar — Karplus-Strong through magnetic pickup simulation.
|
||
|
||
Models a steel string vibrating over a magnetic pickup:
|
||
1. Karplus-Strong plucked string (brighter than acoustic)
|
||
2. Pickup comb filter — a magnetic pickup at 1/4 string length
|
||
cancels the 4th harmonic and boosts the 2nd, creating the
|
||
characteristic electric guitar "honk"
|
||
3. Slightly longer sustain than acoustic (no body absorption)
|
||
"""
|
||
period = int(SAMPLE_RATE / hz)
|
||
if period < 2:
|
||
period = 2
|
||
|
||
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
|
||
|
||
# Initial pluck — steel string, bright pick attack
|
||
buf = rng.uniform(-1.0, 1.0, period).astype(numpy.float64)
|
||
|
||
out = numpy.zeros(n_samples, dtype=numpy.float64)
|
||
|
||
# Karplus-Strong with slightly less damping than acoustic
|
||
for i in range(n_samples):
|
||
out[i] = buf[i % period]
|
||
next_idx = (i + 1) % period
|
||
# Less damping = more sustain (steel string, no wood body absorbing)
|
||
buf[i % period] = 0.5 * (buf[i % period] + buf[next_idx]) * 0.9993
|
||
|
||
# Magnetic pickup simulation — comb filter at pickup position.
|
||
# A pickup at 1/4 of the string length cancels the 4th harmonic
|
||
# and creates the characteristic electric guitar midrange honk.
|
||
pickup_pos = period // 4
|
||
if pickup_pos > 0 and pickup_pos < n_samples:
|
||
pickup = numpy.zeros(n_samples, dtype=numpy.float64)
|
||
pickup[pickup_pos:] = out[:-pickup_pos]
|
||
# Subtract delayed version = comb filter (pickup sampling)
|
||
out = out - pickup * 0.3
|
||
|
||
# Slight single-coil brightness boost
|
||
# High-shelf boost above 2kHz using a simple 1-pole
|
||
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||
brightness = numpy.zeros(n_samples, dtype=numpy.float64)
|
||
if n_samples > 1:
|
||
alpha = 0.15
|
||
brightness[0] = out[0]
|
||
for i in range(1, n_samples):
|
||
brightness[i] = alpha * (out[i] - out[i-1]) + (1 - alpha) * brightness[i-1]
|
||
out = out + brightness * 0.2
|
||
|
||
# Normalize
|
||
mx = numpy.abs(out).max()
|
||
if mx > 0:
|
||
out /= mx
|
||
|
||
return (peak * out).astype(numpy.int16)
|
||
|
||
|
||
def sitar_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||
"""Sitar — Karplus-Strong with jawari bridge buzz and sympathetic strings.
|
||
|
||
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)
|
||
|
||
|
||
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
|
||
|
||
|
||
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()
|
||
|
||
|
||
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"
|
||
|
||
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,
|
||
}
|
||
|
||
|
||
def _render(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
|
||
envelope=Envelope.PIANO):
|
||
"""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).
|
||
|
||
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)]
|
||
else:
|
||
waves = [
|
||
synth(tone.pitch(temperament=temperament), n_samples=n_samples)
|
||
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
|
||
|
||
|
||
def play(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
|
||
envelope=Envelope.PIANO):
|
||
"""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.
|
||
|
||
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_for(_render(tone_or_chord, temperament=temperament, synth=synth,
|
||
t=t, envelope=envelope), ms=t)
|
||
|
||
|
||
def save(tone_or_chord, path, temperament="equal", synth=Synth.SINE, t=1_000,
|
||
envelope=Envelope.PIANO):
|
||
"""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``).
|
||
|
||
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)
|
||
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)
|
||
|
||
|
||
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 _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: lambda n: _synth_tabla_ge_bend(n),
|
||
# 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),
|
||
}
|
||
|
||
renderer = _dispatch.get(sound_value, lambda n: _synth_clave(n))
|
||
result = renderer(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
|
||
|
||
|
||
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
|
||
driven = numpy.tanh(samples * drive)
|
||
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.5, 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
|
||
if makeup:
|
||
peak = numpy.max(numpy.abs(compressed))
|
||
if peak > 0:
|
||
compressed = compressed / peak * 0.9
|
||
|
||
# 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
|
||
|
||
|
||
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)
|
||
synth_kwargs = {}
|
||
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
|
||
|
||
|
||
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))
|
||
|
||
|
||
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)
|