This commit is contained in:
2026-01-14 23:06:56 -06:00
parent 0450482773
commit b160c8ae69
13 changed files with 50 additions and 22 deletions

View File

@@ -21,10 +21,11 @@ This synthesizer isn't very good, but it's neat :3
oscillators increase the sound complexity considerably oscillators increase the sound complexity considerably
- [x] Create a UI scope to visualize the synthesized composite waveform - [x] Create a UI scope to visualize the synthesized composite waveform
- [ ] Create wavetables for more complex tone generation. Needs to be selectable from ui - [ ] Create wavetables for more complex tone generation. Needs to be selectable from ui
- [ ] Wavetable file loading
- [x] Create digital filters, prob biquad. Controllable from ui obv (cutoff + resonance) - [x] Create digital filters, prob biquad. Controllable from ui obv (cutoff + resonance)
- [x] Add polyphony somewhere. Probably involves a voice class. If processing power - [x] Add polyphony somewhere. Probably involves a voice class. If processing power
allows it, tie a voice to each midi note allows it, tie a voice to each midi note
- [ ] polyphony is lacking sustain pedal rn - [x] polyphony is lacking sustain pedal rn
- [ ] planning gets sparse this far out because its how far I got with the ESP32 synth - [ ] planning gets sparse this far out because its how far I got with the ESP32 synth
- [ ] Filter upgrades including some more complex solving techniques (State Variable Filter), - [ ] Filter upgrades including some more complex solving techniques (State Variable Filter),
better key tracking, more natural envelope curves, filter drive, etc. better key tracking, more natural envelope curves, filter drive, etc.
@@ -34,6 +35,7 @@ This synthesizer isn't very good, but it's neat :3
- [ ] Profile saving and loading, also includes loading configurations like keymaps, audio - [ ] Profile saving and loading, also includes loading configurations like keymaps, audio
engine config, etc. from a yaml instead of being hardcoded engine config, etc. from a yaml instead of being hardcoded
- [ ] Noise - [ ] Noise
- [ ] LFO modulation
## setup ## setup
TODO: instructions on build setup TODO: instructions on build setup

View File

