Create main.py
This commit is contained in:
parent
51f3f20a24
commit
72fed6e258
208
synth/main.py
Normal file
208
synth/main.py
Normal 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_())
|
Loading…
Reference in New Issue
Block a user