Create main.py

This commit is contained in:
OusmBlueNinja 2025-04-20 18:51:35 -05:00
parent 51f3f20a24
commit 72fed6e258

208
synth/main.py Normal file
View File

@ -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_())