@@ -50,7 +50,6 @@ void KeyboardController::handleKeyPress(QKeyEvent* e) {
}); });
} }
// TODO: something like a sustain pedal will suspend note-off events. probably control that in the midi controller
void KeyboardController::handleKeyRelease(QKeyEvent* e) { void KeyboardController::handleKeyRelease(QKeyEvent* e) {
if (e->isAutoRepeat()) return; if (e->isAutoRepeat()) return;

View File

@@ -18,6 +18,7 @@ MidiController::~MidiController() {
close(); close();
} }
// this dont work too well but whatever
bool MidiController::openDefaultPort() { bool MidiController::openDefaultPort() {
if (!midiIn_) return false; if (!midiIn_) return false;
if (midiIn_->getPortCount() == 0) { if (midiIn_->getPortCount() == 0) {
@@ -33,8 +34,7 @@ bool MidiController::openPort(unsigned int index) {
try { try {
midiIn_->openPort(index); midiIn_->openPort(index);
midiIn_->setCallback(&MidiController::midiCallback, this); midiIn_->setCallback(&MidiController::midiCallback, this);
std::cout << "Opened MIDI port: " std::cout << "Opened MIDI port: " << midiIn_->getPortName(index) << "\n";
<< midiIn_->getPortName(index) << "\n";
return true; return true;
} catch (RtMidiError& e) { } catch (RtMidiError& e) {
std::cerr << e.getMessage() << std::endl; std::cerr << e.getMessage() << std::endl;
@@ -48,42 +48,45 @@ void MidiController::close() {
} }
} }
// called by RtMidi on receive of a midi message. deltaTime is time since last midi message, not useful atm
void MidiController::midiCallback(double /*deltaTime*/, std::vector<unsigned char>* message, void* userData) { void MidiController::midiCallback(double /*deltaTime*/, std::vector<unsigned char>* message, void* userData) {
auto* self = static_cast<MidiController*>(userData); auto* self = static_cast<MidiController*>(userData);
if (!message || message->empty()) return; if (!message || message->empty()) return;
self->handleMessage(*message); self->handleMessage(*message); // pass to parsing function if valid
} }
void MidiController::handleMessage(const std::vector<unsigned char>& msg) { void MidiController::handleMessage(const std::vector<unsigned char>& msg) {
if(msg.size() <= 1) return; if(msg.size() <= 1) return; // msg doesn't contain useful note info
uint8_t status = msg[0] & 0xF0; uint8_t status = msg[0] & 0xF0;
uint8_t data1 = msg[1]; uint8_t data1 = msg[1];
uint8_t data2 = msg[2]; uint8_t data2 = msg[2];
if(status == 0xFE) return; if(status == 0xFE) return; // "Active Sensing" -> 300ms heartbeat. could be useful to sense if this is missing for device failure detection
if(status == 0xF8) return; if(status == 0xF8) return; // "Timing Clock" -> 24 pulses per quarter note, for steady rhythm. not useful for this instrument
// sustain pedal message event
if(status == 0xB0 && data1 == 64) { if(status == 0xB0 && data1 == 64) {
handleSustain(data2 >= 64); handleSustain(data2 >= 64);
return; return;
} }
unsigned char note = msg.size() > 1 ? msg[1] : 0; unsigned char note = msg.size() > 1 ? msg[1] : 0; // note number
unsigned char vel = msg.size() > 2 ? msg[2] : 0; unsigned char vel = msg.size() > 2 ? msg[2] : 0; // velocity
// Note On (velocity > 0) // note on (velocity > 0)
if (status == 0x90 && vel > 0) { if (status == 0x90 && vel > 0) {
noteOn(note, vel); noteOn(note, vel);
} }
// Note Off (or Note On with velocity 0) // note off (or note on with 0 velocity)
else if (status == 0x80 || (status == 0x90 && vel == 0)) { else if (status == 0x80 || (status == 0x90 && vel == 0)) {
noteOff(note); noteOff(note);
} }
} }
// construct note on event and add to noteQueue
void MidiController::noteOn(uint8_t note, uint8_t vel) { void MidiController::noteOn(uint8_t note, uint8_t vel) {
sustainedNotes_.erase(note); sustainedNotes_.erase(note);
@@ -95,6 +98,7 @@ void MidiController::noteOn(uint8_t note, uint8_t vel) {
}); });
} }
// add note off event to noteQueue if no sustain active
void MidiController::noteOff(uint8_t note) { void MidiController::noteOff(uint8_t note) {
if(sustainDown_) { if(sustainDown_) {
sustainedNotes_.insert(note); sustainedNotes_.insert(note);
@@ -108,6 +112,7 @@ void MidiController::noteOff(uint8_t note) {
}); });
} }
// if sustain goes from on->off, then noteOff all the active ntoes
void MidiController::handleSustain(bool down) { void MidiController::handleSustain(bool down) {
if(down == sustainDown_) return; if(down == sustainDown_) return;

View File

@@ -2,6 +2,7 @@
#include "NoteQueue.h" #include "NoteQueue.h"
#include <iostream> #include <iostream>
// add event to noteQueue, called by MidiController or keyboardController
bool NoteQueue::push(const NoteEvent& event) { bool NoteQueue::push(const NoteEvent& event) {
size_t head = head_.load(std::memory_order_relaxed); size_t head = head_.load(std::memory_order_relaxed);
size_t next = (head + 1) % SIZE; size_t next = (head + 1) % SIZE;
@@ -14,6 +15,7 @@ bool NoteQueue::push(const NoteEvent& event) {
return true; return true;
} }
// take event from noteQueue, called by synth
bool NoteQueue::pop(NoteEvent& event) { bool NoteQueue::pop(NoteEvent& event) {
size_t tail = tail_.load(std::memory_order_relaxed); size_t tail = tail_.load(std::memory_order_relaxed);

View File

@@ -5,10 +5,12 @@ ParameterStore::ParameterStore() {
resetToDefaults(); resetToDefaults();
} }
// set parameter value
void ParameterStore::set(ParamId id, float value) { void ParameterStore::set(ParamId id, float value) {
values_[static_cast<size_t>(id)].store(value, std::memory_order_relaxed); values_[static_cast<size_t>(id)].store(value, std::memory_order_relaxed);
} }
// set a whole envelope of parameters
void ParameterStore::set(EnvelopeId id, float depth, float a, float d, float s, float r) { void ParameterStore::set(EnvelopeId id, float depth, float a, float d, float s, float r) {
EnvelopeParam params = ENV_PARAMS[static_cast<size_t>(id)]; EnvelopeParam params = ENV_PARAMS[static_cast<size_t>(id)];
@@ -19,6 +21,7 @@ void ParameterStore::set(EnvelopeId id, float depth, float a, float d, float s,
set(params.r, r); set(params.r, r);
} }
// get a single parameter
float ParameterStore::get(ParamId id) const { float ParameterStore::get(ParamId id) const {
return values_[static_cast<size_t>(id)].load(std::memory_order_relaxed); return values_[static_cast<size_t>(id)].load(std::memory_order_relaxed);
} }
@@ -28,3 +31,5 @@ void ParameterStore::resetToDefaults() {
values_[i].store(PARAM_DEFS[i].def, std::memory_order_relaxed); values_[i].store(PARAM_DEFS[i].def, std::memory_order_relaxed);
} }
} }
// TODO: applying parameter profiles will work similarly to above function

View File

@@ -11,10 +11,10 @@ int main(int argc, char *argv[]) {
QApplication app(argc, argv); QApplication app(argc, argv);
MainWindow window; MainWindow window; // entry point goes to MainWindow::MainWindow()
window.show(); window.show();
int status = app.exec(); int status = app.exec(); // assembles ui
return status; return status;
} }

View File

@@ -21,9 +21,10 @@ AudioEngine::~AudioEngine() {
bool AudioEngine::start() { bool AudioEngine::start() {
// initialize the audio engine
RtAudio::StreamParameters params; RtAudio::StreamParameters params;
params.deviceId = audio_.getDefaultOutputDevice(); params.deviceId = audio_.getDefaultOutputDevice();
params.nChannels = channels_; params.nChannels = channels_; // we're doing two duplicate channels for pseudo-mono
params.firstChannel = 0; params.firstChannel = 0;
RtAudio::StreamOptions options; RtAudio::StreamOptions options;
@@ -47,7 +48,8 @@ void AudioEngine::stop() {
int32_t AudioEngine::audioCallback( void* outputBuffer, void*, uint32_t nFrames, double, RtAudioStreamStatus status, void* userData) { int32_t AudioEngine::audioCallback( void* outputBuffer, void*, uint32_t nFrames, double, RtAudioStreamStatus status, void* userData) {
if (status) std::cerr << "Stream underflow!" << std::endl; // error if process is too slow for the callback. If this is consistent, then need to optimize synth.process() or whatever cascades from it
if (status) std::cerr << "Stream underflow" << std::endl;
return static_cast<AudioEngine*>(userData)->process(static_cast<float*>(outputBuffer), nFrames); return static_cast<AudioEngine*>(userData)->process(static_cast<float*>(outputBuffer), nFrames);
} }

View File

@@ -14,6 +14,7 @@ void Envelope::noteOff() {
if(state_ != State::Idle) state_ = State::Release; if(state_ != State::Idle) state_ = State::Release;
} }
// returns current value based on state and steps forward one sample
float Envelope::process() { float Envelope::process() {
switch (state_) { switch (state_) {

View File

@@ -13,6 +13,7 @@ void Filter::setSampleRate(float sampleRate) {
calculateCoefficients(); calculateCoefficients();
} }
// recalculate filter based on params
void Filter::setParams(Type type, float frequency, float q) { void Filter::setParams(Type type, float frequency, float q) {
type_ = type; type_ = type;
frequency_ = std::min(frequency, sampleRate_ / 2.0f * 0.999f); frequency_ = std::min(frequency, sampleRate_ / 2.0f * 0.999f);
@@ -20,6 +21,7 @@ void Filter::setParams(Type type, float frequency, float q) {
calculateCoefficients(); calculateCoefficients();
} }
// update current state and output filtered value
float Filter::biquadProcess(float in) { float Filter::biquadProcess(float in) {
// calculate filtered sample // calculate filtered sample
@@ -32,6 +34,7 @@ float Filter::biquadProcess(float in) {
return out; return out;
} }
// internal control system emulation
void Filter::calculateCoefficients() { void Filter::calculateCoefficients() {
if(q_ < 0.001f) q_ = 0.001f; if(q_ < 0.001f) q_ = 0.001f;
@@ -72,7 +75,7 @@ void Filter::calculateCoefficients() {
break; break;
} }
// Normalize // values need to be normalized
b0_ = b0 / a0; b0_ = b0 / a0;
b1_ = b1 / a0; b1_ = b1 / a0;
b2_ = b2 / a0; b2_ = b2 / a0;

View File

@@ -2,3 +2,5 @@
#include "Oscillator.h" #include "Oscillator.h"
// placeholder // placeholder
// will eventually hold wavetable sampling, store phase state, and all that silliness

View File

@@ -5,11 +5,13 @@ ScopeBuffer::ScopeBuffer(size_t size) : buffer_(size) {
} }
// add a single sample to the scope buffer, called by synth
void ScopeBuffer::push(float sample) { void ScopeBuffer::push(float sample) {
size_t w = writeIndex_.fetch_add(1, std::memory_order_relaxed); size_t w = writeIndex_.fetch_add(1, std::memory_order_relaxed);
buffer_[w % buffer_.size()] = sample; buffer_[w % buffer_.size()] = sample;
} }
// outputs value from the scope buffer, called by the scope widget
void ScopeBuffer::read(std::vector<float>& out) const { void ScopeBuffer::read(std::vector<float>& out) const {
size_t w = writeIndex_.load(std::memory_order_relaxed); size_t w = writeIndex_.load(std::memory_order_relaxed);
for (size_t i = 0; i < out.size(); i++) { for (size_t i = 0; i < out.size(); i++) {

View File

@@ -30,6 +30,8 @@ inline float Synth::getParam(ParamId id) {
return params_[static_cast<size_t>(id)].current; return params_[static_cast<size_t>(id)].current;
} }
// called by audio engine before process (because it has the reference to the noteEvent store)
// this function consumes the noteEvents in the noteEvent store (whether produced by MIDI or keyboard)
void Synth::handleNoteEvent(const NoteEvent& event) { void Synth::handleNoteEvent(const NoteEvent& event) {
lastTime = event.timestamp; lastTime = event.timestamp;
@@ -45,7 +47,6 @@ void Synth::handleNoteEvent(const NoteEvent& event) {
} }
} }
// TODO: find quietest voice and assign a note to it instead of just the first inactive one
// find inactive voice and start it with the given note // find inactive voice and start it with the given note
for(Voice& v : voices_) { for(Voice& v : voices_) {
if(!v.isActive()) { if(!v.isActive()) {
@@ -85,7 +86,7 @@ void Synth::process(float* out, uint32_t nFrames, uint32_t sampleRate) {
float params[PARAM_COUNT] = {0.0f}; float params[PARAM_COUNT] = {0.0f};
for(int i = 0; i < PARAM_COUNT; i++) { for(int i = 0; i < PARAM_COUNT; i++) {
params[i] = params_[i].current; params[i] = params_[i].current;
} } // maybe take this outside the loop if performance is an issue
// foreach voice, process... // foreach voice, process...
float mix = 0.0f; float mix = 0.0f;
@@ -94,6 +95,7 @@ void Synth::process(float* out, uint32_t nFrames, uint32_t sampleRate) {
} }
mix /= 4.0f; // for number of voices to prevent clipping mix /= 4.0f; // for number of voices to prevent clipping
mix = tanh(mix); // really prevents clipping mix = tanh(mix); // really prevents clipping
// TODO: these saturation function work kinda like magic, use them elsewhere
sampleOut = mix; sampleOut = mix;

View File

@@ -7,6 +7,7 @@ Voice::Voice(SmoothedParam* params) : params_(params) {
} }
// cascade sample rate to all descendant objects
void Voice::setSampleRate(float sampleRate) { void Voice::setSampleRate(float sampleRate) {
sampleRate_ = sampleRate; sampleRate_ = sampleRate;
@@ -23,6 +24,7 @@ void Voice::setSampleRate(float sampleRate) {
//osc1_.setSampleRate(sampleRate); //osc1_.setSampleRate(sampleRate);
} }
// calculates oscillator frequency based on midi note
inline float Voice::noteToFrequency(uint8_t note) { inline float Voice::noteToFrequency(uint8_t note) {
return SYNTH_PITCH_STANDARD * pow(2.0f, static_cast<float>(note - SYNTH_MIDI_HOME) / static_cast<float>(SYNTH_NOTES_PER_OCTAVE)); return SYNTH_PITCH_STANDARD * pow(2.0f, static_cast<float>(note - SYNTH_MIDI_HOME) / static_cast<float>(SYNTH_NOTES_PER_OCTAVE));
} }
@@ -55,6 +57,7 @@ bool Voice::isActive() {
return active_; return active_;
} }
// generates a single sample, called from synth.process()
float Voice::process(float* params, bool& scopeTrigger) { float Voice::process(float* params, bool& scopeTrigger) {
// process all envelopes // process all envelopes
@@ -69,11 +72,10 @@ float Voice::process(float* params, bool& scopeTrigger) {
return 0.0f; return 0.0f;
} }
// process all envelopes
float gainEnv = gainEnvelope_.process(); float gainEnv = gainEnvelope_.process();
float cutoffEnv = cutoffEnvelope_.process(); float cutoffEnv = cutoffEnvelope_.process();
float resonanceEnv = resonanceEnvelope_.process(); float resonanceEnv = resonanceEnvelope_.process();
// TODO: envelope is shared between all notes so this sequence involves a note change but only one envelope attack:
// NOTE_A_ON > NOTE_B_ON > NOTE_A_OFF and note B starts playing part-way through note A's envelope
// TODO: make pitchOffset variable for each oscillator (maybe three values like octave, semitone offset, and pitch offset in cents) // TODO: make pitchOffset variable for each oscillator (maybe three values like octave, semitone offset, and pitch offset in cents)
float pitchOffset = 1.0f; float pitchOffset = 1.0f;
@@ -120,6 +122,7 @@ float Voice::process(float* params, bool& scopeTrigger) {
sampleOut = filter1_.biquadProcess(sampleOut); sampleOut = filter1_.biquadProcess(sampleOut);
sampleOut = filter2_.biquadProcess(sampleOut); sampleOut = filter2_.biquadProcess(sampleOut);
// state tracking, may keep this here even if oscillators store their own phase because it might help with scope triggering
phase_ += phaseInc; phase_ += phaseInc;
if (phase_ > 2.0f * M_PI) { if (phase_ > 2.0f * M_PI) {
phase_ -= 2.0f * M_PI; phase_ -= 2.0f * M_PI;