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": {