import sys import numpy as np import sounddevice as sd from PyQt5.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QComboBox, QDial, QPushButton, QGroupBox ) from PyQt5.QtCore import Qt, QTimer import pyqtgraph as pg class ADSREnvelope: def __init__(self, fs): self.fs = fs self.attack = 0.01 self.decay = 0.1 self.sustain = 0.7 self.release = 0.2 self.state = 'idle' self.level = 0.0 self.counter = 0 self.note_on_time = 0 self.note_off_time = 0 def trigger_on(self): self.state = 'attack' self.counter = 0 def trigger_off(self): self.state = 'release' self.counter = 0 def process(self, frames): env = np.zeros(frames) for i in range(frames): if self.state == 'attack': self.level += 1.0 / (self.attack * self.fs) if self.level >= 1.0: self.level = 1.0 self.state = 'decay' elif self.state == 'decay': self.level -= (1.0 - self.sustain) / (self.decay * self.fs) if self.level <= self.sustain: self.level = self.sustain self.state = 'sustain' elif self.state == 'sustain': self.level = self.sustain elif self.state == 'release': self.level -= self.sustain / (self.release * self.fs) if self.level <= 0.0: self.level = 0.0 self.state = 'idle' env[i] = self.level return env class Synthesizer(QWidget): def __init__(self): super().__init__() self.setWindowTitle("Eurorack-style Synthesizer") self.setStyleSheet("background-color: #1e1e1e; color: #ffffff;") self.fs = 44100 self.phase = 0.0 self.lfo_phase = 0.0 # VCO self.waveform = 'Sine' self.frequency = 440.0 self.amplitude = 0.5 # LFO self.lfo_wave = 'Sine' self.lfo_rate = 5.0 self.lfo_depth = 0.0 # Envelope self.env = ADSREnvelope(self.fs) # Filter self.filter_prev = 0.0 self.filter_cutoff = 5000.0 self.filter_type = 'Low-pass' # Stream self.stream = sd.OutputStream( samplerate=self.fs, channels=1, callback=self.audio_callback ) self.init_ui() self.init_plot() def init_ui(self): main = QVBoxLayout() # VCO vco = QGroupBox('VCO') gv = QGridLayout() vco.setLayout(gv) gv.addWidget(QLabel('Waveform:'),0,0) self.wave_combo = QComboBox(); self.wave_combo.addItems(['Sine','Square','Sawtooth','Triangle']) self.wave_combo.currentTextChanged.connect(lambda t:setattr(self,'waveform',t)) gv.addWidget(self.wave_combo,0,1) gv.addWidget(QLabel('Freq'),1,0) self.freq_dial=QDial(); self.freq_dial.setRange(20,2000); self.freq_dial.setValue(440) self.freq_dial.valueChanged.connect(self.on_freq_change); gv.addWidget(self.freq_dial,1,1) self.freq_label=QLabel('440 Hz'); gv.addWidget(self.freq_label,1,2) main.addWidget(vco) # LFO lfo = QGroupBox('LFO') gl = QGridLayout(); lfo.setLayout(gl) gl.addWidget(QLabel('Wave:'),0,0) self.lfo_combo=QComboBox(); self.lfo_combo.addItems(['Sine','Square','Sawtooth','Triangle']) self.lfo_combo.currentTextChanged.connect(lambda t:setattr(self,'lfo_wave',t)); gl.addWidget(self.lfo_combo,0,1) gl.addWidget(QLabel('Rate'),1,0) self.lfo_rate_dial=QDial(); self.lfo_rate_dial.setRange(1,200); self.lfo_rate_dial.setValue(50) self.lfo_rate_dial.valueChanged.connect(self.on_lfo_rate_change); gl.addWidget(self.lfo_rate_dial,1,1) self.lfo_rate_label=QLabel('5.0 Hz'); gl.addWidget(self.lfo_rate_label,1,2) gl.addWidget(QLabel('Depth'),2,0) self.lfo_depth_dial=QDial(); self.lfo_depth_dial.setRange(0,100); self.lfo_depth_dial.setValue(0) self.lfo_depth_dial.valueChanged.connect(self.on_lfo_depth_change); gl.addWidget(self.lfo_depth_dial,2,1) self.lfo_depth_label=QLabel('0.00'); gl.addWidget(self.lfo_depth_label,2,2) main.addWidget(lfo) # Envelope env = QGroupBox('Envelope (ADSR)') ge = QGridLayout(); env.setLayout(ge) params=['attack','decay','sustain','release'] ranges=[(1,5000),(1,5000),(0,100),(1,5000)] defaults=[10,100,70,200] for i,param in enumerate(params): ge.addWidget(QLabel(param.title()),i,0) dial=QDial(); dial.setRange(*ranges[i]); dial.setValue(defaults[i]) dial.valueChanged.connect(lambda v,p=param: setattr(self.env,p,(v/1000.0) if p!='sustain' else (v/100))) ge.addWidget(dial,i,1) lbl=QLabel(str(defaults[i])); ge.addWidget(lbl,i,2) dial.valueChanged.connect(lambda v,l=lbl,p=param: l.setText(f"{(v/1000.0):.3f}" if p!='sustain' else f"{(v/100):.2f}")) main.addWidget(env) # Filter flt = QGroupBox('Filter') gf = QGridLayout(); flt.setLayout(gf) gf.addWidget(QLabel('Type'),0,0) self.filt_combo=QComboBox(); self.filt_combo.addItems(['Low-pass','High-pass']) self.filt_combo.currentTextChanged.connect(lambda t:setattr(self,'filter_type',t)); gf.addWidget(self.filt_combo,0,1) gf.addWidget(QLabel('Cutoff'),1,0) self.fcut_dial=QDial(); self.fcut_dial.setRange(20,20000); self.fcut_dial.setValue(5000) self.fcut_dial.valueChanged.connect(self.on_fcut_change); gf.addWidget(self.fcut_dial,1,1) self.fcut_label=QLabel('5000'); gf.addWidget(self.fcut_label,1,2) main.addWidget(flt) # Controls btns=QHBoxLayout() self.start_btn=QPushButton('Start'); self.start_btn.clicked.connect(self.start_audio) self.stop_btn=QPushButton('Stop'); self.stop_btn.clicked.connect(self.stop_audio) btns.addWidget(self.start_btn); btns.addWidget(self.stop_btn) main.addLayout(btns) self.setLayout(main) def init_plot(self): self.plot=pg.PlotWidget(title='Waveform'); self.plot.setYRange(-1,1) self.curve=self.plot.plot(pen=pg.mkPen('y')); self.layout().addWidget(self.plot) self.timer=QTimer(); self.timer.timeout.connect(self.update_plot); self.timer.start(50) def update_plot(self): t=(np.arange(512)+self.phase)/self.fs y=self.generate_wave(self.waveform,self.frequency,t) self.curve.setData(y) def on_freq_change(self,val): self.frequency=val; self.freq_label.setText(f"{val} Hz") def on_lfo_rate_change(self,val): self.lfo_rate=val/10.0; self.lfo_rate_label.setText(f"{self.lfo_rate:.1f} Hz") def on_lfo_depth_change(self,val): self.lfo_depth=val/100.0; self.lfo_depth_label.setText(f"{self.lfo_depth:.2f}") def on_fcut_change(self,val): self.filter_cutoff=val; self.fcut_label.setText(str(val)) def generate_wave(self,shape,freq,t): if shape=='Sine': return np.sin(2*np.pi*freq*t) if shape=='Square': return np.sign(np.sin(2*np.pi*freq*t)) if shape=='Sawtooth': return 2*(t*freq-np.floor(0.5+t*freq)) if shape=='Triangle': return 2*np.abs(2*(t*freq-np.floor(0.5+t*freq)))-1 def audio_callback(self,outdata,frames,time,status): t=(np.arange(frames)+self.phase)/self.fs lt=(np.arange(frames)+self.lfo_phase)/self.fs lfo=self.generate_wave(self.lfo_wave,self.lfo_rate,lt) freq_mod=self.frequency+self.lfo_depth*self.lfo_rate*lfo wav=self.generate_wave(self.waveform,freq_mod,t) env=self.env.process(frames) sig=wav*self.amplitude*env # simple one-pole filter f=2*np.sin(np.pi*self.filter_cutoff/self.fs) out=np.zeros_like(sig) for i,x in enumerate(sig): if self.filter_type=='Low-pass': self.filter_prev+=f*(x-self.filter_prev) out[i]=self.filter_prev else: # high-pass: x - lowpass self.filter_prev+=f*(x-self.filter_prev) out[i]=x-self.filter_prev outdata[:] = out.reshape(-1,1) self.phase=(self.phase+frames)%self.fs; self.lfo_phase=(self.lfo_phase+frames)%self.fs def start_audio(self): if not self.stream.active: self.stream.start(); self.env.trigger_on() def stop_audio(self): if self.stream.active: self.env.trigger_off(); self.stream.stop() if __name__=='__main__': app=QApplication(sys.argv); synth=Synthesizer(); synth.resize(600,900); synth.show(); sys.exit(app.exec_())