#4490 Python Synth Methods
#4490 Python Synth Methods
#4490 Python synth: If I have methods for VCO, VCF, VCA, LFO, envelope generator (EG), and noise generator, how would i apply them in code?
To build a classic subtractive synthesizer in code, you need to connect these methods in a specific data flow, usually referred to as a signal chain.
Think of it like a plumbing system: audio signals flow from the generators (VCO, Noise), through the modifiers (VCF, VCA), and finally out to your speakers. The modulation sources (LFO, EG) act like automated hands turning the knobs of those modifiers.
Here is how you would structure the signal flow in a typical processing loop (usually per audio buffer or per sample).
The Standard Subtractive Signal Chain
[ LFO ] --------+ (Modulation)
v
[ VCO ] ---> [ VCF ] ---> [ VCA ] ---> Output
^ ^
[ EG ] --------+------------+ (Modulation)
Implementing the Flow in Code
Assuming your methods return a NumPy array or a single float sample-by-sample, here is how you orchestrate them in a standard synthesis block:
import numpy as np
def process_audio_block(frequency, duration, sample_rate=44100):
num_samples = int(sample_rate * duration)
# 1. Generate Modulation Signals
# LFOs and EGs don't make sound; they dictate how the sound changes over time.
lfo_signal = lfo.generate(frequency=6.0, depth=0.1, num_samples=num_samples)
eg_signal = eg.generate(attack=0.1, decay=0.2, sustain=0.7, release=0.3, num_samples=num_samples)
# 2. Generate the Raw Sound Sources
# Apply LFO to the VCO frequency for a vibrato effect
vco_freqs = frequency * (1.0 + lfo_signal)
vco_out = vco.generate(frequencies=vco_freqs, waveform='sawtooth', num_samples=num_samples)
noise_out = noise.generate(color='white', num_samples=num_samples)
# Mix the sound sources together (e.g., 80% synth, 20% noise)
mixed_source = (0.8 * vco_out) + (0.2 * noise_out)
# 3. Apply the Filter (VCF)
# Use the Envelope Generator to dynamically modulate the filter cutoff frequency
base_cutoff = 1000.0 # Hz
cutoff_mod = base_cutoff + (eg_signal * 3000.0) # Sweep up to 4000Hz
filtered_out = vcf.process(mixed_source, cutoff_frequencies=cutoff_mod, resonance=0.7)
# 4. Apply the Amplifier (VCA)
# The EG shapes the overall volume volume contour of the note
final_output = filtered_out * eg_signal
return final_output
Key Conceptual Rules for Your Code
1. Control Rate vs. Audio Rate
Audio Rate (VCO, Noise, VCF, VCA): These handle the actual sound. They must calculate values at the full sample rate (e.g., 44.1 kHz) to avoid digital distortion.
Control Rate (LFO, EG): These move much slower. While you can calculate them at audio rate for simplicity (like in the example above), in a production synth, they are often calculated at a lower rate (e.g., every 64 samples) to save CPU.
2. The Multiplicative Nature of VCA
The VCA is pure math multiplication. You take your audio signal array and multiply it element-wise by your envelope array. If eg_signal is 0.0, the output is silence. If it's 1.0, it's full volume.
3. Modulating the VCF
Filters sound alive when they move. Passing a fixed cutoff frequency to your VCF method will sound static. Always combine a baseline cutoff frequency with a scaled version of your EG or LFO before feeding it into the VCF's parameter argument.
Are you designing this to process audio sample-by-sample in real-time, or are you generating entire blocks/buffers of audio at once?
Provide the individual python methods
Here is a complete, lightweight implementation of the individual components using NumPy.
To keep the code clean and performant, these methods are written using vectorized block processing (generating arrays of samples at once) rather than looping sample-by-sample.
1. Sound Generators (Audio Rate)
VCO (Voltage Controlled Oscillator)
To allow for modulation (like vibrato), the VCO calculates the phase by taking the cumulative sum (cumsum) of the frequency array over time.
import numpy as np
class VCO:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
self.phase = 0.0
def generate(self, frequencies, waveform='sawtooth'):
"""
frequencies: a float or a NumPy array of frequencies per sample.
waveform: 'sine', 'sawtooth', or 'square'
"""
num_samples = len(frequencies) if isinstance(frequencies, np.ndarray) else 1
dt = 1.0 / self.sample_rate
# Calculate instantaneous phase step for every sample
phase_steps = 2 * np.pi * frequencies * dt
# Accumulate phases and add to current phase
phases = self.phase + np.cumsum(phase_steps)
# Keep the internal tracker updated for the next block
self.phase = phases[-1] % (2 * np.pi)
if waveform == 'sine':
return np.sin(phases)
elif waveform == 'sawtooth':
return 2.0 * (phases % (2 * np.pi) / (2 * np.pi)) - 1.0
elif waveform == 'square':
return np.sign(np.sin(phases))
else:
raise ValueError("Unknown waveform type")
Noise Generator
Pure white noise is simply a stream of random numbers bounded between -1.0 and 1.0.
class NoiseGenerator:
def generate(self, num_samples):
# Generates white noise between -1.0 and 1.0
return np.random.uniform(-1.0, 1.0, num_samples)
2. Modulators (Control Rate)
LFO (Low-Frequency Oscillator)
An LFO is structurally identical to a VCO, but it runs at a much lower frequency and is used to output a shape (like a sine wave) to modulate other parameters.
class LFO:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
self.phase = 0.0
def generate(self, frequency, depth, num_samples):
dt = 1.0 / self.sample_rate
# Standard fixed-frequency phase calculation
phases = self.phase + 2 * np.pi * frequency * dt * np.arange(num_samples)
self.phase = phases[-1] % (2 * np.pi)
# Output is scaled by the 'depth' of modulation
return depth * np.sin(phases)
Envelope Generator (EG)
This implements a classic ADSR (Attack, Decay, Sustain, Release) envelope. For a block-based approach, we track the current time state of the envelope.
class EnvelopeGenerator:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
self.time = 0.0
self.gate_open = True # True for Note On, False for Note Off (Release)
def generate(self, attack, decay, sustain, release, num_samples):
dt = 1.0 / self.sample_rate
envelope = np.zeros(num_samples)
for i in range(num_samples):
t = self.time
if self.gate_open:
if t < attack:
# Linearly ramp up to 1.0
val = t / attack
elif t < (attack + decay):
# Linearly decay to sustain level
decay_time = t - attack
val = 1.0 - (1.0 - sustain) * (decay_time / decay)
else:
# Hold at sustain level
val = sustain
else:
# Note Off: Fade out from sustain to 0.0 based on release time
# (Simplified representation for sequential block processing)
val = sustain * max(0.0, 1.0 - (t / release))
envelope[i] = val
self.time += dt
return envelope
def note_off(self):
self.gate_open = False
self.time = 0.0 # Reset time clock to handle release phase
3. Signal Modifiers (Audio Rate)
VCF (Voltage Controlled Filter)
This uses a lightweight, standard state-variable filter (SVF) digital implementation. It loops through the samples to dynamically adjust the lowpass output based on the cutoff array.
class VCF:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
# Filter state registers
self.ic1eq = 0.0
self.ic2eq = 0.0
def process(self, audio_in, cutoff_frequencies, resonance):
"""
cutoff_frequencies: NumPy array matching audio_in length (dynamic cutoff)
resonance: float between 0.0 (no resonance) and 1.0 (self-oscillation)
"""
num_samples = len(audio_in)
output = np.zeros(num_samples)
# Convert Q/Resonance factor (Map 0-1 input to a stable damping factor)
q = 1.0 / (2.0 * (1.0 - min(0.99, resonance)))
g_damping = 1.0 / q
for i in range(num_samples):
# Clamp cutoff to safe digital limit (Nyquist / 2)
cutoff = max(20.0, min(cutoff_frequencies[i], self.sample_rate / 3.0))
# Pre-warp filter coefficients
g = np.tan(np.pi * cutoff / self.sample_rate)
k = g_damping
# SVF core equations (Lowpass topology)
v0 = audio_in[i]
v3 = v0 - self.ic2eq
v1 = (self.ic1eq * (1.0 - g*k) + g*v3) / (1.0 + g*(g + k))
v2 = self.ic2eq + g*v1
# Update state variables
self.ic1eq = 2.0 * v1 - self.ic1eq
self.ic2eq = 2.0 * v2 - self.ic2eq
output[i] = v2 # v2 is the Lowpass output
return output
VCA (Voltage Controlled Amplifier)
Because the VCA just controls amplitude, it does not need internal state tracking. It multiplies the incoming sound by the control envelope.
class VCA:
def process(self, audio_in, envelope_signal):
"""
audio_in: NumPy array of audio
envelope_signal: NumPy array representing volume (0.0 to 1.0)
"""
return audio_in * envelope_signal
Putting it all together
If you initialize these classes, you can pass arrays cleanly between them:
# Initialization
sample_rate = 44100
vco = VCO(sample_rate)
vcf = VCF(sample_rate)
vca = VCA()
eg = EnvelopeGenerator(sample_rate)
lfo = LFO(sample_rate)
# Run a 1-second block at A4 (440Hz)
num_samples = sample_rate * 1
freq_array = np.full(num_samples, 440.0)
# Chain execution
lfo_mod = lfo.generate(frequency=5.0, depth=0.05, num_samples=num_samples)
vco_out = vco.generate(freq_array * (1.0 + lfo_mod), waveform='sawtooth')
env_out = eg.generate(attack=0.05, decay=0.2, sustain=0.7, release=0.2, num_samples=num_samples)
# Filter cutoff sweeps dynamically using the envelope
cutoff_mod = 200.0 + (env_out * 2000.0)
filtered_out = vcf.process(vco_out, cutoff_frequencies=cutoff_mod, resonance=0.5)
# Final volume shape
final_audio = vca.process(filtered_out, env_out)Are there any other methods that would be useful?
To take your synthesizer from a basic functional model to a tool capable of rich, complex, and modern sounds, there are several key modules you should add.
A standard subtractive synth gets its core tone from raw oscillators, but it gets its character and playability from specific modulation utilities and post-processing effects.
Here are the most useful methods and components to implement next, ranked by their impact on your sound.
1. Sub-Oscillator (VCO Expansion)
A sub-oscillator generates a waveform exactly one or two octaves below your main VCO frequency. It adds immediate weight and "beefiness" to bass lines and leads without requiring you to build a complex multi-oscillator polyphonic setup.
class SubOscillator:
def __init__(self, sample_rate=44100):
self.vco = VCO(sample_rate)
def generate(self, frequencies, octave_down=1, num_samples=1):
# Divide the main frequency array by 2 (1 octave) or 4 (2 octaves)
sub_freqs = frequencies / (2 ** octave_down)
# Sub-oscillators are traditionally square waves for maximum harmonic weight
return self.vco.generate(sub_freqs, waveform='square')
2. Portamento / Glide (Control Modifier)
Portamento makes the synthesizer frequency slide smoothly from one note to the next instead of jumping instantly. This is the secret behind classic acid basslines and expressive synth leads.
class Portamento:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
self.current_freq = None
def process(self, target_frequency, glide_time, num_samples):
"""
Smoothly interpolates from the last played frequency to a new target frequency.
"""
if self.current_freq == None:
self.current_freq = target_frequency
if glide_time <= 0.001:
return np.full(num_samples, target_frequency)
# Calculate a low-pass filter coefficient for the frequency glide
alpha = 1.0 - np.exp(-1.0 / (glide_time * self.sample_rate))
freq_array = np.zeros(num_samples)
for i in range(num_samples):
# Smoothly nudge the current frequency toward the target
self.current_freq += alpha * (target_frequency - self.current_freq)
freq_array[i] = self.current_freq
return freq_array
3. Wavefolder / Distortion (Waveshaper)
Subtractive synthesis is all about removing harmonics with a filter. A Waveshaper goes the other direction—it adds aggressive new harmonics by bending, clipping, or folding the audio signal when it gets too loud.
class WaveShaper:
def drive(self, audio_in, gain):
"""Soft-clipping distortion using a hyperbolic tangent (tanh) function."""
return np.tanh(audio_in * gain)
def fold(self, audio_in, threshold=0.7):
"""
Wavefolding: Instead of clipping the wave at a ceiling,
it mirrors the waveform back downward, creating complex, metallic tones.
"""
folded = np.copy(audio_in)
# Simple mathematical folding logic
for i in range(len(folded)):
while abs(folded[i]) > threshold:
if folded[i] > threshold:
folded[i] = 2 * threshold - folded[i]
elif folded[i] < -threshold:
folded[i] = -2 * threshold - folded[i]
return folded
4. Stereo Chorus Effect (Delay Modulation)
Raw synth oscillators are entirely monophonic and can sound flat. A Chorus effect duplicates the audio signal, delays the copy slightly, and modulates that delay time with an LFO. This creates a massive, lush, and wide stereo image.
class StereoChorus:
def __init__(self, sample_rate=44100, max_delay_samples=441):
self.sample_rate = sample_rate
# Create a ring buffer to store past audio samples
self.buffer = np.zeros(max_delay_samples * 2)
self.write_idx = 0
def process(self, audio_in, rate=1.5, depth=30, mix=0.5):
"""
rate: LFO modulation speed (Hz)
depth: Modulation width in samples
mix: 0.0 (dry) to 1.0 (wet chorus)
"""
num_samples = len(audio_in)
left_out = np.zeros(num_samples)
right_out = np.zeros(num_samples)
buf_len = len(self.buffer)
# Internal LFO for modulating delay time
t = np.arange(num_samples) / self.sample_rate
lfo = np.sin(2 * np.pi * rate * t)
for i in range(num_samples):
# Store current sample in ring buffer
self.buffer[self.write_idx] = audio_in[i]
# Calculate varying delay times for Left and Right channels to create stereo
delay_l = 100 + depth * lfo[i]
delay_r = 120 + depth * (-lfo[i]) # Inverted LFO for width
# Read from the past in the buffer
read_idx_l = int(self.write_idx - delay_l) % buf_len
read_idx_r = int(self.write_idx - delay_r) % buf_len
# Mix dry signal with delayed signal
left_out[i] = (1.0 - mix) * audio_in[i] + mix * self.buffer[read_idx_l]
right_out[i] = (1.0 - mix) * audio_in[i] + mix * self.buffer[read_idx_r]
self.write_idx = (self.write_idx + 1) % buf_len
return left_out, right_out
5. Parameter Smoothing / Slew Rate Limiter
If a user suddenly turns a knob on a MIDI controller (like moving the filter cutoff from 200Hz to 5000Hz instantly), your digital synth will produce an ugly digital click or pop.
A Parameter Smoother takes a sudden change in value and gently ramps it over a few milliseconds so the transition is perfectly seamless to the human ear.
How they fit into the chain:
If you add these, your upgraded architectural flow looks like this:
[ MIDI Note ] ---> [ Portamento ] ---> Main Frequency Array
|
+-------------------------+
v v
[ Main VCO ] [ Sub Oscillator ]
| |
+----------->(Mixer)<-----+
|
v
[ WaveShaper ]
|
v
[ VCF ]
|
v
[ VCA ]
|
v
[ Stereo Chorus ] ---> OutputBring it all together
Here is the complete, integrated architecture for your advanced subtractive synthesizer. This script combines all the core modules (VCO, Sub-Osc, Noise, LFO, EG, VCF, VCA) alongside the advanced utilities (Portamento, WaveShaper, Chorus) into a single, cohesive executable pipeline.
We will use block-based processing to synthesize 2 seconds of a modulating, glided synth bass note and save the final stereo output.
The Complete Synthesizer Script
import numpy as np
import scipy.io.wavfile as wav
# =====================================================================
# 1. INDIVIDUAL MODULE IMPLEMENTATIONS
# =====================================================================
class Portamento:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
self.current_freq = None
def process(self, target_frequency, glide_time, num_samples):
if self.current_freq is None:
self.current_freq = target_frequency
if glide_time <= 0.001:
return np.full(num_samples, target_frequency)
alpha = 1.0 - np.exp(-1.0 / (glide_time * self.sample_rate))
freq_array = np.zeros(num_samples)
for i in range(num_samples):
self.current_freq += alpha * (target_frequency - self.current_freq)
freq_array[i] = self.current_freq
return freq_array
class VCO:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
self.phase = 0.0
def generate(self, frequencies, waveform='sawtooth'):
num_samples = len(frequencies)
dt = 1.0 / self.sample_rate
phase_steps = 2 * np.pi * frequencies * dt
phases = self.phase + np.cumsum(phase_steps)
self.phase = phases[-1] % (2 * np.pi)
if waveform == 'sine':
return np.sin(phases)
elif waveform == 'sawtooth':
return 2.0 * (phases % (2 * np.pi) / (2 * np.pi)) - 1.0
elif waveform == 'square':
return np.sign(np.sin(phases))
else:
raise ValueError("Unknown waveform")
class NoiseGenerator:
def generate(self, num_samples):
return np.random.uniform(-1.0, 1.0, num_samples)
class LFO:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
self.phase = 0.0
def generate(self, frequency, depth, num_samples):
dt = 1.0 / self.sample_rate
phases = self.phase + 2 * np.pi * frequency * dt * np.arange(num_samples)
self.phase = phases[-1] % (2 * np.pi)
return depth * np.sin(phases)
class EnvelopeGenerator:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
self.time = 0.0
self.gate_open = True
def generate(self, attack, decay, sustain, release, num_samples):
dt = 1.0 / self.sample_rate
envelope = np.zeros(num_samples)
for i in range(num_samples):
t = self.time
if self.gate_open:
if t < attack:
val = t / attack
elif t < (attack + decay):
decay_time = t - attack
val = 1.0 - (1.0 - sustain) * (decay_time / decay)
else:
val = sustain
else:
val = sustain * max(0.0, 1.0 - (t / release))
envelope[i] = val
self.time += dt
return envelope
class WaveShaper:
def drive(self, audio_in, gain):
return np.tanh(audio_in * gain)
class VCF:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
self.ic1eq = 0.0
self.ic2eq = 0.0
def process(self, audio_in, cutoff_frequencies, resonance):
num_samples = len(audio_in)
output = np.zeros(num_samples)
q = 1.0 / (2.0 * (1.0 - min(0.99, resonance)))
g_damping = 1.0 / q
for i in range(num_samples):
cutoff = max(20.0, min(cutoff_frequencies[i], self.sample_rate / 3.0))
g = np.tan(np.pi * cutoff / self.sample_rate)
k = g_damping
v0 = audio_in[i]
v3 = v0 - self.ic2eq
v1 = (self.ic1eq * (1.0 - g*k) + g*v3) / (1.0 + g*(g + k))
v2 = self.ic2eq + g*v1
self.ic1eq = 2.0 * v1 - self.ic1eq
self.ic2eq = 2.0 * v2 - self.ic2eq
output[i] = v2
return output
class StereoChorus:
def __init__(self, sample_rate=44100, max_delay_samples=1000):
self.sample_rate = sample_rate
self.buffer = np.zeros(max_delay_samples * 2)
self.write_idx = 0
def process(self, audio_in, rate=1.0, depth=25, mix=0.4):
num_samples = len(audio_in)
left_out, right_out = np.zeros(num_samples), np.zeros(num_samples)
buf_len = len(self.buffer)
t = np.arange(num_samples) / self.sample_rate
lfo = np.sin(2 * np.pi * rate * t)
for i in range(num_samples):
self.buffer[self.write_idx] = audio_in[i]
delay_l = 200 + depth * lfo[i]
delay_r = 240 + depth * (-lfo[i])
left_out[i] = (1.0 - mix) * audio_in[i] + mix * self.buffer[int(self.write_idx - delay_l) % buf_len]
right_out[i] = (1.0 - mix) * audio_in[i] + mix * self.buffer[int(self.write_idx - delay_r) % buf_len]
self.write_idx = (self.write_idx + 1) % buf_len
return left_out, right_out
# =====================================================================
# 2. THE SIGNAL CHAIN PIPELINE
# =====================================================================
def run_synth_pipeline(target_note_hz, duration=2.0, sample_rate=44100):
num_samples = int(sample_rate * duration)
# Instantiate all components
glide_unit = Portamento(sample_rate)
main_vco = VCO(sample_rate)
sub_vco = VCO(sample_rate)
noise_gen = NoiseGenerator()
lfo_unit = LFO(sample_rate)
amp_eg = EnvelopeGenerator(sample_rate)
filter_eg = EnvelopeGenerator(sample_rate)
shaper = WaveShaper()
filter_unit= VCF(sample_rate)
chorus = StereoChorus(sample_rate)
# Initialize portamento with a starting note 1 octave below target
glide_unit.current_freq = target_note_hz / 2.0
print("--- 1. Generating Control Signals ---")
# Smooth glide over 0.3 seconds to our target note
pitch_frequencies = glide_unit.process(target_note_hz, glide_time=0.3, num_samples=num_samples)
# Create LFO for subtle pitch vibrato (6 Hz)
vibrato = lfo_unit.generate(frequency=6.0, depth=0.005, num_samples=num_samples)
vco_pitches = pitch_frequencies * (1.0 + vibrato)
# Generate envelopes
vol_envelope = amp_eg.generate(attack=0.05, decay=0.3, sustain=0.6, release=0.4, num_samples=num_samples)
filter_envelope = filter_eg.generate(attack=0.1, decay=0.4, sustain=0.2, release=0.4, num_samples=num_samples)
print("--- 2. Generating & Mixing Audio Sources ---")
# Main Oscillator (Sawtooth)
vco_main_out = main_vco.generate(vco_pitches, waveform='sawtooth')
# Sub Oscillator (Square wave pitched down exactly 1 octave)
vco_sub_out = sub_vco.generate(vco_pitches / 2.0, waveform='square')
# White Noise
noise_out = noise_gen.generate(num_samples)
# Mixer Stage
mixed_audio = (0.5 * vco_main_out) + (0.4 * vco_sub_out) + (0.05 * noise_out)
print("--- 3. Processing Waveshaper / Saturation ---")
# Add warm harmonic distortion (Gain factor = 2.5)
saturated_audio = shaper.drive(mixed_audio, gain=2.5)
print("--- 4. Applying Filter (VCF) ---")
# Base filter cutoff at 150Hz, sweeping up dynamically by 2500Hz via the envelope
cutoff_modulation = 150.0 + (filter_envelope * 2500.0)
filtered_audio = filter_unit.process(saturated_audio, cutoff_frequencies=cutoff_modulation, resonance=0.65)
print("--- 5. Applying Amplifier (VCA) ---")
# Apply volume scaling envelope
vca_output = filtered_audio * vol_envelope
print("--- 6. Applying Spatial Chorus Effect ---")
# Turn the mono stream into an expansive stereo audio array
stereo_left, stereo_right = chorus.process(vca_output, rate=1.2, depth=35, mix=0.4)
# Interleave left and right channels into a master stereo array
master_stereo = np.vstack((stereo_left, stereo_right)).T
# Normalize final audio to avoid hard digital clipping
max_val = np.max(np.abs(master_stereo))
if max_val > 0:
master_stereo = master_stereo / max_val
return master_stereo
# =====================================================================
# 3. RUN AND RENDER
# =====================================================================
if __name__ == "__main__":
SR = 44100
# Process a fat synth bassline note at E2 (82.41 Hz)
synth_audio = run_synth_pipeline(target_note_hz=82.41, duration=2.5, sample_rate=SR)
# Cast to float32 WAV standards and export
audio_out = (synth_audio).astype(np.float32)
wav.write("python_synth_output.wav", SR, audio_out)
print("\n[Success] Render completed! Saved output as 'python_synth_output.wav'")
Why this design works seamlessly together:
No Clipping Real Estate: In Step 2, the inputs inside the mixer are proportional coefficients (
0.5 + 0.4 + 0.05 = 0.95). Keeping them scaled under 1.0 prevents numeric overflow before it hits the filter.Warmth over Digital Harshness: Passing the mixed audio through
np.tanhright before the VCF allows the distortion to create aggressive high harmonics. The filter then immediately sweeps down to clean up those harsh frequencies, mimicking real analog hardware.Separation of Concerns: The volume envelope determines when the sound starts and dies (
amp_eg), while a dedicated secondary envelope (filter_eg) dictates exclusively how bright or dark the instrument sounds.Stereo Widening at the Tail-End: Effects like Chorus always go last. Keeping the internal synth matrix strictly monophonic saves CPU, spreading into stereo only at the final master bus stage.
Add a PyQT6 GUI to support synth patch creation, saving, clearing, and a "keyboard" to play notes, with 3-octave selection
To turn your modular Python engine into an interactive standalone instrument, we can wrap it in a PyQt6 GUI.
Because Python runs on a single main thread by default, attempting to process heavy audio blocks directly inside a UI loop will freeze the window. To prevent this, the code below uses a background thread (QThread) for processing and real-time streaming via sounddevice.
Prerequisites
Make sure you install the required GUI and real-time audio libraries:
pip install PyQt6 sounddevice numpy scipy
The Complete Integrated App (synth_app.py)
Run this single file to launch your synth workstation. It features a fully responsive control dashboard, real-time audio compilation, json-based patch memory management, and an interactive musical typing canvas spanning 3 octaves.
import sys
import json
import numpy as np
import sounddevice as sd
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QGridLayout, QGroupBox, QSlider,
QLabel, QPushButton, QComboBox, QFileDialog, QMessageBox)
from PyQt6.QtCore import Qt, QThread, pyqtSignal
# =====================================================================
# 1. SYNTH ENGINE MODULES (Optimized for Real-Time Compilation)
# =====================================================================
class Portamento:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
self.current_freq = None
def process(self, target_frequency, glide_time, num_samples):
if self.current_freq is None: self.current_freq = target_frequency
if glide_time <= 0.001: return np.full(num_samples, target_frequency)
alpha = 1.0 - np.exp(-1.0 / (glide_time * self.sample_rate))
freq_array = np.zeros(num_samples)
for i in range(num_samples):
self.current_freq += alpha * (target_frequency - self.current_freq)
freq_array[i] = self.current_freq
return freq_array
class VCO:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
self.phase = 0.0
def generate(self, frequencies, waveform='sawtooth'):
num_samples = len(frequencies)
dt = 1.0 / self.sample_rate
phases = self.phase + np.cumsum(2 * np.pi * frequencies * dt)
self.phase = phases[-1] % (2 * np.pi) if num_samples > 0 else self.phase
if waveform == 'sine': return np.sin(phases)
elif waveform == 'sawtooth': return 2.0 * (phases % (2 * np.pi) / (2 * np.pi)) - 1.0
elif waveform == 'square': return np.sign(np.sin(phases))
return np.sin(phases)
class NoiseGenerator:
def generate(self, num_samples):
return np.random.uniform(-1.0, 1.0, num_samples)
class LFO:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
self.phase = 0.0
def generate(self, frequency, depth, num_samples):
dt = 1.0 / self.sample_rate
phases = self.phase + 2 * np.pi * frequency * dt * np.arange(num_samples)
self.phase = phases[-1] % (2 * np.pi) if num_samples > 0 else self.phase
return depth * np.sin(phases)
class EnvelopeGenerator:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
def generate(self, attack, decay, sustain, release, num_samples):
dt = 1.0 / self.sample_rate
envelope = np.zeros(num_samples)
time = 0.0
# Calculate full ADSR curve for the single block trigger
for i in range(num_samples):
if time < attack:
val = time / max(0.001, attack)
elif time < (attack + decay):
val = 1.0 - (1.0 - sustain) * ((time - attack) / max(0.001, decay))
elif time < (num_samples * dt - release):
val = sustain
else:
release_start = num_samples * dt - release
val = sustain * max(0.0, 1.0 - ((time - release_start) / max(0.001, release)))
envelope[i] = val
time += dt
return envelope
class WaveShaper:
def drive(self, audio_in, gain):
return np.tanh(audio_in * gain)
class VCF:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
self.ic1eq, self.ic2eq = 0.0, 0.0
def process(self, audio_in, cutoff_frequencies, resonance):
num_samples = len(audio_in)
output = np.zeros(num_samples)
q = 1.0 / (2.0 * (1.0 - min(0.99, resonance)))
g_damping = 1.0 / q
for i in range(num_samples):
cutoff = max(20.0, min(cutoff_frequencies[i], self.sample_rate / 3.0))
g = np.tan(np.pi * cutoff / self.sample_rate)
v0 = audio_in[i]
v3 = v0 - self.ic2eq
v1 = (self.ic1eq * (1.0 - g * g_damping) + g * v3) / (1.0 + g * (g + g_damping))
v2 = self.ic2eq + g * v1
self.ic1eq = 2.0 * v1 - self.ic1eq
self.ic2eq = 2.0 * v2 - self.ic2eq
output[i] = v2
return output
class StereoChorus:
def __init__(self, sample_rate=44100):
self.sample_rate = sample_rate
self.buffer = np.zeros(2000)
self.write_idx = 0
def process(self, audio_in, rate, depth, mix):
num_samples = len(audio_in)
left_out, right_out = np.zeros(num_samples), np.zeros(num_samples)
buf_len = len(self.buffer)
t = np.arange(num_samples) / self.sample_rate
lfo = np.sin(2 * np.pi * rate * t)
for i in range(num_samples):
self.buffer[self.write_idx] = audio_in[i]
delay_l = 200 + depth * lfo[i]
delay_r = 240 + depth * (-lfo[i])
left_out[i] = (1.0 - mix) * audio_in[i] + mix * self.buffer[int(self.write_idx - delay_l) % buf_len]
right_out[i] = (1.0 - mix) * audio_in[i] + mix * self.buffer[int(self.write_idx - delay_r) % buf_len]
self.write_idx = (self.write_idx + 1) % buf_len
return left_out, right_out
# =====================================================================
# 2. AUDIO BACKGROUND WORKER THREAD
# =====================================================================
class AudioWorker(QThread):
def __init__(self, target_hz, params, sample_rate=44100):
super().__init__()
self.target_hz = target_hz
self.p = params
self.sample_rate = sample_rate
def run(self):
duration = 1.2 # Max fixed block length per click
num_samples = int(self.sample_rate * duration)
# Pipelines
glide_unit = Portamento(self.sample_rate)
main_vco = VCO(self.sample_rate)
sub_vco = VCO(self.sample_rate)
noise_gen = NoiseGenerator()
lfo_unit = LFO(self.sample_rate)
amp_eg = EnvelopeGenerator(self.sample_rate)
filter_eg = EnvelopeGenerator(self.sample_rate)
shaper = WaveShaper()
vcf_unit = VCF(self.sample_rate)
chorus = StereoChorus(self.sample_rate)
# Process Blocks
pitches = glide_unit.process(self.target_hz, self.p['glide'], num_samples)
vibrato = lfo_unit.generate(self.p['lfo_rate'], self.p['lfo_depth'] * 0.1, num_samples)
vco_pitches = pitches * (1.0 + vibrato)
vol_env = amp_eg.generate(self.p['amp_a'], self.p['amp_d'], self.p['amp_s'], self.p['amp_r'], num_samples)
fil_env = filter_eg.generate(self.p['fil_a'], self.p['fil_d'], self.p['fil_s'], self.p['fil_r'], num_samples)
vco_out = main_vco.generate(vco_pitches, self.p['waveform'])
sub_out = sub_vco.generate(vco_pitches / 2.0, 'square')
noise_out = noise_gen.generate(num_samples)
mixed = (vco_out * self.p['mix_vco']) + (sub_out * self.p['mix_sub']) + (noise_out * self.p['mix_noise'])
saturated = shaper.drive(mixed, self.p['drive'])
cutoffs = self.p['cutoff'] + (fil_env * self.p['fil_env_amt'] * 5000.0)
filtered = vcf_unit.process(saturated, cutoffs, self.p['res'])
vca_out = filtered * vol_env
s_left, s_right = chorus.process(vca_out, self.p['cho_rate'], self.p['cho_depth'], self.p['cho_mix'])
master = np.vstack((s_left, s_right)).T
max_val = np.max(np.abs(master))
if max_val > 0: master /= max_val
# Audio playback trigger
sd.play(master.astype(np.float32), self.sample_rate)
sd.wait()
# =====================================================================
# 3. MAIN PYQT6 APPLICATION WINDOW
# =====================================================================
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Python Subtractive Synthesizer Studio")
self.setMinimumSize(950, 650)
self.base_octave = 3 # Root tracking index
self.sliders = {}
# Note configuration
self.note_names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'D#', 'B']
self.note_frequencies = [16.35, 17.32, 18.35, 19.45, 20.60, 21.83, 23.12, 24.50, 25.96, 27.50, 29.14, 30.87]
self.init_ui()
self.reset_to_defaults()
def init_ui(self):
main_widget = QWidget()
self.setCentralWidget(main_widget)
main_layout = QVBoxLayout(main_widget)
# --- TOP LEVEL FILE MANAGEMENT UTILITIES ---
top_bar = QHBoxLayout()
save_btn = QPushButton("Save Patch")
save_btn.clicked.connect(self.save_patch)
load_btn = QPushButton("Load Patch")
load_btn.clicked.connect(self.load_patch)
clear_btn = QPushButton("Clear/Reset Synth")
clear_btn.clicked.connect(self.reset_to_defaults)
self.octave_combo = QComboBox()
self.octave_combo.addItems(["Octaves 1-3 (Bass)", "Octaves 3-5 (Mid)", "Octaves 5-7 (Lead)"])
self.octave_combo.setCurrentIndex(1)
self.octave_combo.currentIndexChanged.connect(self.change_octave_range)
top_bar.addWidget(save_btn)
top_bar.addWidget(load_btn)
top_bar.addWidget(clear_btn)
top_bar.addStretch()
top_bar.addWidget(QLabel("Keyboard Range:"))
top_bar.addWidget(self.octave_combo)
main_layout.addLayout(top_bar)
# --- SYNTH PARAMETERS CONTROL PANEL MATRIX ---
controls_layout = QGridLayout()
# Oscillator & Mixer Config
vco_box = QGroupBox("Oscillators / Mixer")
vco_v = QVBoxLayout()
self.wave_combo = QComboBox()
self.wave_combo.addItems(["sawtooth", "sine", "square"])
vco_v.addWidget(QLabel("Main Waveform:"))
vco_v.addWidget(self.wave_combo)
self.add_slider(vco_v, "Main VCO Level", "mix_vco", 0, 100, 70, 0.01)
self.add_slider(vco_v, "Sub VCO Level", "mix_sub", 0, 100, 50, 0.01)
self.add_slider(vco_v, "Noise Level", "mix_noise", 0, 100, 5, 0.01)
self.add_slider(vco_v, "Portamento Glide", "glide", 0, 100, 15, 0.01)
vco_box.setLayout(vco_v)
controls_layout.addWidget(vco_box, 0, 0)
# Filter (VCF) Controls
vcf_box = QGroupBox("Filter (VCF)")
vcf_v = QVBoxLayout()
self.add_slider(vcf_v, "Cutoff Base (Hz)", "cutoff", 50, 4000, 800, 1)
self.add_slider(vcf_v, "Resonance (Q)", "res", 0, 95, 30, 0.01)
self.add_slider(vcf_v, "Filter Env Amount", "fil_env_amt", 0, 100, 40, 0.01)
vcf_box.setLayout(vcf_v)
controls_layout.addWidget(vcf_box, 0, 1)
# Modulation (LFO / Saturation)
mod_box = QGroupBox("Modulation & Drive")
mod_v = QVBoxLayout()
self.add_slider(mod_v, "LFO Rate (Hz)", "lfo_rate", 1, 20, 6, 1)
self.add_slider(mod_v, "LFO Pitch Depth", "lfo_depth", 0, 100, 5, 0.01)
self.add_slider(mod_v, "Saturation Drive", "drive", 10, 100, 15, 0.1)
mod_box.setLayout(mod_v)
controls_layout.addWidget(mod_box, 0, 2)
# Envelopes Matrix
env_box = QGroupBox("Envelopes (ADSR)")
env_h = QHBoxLayout()
amp_box = QGroupBox("Amp Envelope")
amp_v = QVBoxLayout()
self.add_slider(amp_v, "Attack (s)", "amp_a", 0, 100, 5, 0.01)
self.add_slider(amp_v, "Decay (s)", "amp_d", 0, 100, 25, 0.01)
self.add_slider(amp_v, "Sustain", "amp_s", 0, 100, 70, 0.01)
self.add_slider(amp_v, "Release (s)", "amp_r", 2, 100, 25, 0.01)
amp_box.setLayout(amp_v)
fil_box = QGroupBox("Filter Envelope")
fil_v = QVBoxLayout()
self.add_slider(fil_v, "Attack (s)", "fil_a", 0, 100, 10, 0.01)
self.add_slider(fil_v, "Decay (s)", "fil_d", 0, 100, 30, 0.01)
self.add_slider(fil_v, "Sustain", "fil_s", 0, 100, 30, 0.01)
self.add_slider(fil_v, "Release (s)", "fil_r", 2, 100, 30, 0.01)
fil_box.setLayout(fil_v)
env_h.addWidget(amp_box)
env_h.addWidget(fil_box)
env_box.setLayout(env_h)
controls_layout.addWidget(env_box, 1, 0, 1, 2)
# FX Rack
fx_box = QGroupBox("Stereo Chorus FX")
fx_v = QVBoxLayout()
self.add_slider(fx_v, "Chorus Mix", "cho_mix", 0, 100, 35, 0.01)
self.add_slider(fx_v, "Chorus Rate", "cho_rate", 1, 5, 1, 0.1)
self.add_slider(fx_v, "Chorus Depth", "cho_depth", 0, 50, 20, 1)
fx_box.setLayout(fx_v)
controls_layout.addWidget(fx_box, 1, 2)
main_layout.addLayout(controls_layout)
# --- INTERACTIVE 3-OCTAVE KEYBOARD ---
self.keyboard_container = QHBoxLayout()
self.build_interactive_keyboard()
main_layout.addLayout(self.keyboard_container)
def add_slider(self, layout, label_text, target_key, min_v, max_v, default_v, scale):
lbl = QLabel(f"{label_text}: {default_v}")
slider = QSlider(Qt.Orientation.Horizontal)
slider.setMinimum(min_v)
slider.setMaximum(max_v)
slider.setValue(default_v)
slider.valueChanged.connect(lambda val: lbl.setText(f"{label_text}: {round(val * scale, 2)}"))
layout.addWidget(lbl)
layout.addWidget(slider)
self.sliders[target_key] = (slider, scale)
def build_interactive_keyboard(self):
# Clear layout safely first
while self.keyboard_container.count():
child = self.keyboard_container.takeAt(0)
if child.widget(): child.widget().deleteLater()
keyboard_widget = QWidget()
kb_layout = QHBoxLayout(keyboard_widget)
kb_layout.setSpacing(2)
# Draw 3 explicit octaves based on context range selector
for octave_offset in range(3):
act_octave = self.base_octave + octave_offset
for idx, note in enumerate(self.note_names):
note_text = f"{note}{act_octave}"
btn = QPushButton(note_text)
btn.setMinimumHeight(140)
# Dynamic keys mapping styles
if "#" in note:
btn.setStyleSheet("background-color: #222; color: white; font-weight: bold;")
btn.setFixedWidth(28)
else:
btn.setStyleSheet("background-color: #f7f7f7; color: black;")
btn.setFixedWidth(40)
hz = self.note_frequencies[idx] * (2 ** act_octave)
btn.clicked.connect(lambda checked, frequency=hz: self.trigger_note(frequency))
kb_layout.addWidget(btn)
self.keyboard_container.addWidget(keyboard_widget)
# =====================================================================
# 4. DATA HANDLING AND ENGINE INTEGRATION
# =====================================================================
def get_current_parameters(self):
# Parse slider integers back to exact floating values
params = {k: s[0].value() * s[1] for k, s in self.sliders.items()}
params['waveform'] = self.wave_combo.currentText()
return params
def trigger_note(self, hz):
# Fire compilation inside safe worker execution thread context
self.worker = AudioWorker(hz, self.get_current_parameters())
self.worker.start()
def change_octave_range(self, index):
ranges = [1, 3, 5]
self.base_octave = ranges[index]
self.build_interactive_keyboard()
def reset_to_defaults(self):
defaults = {
"mix_vco": 70, "mix_sub": 40, "mix_noise": 5, "glide": 10,
"cutoff": 1200, "res": 25, "fil_env_amt": 35,
"lfo_rate": 5, "lfo_depth": 10, "drive": 12,
"amp_a": 5, "amp_d": 20, "amp_s": 75, "amp_r": 20,
"fil_a": 10, "fil_d": 30, "fil_s": 40, "fil_r": 25,
"cho_mix": 20, "cho_rate": 1, "cho_depth": 15
}
for key, value in defaults.items():
if key in self.sliders:
self.sliders[key][0].setValue(value)
self.wave_combo.setCurrentIndex(0)
def save_patch(self):
file_path, _ = QFileDialog.getSaveFileName(self, "Save Patch Profile", "", "Synth Patch (*.json)")
if file_path:
try:
with open(file_path, 'w') as f:
json.dump(self.get_current_parameters(), f, indent=4)
QMessageBox.information(self, "Saved", "Patch configurations saved successfully.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Could not save data setup: {str(e)}")
def load_patch(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Load Patch Profile", "", "Synth Patch (*.json)")
if file_path:
try:
with open(file_path, 'r') as f:
data = json.load(f)
# Re-map loaded settings back to structural items
for key, val in data.items():
if key in self.sliders:
raw_int = int(val / self.sliders[key][1])
self.sliders[key][0].setValue(raw_int)
if 'waveform' in data:
self.wave_combo.setCurrentText(data['waveform'])
QMessageBox.information(self, "Loaded", "Patch layout setup successfully loaded.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to parse patch data file profile: {str(e)}")
# =====================================================================
# 5. ENTRY EXECUTION RUNTIME BLOCK
# =====================================================================
if __name__ == "__main__":
app = QApplication(sys.argv)
# Give the app layout canvas a sleek modern dark vibe theme profile
app.setStyle('Fusion')
window = MainWindow()
window.show()
sys.exit(app.exec())
What Makes This Implementation Work
Anti-Freeze Audio Threading (
QThread): If you try to process a large NumPy loop inside standard PyQt callback triggers, the UI will freeze and crackle. Moving the entire signal chain instantiation inside the worker thread ensures the interface remains completely responsive while processing audio.Dynamic 3-Octave Render mapping: Changing the dropdown menu switches the base tracking array range (
Octaves 1-3,3-5, or5-7) and completely rebuilds the UI buttons dynamically, mapping the keys to their correct absolute frequencies.Lossless Patch Storage Engine: Saving and loading works by exporting a dictionary map directly into light standard JSON documents. When loading, it reverses the math logic to convert the saved floating point configurations back into slider steps.
Describe how to add generative-AI to do fun and creative things with the synth
To make this Python synthesizer truly intelligent and experimental, you can integrate Generative AI. Since the entire synth is controlled by a simple JSON structure (as implemented in the PyQt6 patch system), it is incredibly easy for an AI to interact with it.
You aren't asking the AI to generate raw audio files; instead, you are asking it to generate the patch configurations, modulation curves, or musical scores that drive your engine.
Here are four creative ways to integrate Generative AI into your synthesizer workspace:
1. Natural Language "Text-to-Patch" Preset Generator
Instead of turning knobs manually, you can use a Small Language Model (like a locally run Llama 3 or an OpenAI API call) to listen to a user's descriptive prompt and turn it into a precise configuration block.
How it Works:
You provide the AI with a strict system prompt containing your JSON patch schema and ask it to respond only with valid JSON.
User Prompt: "Give me a dark, eerie retro sci-fi bass line that sounds like a corrupted spaceship engine."
AI Output: ```json { "mix_vco": 0.30, "mix_sub": 0.80, "mix_noise": 0.25, "waveform": "square", "cutoff": 350, "res": 0.65, "drive": 4.5, "glide": 0.40, "amp_a": 0.15, "amp_d": 0.40, "amp_s": 0.50, "amp_r": 0.60 }
* **Why it's fun:** It acts like an intelligent sound designer assistant. You can type abstract emotions ("melancholic", "cyberpunk", "underwater rubber duck") and hear the synth reconfigure itself instantly.
---
## 2. LLM "Patch Breeder" (Evolutionary Presets)
You can use an AI to take a patch you already like and "breed" variations of it, acting like a genetic mutation algorithm.
### How it Works:
1. You pass your current favorite JSON patch to the LLM.
2. You prompt the AI: *"Analyze this patch. Generate 3 unique mutations. Mutant A should be slightly dirtier, Mutant B should be a spacey/ambient version, and Mutant C should be unpredictable and chaotic."*
3. The UI populates three buttons: **[Mutate: Aggressive]**, **[Mutate: Spacey]**, **[Mutate: Chaos]**.
* **Why it's fun:** It breaks you out of "producer's block" by introducing intelligent controlled randomness based on settings you already enjoy.
---
## 3. Neural Algorithmic Generative Sequencer (Markov Chains or RNNs)
Instead of just playing single notes, you can use a lightweight generative model to write ever-evolving MIDI patterns that play your synth engine automatically.
### How it Works:
* You can implement a small **Markov Chain** or use an online neural network trained on Bach or classical music.
* The model looks at the note you just pressed, calculates a probability matrix of what note should logically come next based on a specific musical scale (e.g., *C Minor Pentatonic*), and auto-plays an infinite, never-repeating melody.
* **Why it's fun:** You turn the synth on, select a mood, and let the AI perform an endless live ambient concert or acid-house bassline while you tweak the filter knobs in real-time.
---
## 4. AI Audio-to-Patch "Mimic" (De-Synthesizer)
This is an advanced technique where you give an AI an audio recording of a sound from the real world, and it tries to replicate that sound using *only* your synthesizer knobs.
### How it Works:
1. You load a short `.wav` file into the app (e.g., a coin dropping, a person whistling, or a clip of a vintage synth from a movie).
2. A **Convolutional Neural Network (CNN)** or a Reinforcement Learning agent analyzes the audio frequency footprint.
3. The AI runs your Python synth engine in the background hundreds of times, tweaking the knobs automatically using an optimization algorithm until the output sound matches the input `.wav` file as closely as possible.
* **Why it's fun:** You can feed the AI a sample of a laser beam or an animal growl, and see how close your digital code can get to mimicking reality.
---
## Code Concept: Quick Text-to-Patch Implementation
To add this to your PyQt6 GUI, you would add a `QLineEdit` text box and a "Generate" button that connects to an asynchronous API worker like this:
import openai # or a local framework like ollama
def generate_ai_patch(user_text_prompt):
system_instructions = (
"You are an expert synthesizer sound designer. Respond ONLY with a raw JSON object "
"matching these exact keys: mix_vco (0-1), mix_sub (0-1), mix_noise (0-1), "
"cutoff (50-4000), res (0-0.95), drive (1-10), waveform (sine, sawtooth, square)."
)
response = openai.chat.completions.create(
model="gpt-4o-mini", # an exceptionally fast, cheap model for JSON tasks
response_format={ "type": "json_object" }, # Forces JSON output
messages=[
{"role": "system", "content": system_instructions},
{"role": "user", "content": f"Create a patch for: {user_text_prompt}"}
]
)
# Parse the response dictionary and feed it straight into your synth's sliders!
patch_json = json.loads(response.choices[0].message.content)
return patch_json
Which of these generative ideas sounds like the one you'd want to experiment with first?
From <https://gemini.google.com/app/> Google Gemini (3.5 Flash)

Comments
Post a Comment