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
- [x] Create a UI scope to visualize the synthesized composite waveform
- [ ] 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] Add polyphony somewhere. Probably involves a voice class. If processing power
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
- [ ] Filter upgrades including some more complex solving techniques (State Variable Filter),
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
engine config, etc. from a yaml instead of being hardcoded
- [ ] Noise
- [ ] LFO modulation
## 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) {
if (e->isAutoRepeat()) return;

View File

@@ -18,6 +18,7 @@ MidiController::~MidiController() {
close();
}
// this dont work too well but whatever
bool MidiController::openDefaultPort() {
if (!midiIn_) return false;
if (midiIn_->getPortCount() == 0) {
@@ -33,8 +34,7 @@ bool MidiController::openPort(unsigned int index) {
try {
midiIn_->openPort(index);
midiIn_->setCallback(&MidiController::midiCallback, this);
std::cout << "Opened MIDI port: "
<< midiIn_->getPortName(index) << "\n";
std::cout << "Opened MIDI port: " << midiIn_->getPortName(index) << "\n";
return true;
} catch (RtMidiError& e) {
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) {
auto* self = static_cast<MidiController*>(userData);
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) {
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 data1 = msg[1];
uint8_t data2 = msg[2];
if(status == 0xFE) return;
if(status == 0xF8) 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; // "Timing Clock" -> 24 pulses per quarter note, for steady rhythm. not useful for this instrument
// sustain pedal message event
if(status == 0xB0 && data1 == 64) {
handleSustain(data2 >= 64);
return;
}
unsigned char note = msg.size() > 1 ? msg[1] : 0;
unsigned char vel = msg.size() > 2 ? msg[2] : 0;
unsigned char note = msg.size() > 1 ? msg[1] : 0; // note number
unsigned char vel = msg.size() > 2 ? msg[2] : 0; // velocity
// Note On (velocity > 0)
// note on (velocity > 0)
if (status == 0x90 && vel > 0) {
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)) {
noteOff(note);
}
}
// construct note on event and add to noteQueue
void MidiController::noteOn(uint8_t note, uint8_t vel) {
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) {
if(sustainDown_) {
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) {
if(down == sustainDown_) return;

View File

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

View File

@@ -5,10 +5,12 @@ ParameterStore::ParameterStore() {
resetToDefaults();
}
// set parameter value
void ParameterStore::set(ParamId id, float value) {
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) {
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);
}
// get a single parameter
float ParameterStore::get(ParamId id) const {
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);
}
}
// 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);
MainWindow window;
MainWindow window; // entry point goes to MainWindow::MainWindow()
window.show();
int status = app.exec();
int status = app.exec(); // assembles ui
return status;
}

View File

@@ -21,9 +21,10 @@ AudioEngine::~AudioEngine() {
bool AudioEngine::start() {
// initialize the audio engine
RtAudio::StreamParameters params;
params.deviceId = audio_.getDefaultOutputDevice();
params.nChannels = channels_;
params.nChannels = channels_; // we're doing two duplicate channels for pseudo-mono
params.firstChannel = 0;
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) {
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);
}

View File

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

View File

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

View File

@@ -2,3 +2,5 @@
#include "Oscillator.h"
// 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) {
size_t w = writeIndex_.fetch_add(1, std::memory_order_relaxed);
buffer_[w % buffer_.size()] = sample;
}
// outputs value from the scope buffer, called by the scope widget
void ScopeBuffer::read(std::vector<float>& out) const {
size_t w = writeIndex_.load(std::memory_order_relaxed);
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;
}
// 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) {
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
for(Voice& v : voices_) {
if(!v.isActive()) {
@@ -85,7 +86,7 @@ void Synth::process(float* out, uint32_t nFrames, uint32_t sampleRate) {
float params[PARAM_COUNT] = {0.0f};
for(int i = 0; i < PARAM_COUNT; i++) {
params[i] = params_[i].current;
}
} // maybe take this outside the loop if performance is an issue
// foreach voice, process...
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 = tanh(mix); // really prevents clipping
// TODO: these saturation function work kinda like magic, use them elsewhere
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) {
sampleRate_ = sampleRate;
@@ -23,6 +24,7 @@ void Voice::setSampleRate(float sampleRate) {
//osc1_.setSampleRate(sampleRate);
}
// calculates oscillator frequency based on midi 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));
}
@@ -55,6 +57,7 @@ bool Voice::isActive() {
return active_;
}
// generates a single sample, called from synth.process()
float Voice::process(float* params, bool& scopeTrigger) {
// process all envelopes
@@ -69,11 +72,10 @@ float Voice::process(float* params, bool& scopeTrigger) {
return 0.0f;
}
// process all envelopes
float gainEnv = gainEnvelope_.process();
float cutoffEnv = cutoffEnvelope_.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)
float pitchOffset = 1.0f;
@@ -120,6 +122,7 @@ float Voice::process(float* params, bool& scopeTrigger) {
sampleOut = filter1_.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;
if (phase_ > 2.0f * M_PI) {
phase_ -= 2.0f * M_PI;