diff --git a/docs/_static/audio/synth_choir.wav b/docs/_static/audio/synth_choir.wav
new file mode 100644
index 0000000..2b48aad
Binary files /dev/null and b/docs/_static/audio/synth_choir.wav differ
diff --git a/docs/_static/audio/synth_pipe_organ.wav b/docs/_static/audio/synth_pipe_organ.wav
new file mode 100644
index 0000000..5ab3129
Binary files /dev/null and b/docs/_static/audio/synth_pipe_organ.wav differ
diff --git a/docs/_static/audio/synth_vibraphone.wav b/docs/_static/audio/synth_vibraphone.wav
new file mode 100644
index 0000000..e5ebdc7
Binary files /dev/null and b/docs/_static/audio/synth_vibraphone.wav differ
diff --git a/docs/_static/audio/synth_wurlitzer.wav b/docs/_static/audio/synth_wurlitzer.wav
new file mode 100644
index 0000000..944f1c6
Binary files /dev/null and b/docs/_static/audio/synth_wurlitzer.wav differ
diff --git a/docs/generate_audio.py b/docs/generate_audio.py
index c2454ab..e66e8a2 100644
--- a/docs/generate_audio.py
+++ b/docs/generate_audio.py
@@ -662,6 +662,41 @@ def gen_synth_kalimba():
p.add(n, Duration.QUARTER, velocity=85)
render("synth_kalimba", score)
+def gen_synth_wurlitzer():
+ score = Score("4/4", bpm=90)
+ p = score.part("demo", instrument="wurlitzer", volume=0.5, reverb=0.25)
+ p.hold("C3", Duration.WHOLE * 2, velocity=60)
+ p.hold("Eb3", Duration.WHOLE * 2, velocity=55)
+ p.hold("G3", Duration.WHOLE * 2, velocity=55)
+ for n in ["G4", "Bb4", "C5", "Bb4", "G4", "F4", "Eb4", "G4"]:
+ p.add(n, Duration.QUARTER, velocity=78)
+ render("synth_wurlitzer", score)
+
+def gen_synth_vibraphone():
+ score = Score("4/4", bpm=90)
+ p = score.part("demo", instrument="vibraphone", volume=0.5)
+ for n in ["C5", "E5", "G5", "C6", "G5", "E5", "C5", "E5"]:
+ p.add(n, Duration.QUARTER, velocity=75)
+ render("synth_vibraphone", score)
+
+def gen_synth_pipe_organ():
+ score = Score("4/4", bpm=70)
+ p = score.part("demo", instrument="pipe_organ", volume=0.5)
+ p.hold("C3", Duration.WHOLE * 4, velocity=70)
+ p.hold("G3", Duration.WHOLE * 4, velocity=65)
+ for n in ["C4", "D4", "E4", "F4", "G4", "F4", "E4", "D4",
+ "C4", "E4", "G4", "C5", "G4", "E4", "C4", "C4"]:
+ p.add(n, Duration.QUARTER, velocity=75)
+ render("synth_pipe_organ", score)
+
+def gen_synth_choir():
+ score = Score("4/4", bpm=70)
+ p = score.part("demo", instrument="choir", volume=0.5)
+ for n, v in [("C4", "ah"), ("E4", "oh"), ("G4", "ah"), ("C5", "ee"),
+ ("G4", "oh"), ("E4", "ah"), ("C4", "oo"), ("C4", "ah")]:
+ p.add(n, Duration.HALF, velocity=70, lyric=v)
+ render("synth_choir", score)
+
def gen_synth_organ():
_synth_demo("organ", "organ_synth", envelope="organ")
@@ -1004,6 +1039,10 @@ GENERATORS = [
gen_synth_electric_guitar,
gen_synth_sitar,
gen_synth_kalimba,
+ gen_synth_wurlitzer,
+ gen_synth_vibraphone,
+ gen_synth_pipe_organ,
+ gen_synth_choir,
gen_synth_organ,
gen_synth_marimba,
gen_synth_harp,
diff --git a/docs/guide/synths.rst b/docs/guide/synths.rst
index a8c8edf..487423a 100644
--- a/docs/guide/synths.rst
+++ b/docs/guide/synths.rst
@@ -479,6 +479,72 @@ singing sustain. The sound of jazz clubs, soul, and neo-soul.
+Wurlitzer Electric Piano
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+The Wurlitzer uses a vibrating steel reed (not a tine like Rhodes)
+picked up by an electrostatic pickup. More nasal, reedy, and biting
+— it barks and growls when played hard. Think Supertramp, Ray Charles.
+
+.. code-block:: python
+
+ wurli = score.part("wurli", synth="wurlitzer_synth")
+ wurli = score.part("wurli", instrument="wurlitzer")
+
+.. raw:: html
+
+
+
+Vibraphone Synth
+~~~~~~~~~~~~~~~~
+
+Struck aluminum bars with motor-driven tremolo discs. The spinning
+motor modulates the sound through the resonator tubes, creating the
+signature vibraphone shimmer. Inharmonic bar modes at 1x, 2.76x, 5.4x.
+
+.. code-block:: python
+
+ vib = score.part("vib", synth="vibraphone_synth")
+ vib = score.part("vib", instrument="vibraphone")
+
+.. raw:: html
+
+
+
+Pipe Organ Synth
+~~~~~~~~~~~~~~~~
+
+Multiple ranks of pipes — principal 8', octave 4', fifteenth 2'.
+Constant air pressure means no dynamics. Wind chiff at the attack.
+Best with cathedral reverb.
+
+.. code-block:: python
+
+ organ = score.part("organ", synth="pipe_organ_synth")
+ organ = score.part("organ", instrument="pipe_organ")
+
+.. raw:: html
+
+
+
+Choir Synth
+~~~~~~~~~~~
+
+Voices singing vowels shaped by formant bandpass filters. The glottal
+source is filtered through vocal tract resonances — F1, F2, F3, F4 —
+which is what makes "ah" sound different from "oo". Use ``lyric=``
+to control the vowel. Best with ``ensemble=`` for a full section.
+
+.. code-block:: python
+
+ choir = score.part("choir", synth="choir_synth")
+ choir = score.part("choir", instrument="choir") # ensemble=6 + cathedral reverb
+ choir.add("C4", Duration.WHOLE, lyric="ah")
+
+.. raw:: html
+
+
+
Bass Guitar Synth
~~~~~~~~~~~~~~~~~
diff --git a/pytheory/play.py b/pytheory/play.py
index 0e0f86d..df6c0fe 100644
--- a/pytheory/play.py
+++ b/pytheory/play.py
@@ -478,6 +478,246 @@ def rhodes_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
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.
@@ -1959,6 +2199,8 @@ _SYNTH_FUNCTIONS = {
"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,
diff --git a/pytheory/rhythm.py b/pytheory/rhythm.py
index 665b307..cfd33fb 100644
--- a/pytheory/rhythm.py
+++ b/pytheory/rhythm.py
@@ -23,6 +23,15 @@ INSTRUMENTS = {
"tremolo_depth": 0.12, "tremolo_rate": 4.5,
"analog": 0.15,
},
+ "wurlitzer": {
+ "synth": "wurlitzer_synth", "envelope": "none",
+ "tremolo_depth": 0.18, "tremolo_rate": 5.0,
+ "analog": 0.2,
+ },
+ "pipe_organ": {
+ "synth": "pipe_organ_synth", "envelope": "none",
+ "reverb": 0.5, "reverb_type": "cathedral",
+ },
"organ": {
"synth": "organ_synth", "envelope": "organ",
"chorus": 0.2, "chorus_rate": 5.5,
@@ -303,8 +312,8 @@ INSTRUMENTS = {
"humanize": 0.15,
},
"choir": {
- "synth": "vocal_synth", "envelope": "pad",
- "detune": 8, "spread": 0.4,
+ "synth": "choir_synth", "envelope": "none",
+ "detune": 6, "spread": 0.3, "ensemble": 6,
"reverb": 0.45, "reverb_type": "cathedral",
},
"granular_texture": {
@@ -321,10 +330,7 @@ INSTRUMENTS = {
# ── Percussion / Mallet ──
"vibraphone": {
- "synth": "fm", "envelope": "mallet",
- "fm_ratio": 1.0, "fm_index": 1.0,
- "lowpass": 5000,
- "tremolo_depth": 0.3, "tremolo_rate": 5.5,
+ "synth": "vibraphone_synth", "envelope": "none",
"reverb": 0.3, "reverb_type": "plate",
},
"marimba": {