diff --git a/synth/main.py b/synth/main.py new file mode 100644 index 0000000..1366eb4 --- /dev/null +++ b/synth/main.py @@ -0,0 +1,208 @@ +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_())