Compare commits

..

8 Commits

Author SHA1 Message Date
dd9bd5511e model checkpoint 2026-06-16 22:42:49 -05:00
c7432320fe math 2026-06-16 20:13:08 -05:00
5bb1bbe56c basic python piano string state space system 2026-06-14 23:38:30 -05:00
4643c681f3 fix midi bug that caused the synth to continue to play notes after an interface stops output 2026-06-14 19:09:59 -05:00
f4d855b36b sync 2026-06-14 15:34:51 -05:00
cfc8cb2b51 tweaks 2026-06-14 12:48:42 -05:00
397e1fe7dc midi checkpoint 2026-06-13 22:04:27 -05:00
e9fff9691b add visual scope 2026-06-13 19:31:46 -05:00
20 changed files with 479 additions and 76 deletions

View File

@@ -111,6 +111,7 @@ if (WIN32)
TARGET sonobulus POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
$<TARGET_FILE:rtaudio>
$<TARGET_FILE:rtmidi>
$<TARGET_FILE:libconfig++>
$<TARGET_FILE_DIR:sonobulus>
)

View File

@@ -67,9 +67,12 @@ $ ./build/sonobulus
```
## Configurations
There is a plan eventually to use config files so app behavior can be tweaked without needing to recompile. Will flesh out more once more of the app's structure takes shape.
Configuration files are located in the config directory. They offer options to change the program's settings without recompiling. Eventually I might make a user's guide for the configurations, but there's not too much there right now.
## Instrument Profiles
Later into the app's development I have this vision: the state-space model of an instrument can be saved to a file. The app can allow parameter tweaking and re-saving of an instruments matrices.
Far far into the future there may be a dynamic process for constructing your own instrument models and a way for the app to load and play those instruments. At the end of the day, a state-space
model is just a set of 2 n-dimensional matrix equations and a profile would only need to be able to represent each element in the matrices.
## Note for Windows MIDI
If you intend to hook this instrument to a DAW or some kind of application that outputs midi (I use musescore), Windows takes an extra step (Linux and Mac works fine AFAIK). You need a program that creates a virtual MIDI port, like loopMIDI in order to hook the interfaces between apps. Installation is easy and just requires having the program open with one virtual midi port available. Then, attach the midi output in your DAW to the loopMIDI port and the synthesizer should pick it up on startup.

View File

@@ -10,7 +10,7 @@ Logger = (
"Error"
);
ShowTime = true;
ShowTime = false;
ShowSourceTrace = false;
CoutEnabled = true;

36
scripts/first_order.py Normal file
View File

@@ -0,0 +1,36 @@
import scipy.signal as sig
import matplotlib.pyplot as plt
import numpy as np
# simple first order step response simulation
# www.halvorsen.blog/documents/programming/python/resources/powerpoints/State Space Models with Python.pdf
# simulation Parameters
x0 = [0, 0]
start = 0
stop = 30
step = 1
t = np.arange(start,stop,step)
K = 3
T = 4
# state-space Model
A = [[-1/T, 0], [0, 0]]
B = [[K/T], [0]]
C = [[1, 0]]
D = 0
sys = sig.StateSpace(A, B, C, D)
# step Response
t, y = sig.step(sys, x0, t)
# plotting
plt.plot(t, y)
plt.title("Step Response")
plt.xlabel("t")
plt.ylabel("y")
plt.grid()
plt.show()

168
scripts/string_model_de.py Normal file
View File

@@ -0,0 +1,168 @@
import matplotlib.pyplot as plt
import numpy as np
import math
# https://people.cs.uchicago.edu/~ridg/stabil/pianostring.pdf
# a differential equations model for a piano string
# eq 1: governing wave equation
# d2y/dt2 = c^2*d2y/dx2 - stiffness*c^2*L^2*d4y/dx4 - 2*b_1*dy/dt + 2*b_3*d3y/dt3 + f(x, x_0, t)
# where y = string's transverse displacement, b_1, b_3 = damping coefficients, f = force density
# c = sqrt(T/mu), transverse wave velocity , T = string tension, mu = string linear mass density)
# eq 2: string stiffness
# stiffness = K^2*(E*S/(T*L^2))
# where K = string's radius of gyration (r/2 for a circular string), E = string's Young's modulus,
# S = cross sectional string area, T = string tension, L = string length
# eq 3: decay
# sigma = 1/tau = b_1 + b_3*omega^2
# where sigma = decay rate, tau = decay time, omega = angular frequency
# eq 4: excitation
# f(x, x_0, t) = f_H(t) * g(x, x_0)
# where f_H(t) = hammer force, g(x, x_0) = hammer dimensional effect
# eq 5: hammer time history
# read the paper if you care, useful only for derivation
# eq 6: power law
# F_H(t) = K*|eta(t) - y(x_0, t)|^p
# where eta(t) is the transverse displacement of the hammer head
# and p = stiffness nonlinear exponent
# eq 7: hammer displacement
# M_H*d2eta/dt2 = -F_H(t)
# where M_H is the mass of the hamemr head
# eq 8: boundary conditions
# y(0, t) = y(L, t) = 0, fixed ends dont move
# d2y/dx2(0, t) = d2y/dx2(L, t) = 0, displacement along the string approaching the ends is continuous
# discrete time implementation
# eq 9: continuous to discrete
# y(x, t) -> y(x_i, t_n) -> y(i, n)
# divide the string into i segments and iterate over n timesteps
# where x_i = delta_x * i and t_n = delta_t * n
# eq 10: recurrence derivation
# y(i, n+1) = a_1*y(i, n) + a_2*y(i, n-1) + a_3*[y(i+1, n) + y(i-1, n)] + a_4*[y(i+2,n) + y(i-2,n)]
# + a_5*[y(i+1, n-1) + y(i-1, n-1) + y(i, n-2)]
# + [delta_t^2 * N*F_H(n) * g(i, i_0)]/M_S
# where a_1 through a_5 are defined as follows:
# a_1 = [2 - 2*r^2 + b_1/delta_t - 6*stiffness*N^2*r^2]/D
# a_2 = [-1 + b_1*delta_t + 2*b_3/delta_t]/D
# a_3 = [r^2*(1 + 4*stiffness*N^2)]/D
# a_4 = [b_3/delta_t - stiffness*N^2*r^2]/D
# a_5 = [-b_3/delta_t]/D
# where D = 1 + b_1*delta_t + 2*b_3/delta_t
# and r = c*delta_t/delta_x
# eq 11: stability condition
# N_max = sqrt{[-1 + sqrt(1+16*stiffness*gamma^2)]/(8*stiffness)}
# where
# eq 12: idk what gamma represents
# gamma = f_e/(2*f_1), f_e = sampling frequency and f_1 = fundamental frequency
# or
# eq 13: if neglecting stiffness
# N_max = gamma
# eq 14: rest condition
# y(i, 0) = 0
# eq 15: discrete hammer displacement
# at t = delta_t (n = 1)
# eta(1) = V_H_0 * delta_t
# eq 16: discrete truncated taylor series
# y(i, 1) = [y(i + 1, 0) + y(i - 1, 0)]/2
# eq 17: hammer force exertion
# F_H(1) = K*|eta(1) - y(i_0, 1)|^p
# eq 18: string displacement iteration
# y(i, 2) = y(i-1, 1)] + y(i + 1, 1) - y(i, 0) + [delta_t^2 * N*F_H(1) * g(i, i_0)]/M_S
# eq 19: hammer displacement iteration
# eta(2) = 2*eta(1) - eta(0) - [delta_t^2 * F_H(1)]/M_H
# eq 20: hammer force iteration
# F_H(2) = K*|eta(2) - y(i_0, 2)|^p
# eq 21: hammer rest condition
# eta(n + 1) < y(i_0, n + 1)
# eq 22: spacial boundary conditions
# y(0, n) = y(N, n) = 0
# eq 23: temporal boundary conditions
# y(-1, n) = -y(1, n)
# y(N + 1, n) = -y(N - 1, n)
# string parameters
E = 1 # youngs modulus
mu = 1 # linear mass density
kappa = 1 # radius of gyration
L = 1 # string length
M_S = mu*L # string mass
S = 1 # string cross sectional area
T = 1 # string tension
c = math.sqrt(T/mu) # transverse wave velocity
stiffness = 1 # string stiffness parameter
sigma = 1 # decay rate
tau = 1/sigma # decay time
omega = 1 # angular frequency
# hammer parameters
M_H = 1 # hammer mass
HSMR = M_H/M_S # hammer-mass string ratio
V_H_0 = 1 # initial hammer velocity at t=0
x_0 = 1 # distance of hammer from agraffe
alpha = x_0 / L # relative hammer striking position
# simulation parameters
f1 = 440 # fundamental frequency
f_e = 44100 # sampling frequency
N = 100 # number of string segments
delta_t = 1/f_e # time step
delta_x = L/N # spatial step
H = f_e * 10 # length of simulation in time
# empirical constants
b_1 = 1 # some constant
b_3 = 1 # some constant
K = 1 # hammer stiffness
p = 1 # stiffness nonlinear exponent
# derived components
D = 1 + b_1*delta_t + 2*b_3/delta_t
r = c*delta_t/delta_x
a_1 = (2 - 2*r**2 + b_1/delta_t - 6*stiffness*N**2*r**2)/D
a_2 = (-1 + b_1*delta_t + 2*b_3/delta_t)/D
a_3 = (r**2*(1 + 4*stiffness*N**2))/D
a_4 = (b_3/delta_t - stiffness*N**2*r**2)/D
a_5 = (-b_3/delta_t)/D
x = [0] * N # current string position
last_x1 = [0] * N # string position from last timestep
last_x2 = [0] * N # string position from two timesteps ago
x_next = [0] * N # buffer for next string position
x_out = np.zeros(H) # taking this as the sound output at some artibraty point along the string
t = np.arange(0, H/f_e, delta_t)
for n in range(H): # time
for i in range(N): # space
x_out[n] = math.sin(n/10000)
# plotting
plt.plot(t, x_out)
plt.title("Step Response")
plt.xlabel("t")
plt.ylabel("y")
plt.grid()
plt.show()

View File

@@ -0,0 +1,82 @@
import scipy.signal as sig
import matplotlib.pyplot as plt
import numpy as np
import math
# simple first order step response simulation
# www.halvorsen.blog/documents/programming/python/resources/powerpoints/State Space Models with Python.pdf
# simulation Parameters
# string pde (wave equation)
# rho*A*d2y(x,t)/dt2 + c*dy(x,t)/dt - T*d2y(x,t)/dx2 = f(x,t)
# where rho*A = 1 dimensional string density, c = string damping, T = string tension, y(x,t) = displacement, f(x,t) = exitation force
# assume fixed ends: y(0, t) = y(L, t) = 0
# displacement y(x,t) can be represented as the sum from n=1 to N of qn(t)*sin(n*pi*x/L) where q_n(t) is the modal coordinate of mode n (displacement is the sum of all resonating modes)
# therefore:
# q..n + 2*zeta_n*omega_n*q.n + omega_n2*q_n = b_n*u(t)
# where omega_n = n*pi/L * sqrt(T/(rho*A)) for an ideal string
# and b_n = sin(n*pi*x_h/L) but assuming the hammer strikes at midpoint, x_h = L/2 ( b_n = sin(n*pi/2) )
# state space representation:
# each mode:
#「 q_n_dot | =「 0 1 | 「 q_n | + 「 0 |* u
# L q_n_dot_dot 」= L -omega_n^2 -2*zeta_n*omega_n 」 L q_ndot 」 L b_n 」
# and x_dot = Ax + Bu
# where A = diagonal matrix of A_n and B = [ 0 b_1 0 b_2 ... b_n]^T
# lets start with 3 nodes where the string is tuned to 440hz (we'll get to arbitrary modes evantually)
f_1 = 440 # fundamental frequency
def f_n(n):
return f_1 * n
def omega_n(n):
return 2*math.pi*f_n(n) # a cooler option would be omega_n = c*n*omega_1*sqrt(1+B*n^2) to factor in string stiffness to its vibration mode
# x = [ q1, q1dot, q2, q2dot, q3, q3dot ]^T < --state vector
omega_1 = omega_n(1)
omega_2 = omega_n(2)
omega_3 = omega_n(3)
zeta_1 = 0.0001 # i guessed
zeta_2 = 2 * zeta_1
zeta_3 = 3 * zeta_1
A = [
[ 0, 1, 0, 0, 0, 0],
[-omega_1**2, -2*zeta_1*omega_1, 0, 0, 0, 0],
[ 0, 0, 0, 1, 0, 0],
[ 0, 0, -omega_2**2, -2*zeta_2*omega_2, 0, 0],
[ 0, 0, 0, 0, 0, 1],
[ 0, 0, 0, 0, -omega_3**2, -2*zeta_3*omega_3]
] # isnt this formatting gorgeous
B = [ [0], [0.707], [0], [0], [0], [-0.707] ]
c_1 = 0.001
c_2 = c_1 / 2
c_3 = c_1 / 3
C = [[-c_1*omega_1**2, -2*c_1*zeta_1*omega_1, -c_2*omega_2**2, -2*c_2*zeta_2*omega_2, -c_3*omega_3**2, -2*c_3*zeta_3*omega_3]]
D = c_1 * 0.707 + c_2 * 0 + c_3 * (-0.707)
# input
#v_h = 100 # hammer velocity
#u = v_h * impulse(t)
x0 = [[0], [0], [0], [0], [0], [0]]
start = 0
stop = 10
step = 1/44100
t = np.arange(start,stop,step)
sys = sig.StateSpace(A, B, C, D)
# step Response
t, y = sig.impulse(sys, T=t)
# plotting
plt.plot(t, y)
plt.title("Step Response")
plt.xlabel("t")
plt.ylabel("y")
plt.grid()
plt.show()

View File

@@ -11,6 +11,7 @@
#include "synth/AudioEngine.hpp"
#include "synth/KeyboardController.hpp"
#include "synth/Scope.hpp"
#include "synth/MidiController.hpp"
int main(int argc, char* argv[]) {
@@ -21,29 +22,28 @@ int main(int argc, char* argv[]) {
// create app objects
ConfigService config = ConfigService("config/sonobulus.cfg");
LoggerService logger = LoggerService(&config, "Engine");
NoteQueue queue = NoteQueue();
ScopeBuffer scopeBuffer = ScopeBuffer(512);
NoteQueue queue = NoteQueue(&config, &logger);
ScopeBuffer scopeBuffer = ScopeBuffer(&config, &logger, 2048);
KeyboardController keyboard(&config, &logger, &queue);
Synth synth(&config, &logger, nullptr, &queue);
MidiController midi(&config, &logger, &queue);
Synth synth(&config, &logger, &scopeBuffer, &queue);
// audio synthesizer doohickey
AudioEngine audioEngine = AudioEngine(&config, &logger, &synth);
audioEngine.start();
// attach backend gui components
//Scope scope; //scope.setBuffer(&scopeBuffer);
qmlRegisterType<TimerComponent>("AppDemo", 1, 0, "TimerComponent");
//qmlRegisterSingletonInstance("AppDemo", 1, 0, "Scope", &scope);
qmlRegisterType<Scope>("AppDemo", 1, 0, "Scope");
// attach backend services to qml
engine.rootContext()->setContextProperty("keyboardController", &keyboard);
engine.rootContext()->setContextProperty("scopeBuffer", &scopeBuffer);
// load qml
//engine.loadFromModule("sonobulus", "Main");
//engine.load(QUrl("qrc:/Main.qml"));
engine.load(QUrl::fromLocalFile("ui/Main.qml")); // ugh
if(engine.rootObjects().isEmpty()) {
std::cout << "engine is empty" << std::endl;
logger.log("Main", LogFlag::Error, "Engine is empty.");
return -1;
}

View File

@@ -9,11 +9,8 @@
AudioEngine::AudioEngine(ConfigService* config, LoggerService* logger, Synth* synth) : config_(config), logger_(logger), synth_(synth) {
if(audioDevice_.getDeviceCount() < 1) {
std::cout << "No audio devices found" << std::endl;
logger_->log("Audio", LogFlag::Error, "No audio devices found.");
}
if(logger_ == nullptr) std::cout << "err: logger nullptr" << std::endl;
}
AudioEngine::~AudioEngine() {
@@ -33,13 +30,13 @@ bool AudioEngine::start() {
RtAudioErrorType status = audioDevice_.openStream(&params, nullptr, RTAUDIO_FLOAT32, sampleRate_, &bufferFrames_, &AudioEngine::audioCallback, this, &options);
if(status != RTAUDIO_NO_ERROR) {
std::cout << "Error opening RtAudio stream" << std::endl;
logger_->log("Audio", LogFlag::Error, "Error opening RtAudio stream.");
return false;
}
status = audioDevice_.startStream();
if(status != RTAUDIO_NO_ERROR) {
std::cout << "Error starting RtAudio stream" << std::endl;
logger_->log("Audio", LogFlag::Error, "Error starting RtAudio stream.");
return false;
}

View File

@@ -28,12 +28,21 @@ bool Instrument::isActive() {
float Instrument::process(bool& scopeTrigger) {
if(active_ && envelope_ < 1.0f) envelope_ += 0.01f;
if(!active_ && envelope_ > 0.0f) envelope_ -= 0.01f;
phase_ += phaseIncrement_;
if(phase_ > 2.0f * pi) phase_ -= 2.0f * pi;
if(!active_ && envelope_ > 0.0f) envelope_ -= 0.0004f;
if(!isActive()) return 0.0f;
return sin(phase_) * envelope_;
phase_ += phaseIncrement_;
if(phase_ > 2.0f * pi) {
phase_ -= 2.0f * pi;
scopeTrigger = true;
}
// float sample = sin(phase_);
targetSample = phase_ / pi - 1.0f; // saw
currentSample = (1.0f - responsiveness_) * currentSample + responsiveness_ * targetSample;
return currentSample * envelope_;
}

View File

@@ -34,4 +34,8 @@ private:
float phaseIncrement_ = 0.0f;
float envelope_ = 0.0f;
float targetSample = 0.0f;
float currentSample = 0.0f;
static constexpr float responsiveness_ = 0.1f;
};

View File

@@ -4,14 +4,21 @@
#include <iostream>
#include <chrono>
MidiController::MidiController(NoteQueue& queue) : noteQueue_(queue) {
MidiController::MidiController(ConfigService* config, LoggerService* logger, NoteQueue* queue) :
config_(config), logger_(logger), noteQueue_(queue) {
try {
#ifdef WIN32
midiIn_ = std::make_unique<RtMidiIn>(RtMidi::WINDOWS_MM);
#else
midiIn_ = std::make_unique<RtMidiIn>(RtMidi::LINUX_ALSA);
#endif
midiIn_->ignoreTypes(false, false, false);
} catch (RtMidiError& e) {
std::cout << "RtMidi init failed: " << e.getMessage() << std::endl;
std::string msg = "RtMidi init failed: " + e.getMessage();
logger_->log("MIDI", LogFlag::Warning, msg);
}
// TODO: this still doesnt work on windows
openDefaultPort();
}
MidiController::~MidiController() {
@@ -19,18 +26,21 @@ MidiController::~MidiController() {
}
// open the first for thats successful
// we could also add an option in the config for a preferred port name
bool MidiController::openDefaultPort() {
if (!midiIn_) return false;
if (midiIn_->getPortCount() == 0) {
std::cout << "No MIDI input ports available" << std::endl;
logger_->log("MIDI", LogFlag::Warning, "No MIDI input ports available.");
return false;
}
// TODO: the ui will eventually need this class to expose the available midi ports so they can be chosen dynamically
uint32_t portCount = midiIn_->getPortCount();
std::cout << "Available MidiIn ports: " << portCount << std::endl;
std::string msg = "Available MidiIn ports: " + std::to_string(portCount);
logger_->log("MIDI", LogFlag::Info, msg);
for (int i = 0; i < portCount; i++) {
std::cout << "#" << i << " : " << midiIn_->getPortName(i) << std::endl;
msg = "\t#" + std::to_string(i) + " : " + midiIn_->getPortName(i);
logger_->log("MIDI", LogFlag::Info, msg);
if(openPort(i)) return true;
}
@@ -43,11 +53,13 @@ bool MidiController::openPort(unsigned int index) {
try {
midiIn_->openPort(index);
midiIn_->setCallback(&MidiController::midiCallback, this);
std::cout << "Opened MIDI port: " << midiIn_->getPortName(index) << std::endl;
std::string msg = "Opened MIDI port: " + midiIn_->getPortName(index);
logger_->log("MIDI", LogFlag::Info, msg);
return true;
} catch (RtMidiError& e) {
std::cout << "Midi Port error" << std::endl;
std::cerr << e.getMessage() << std::endl;
std::string msg = "Midi Port error: " + e.getMessage();
logger_->log("MIDI", LogFlag::Error, msg);
return false;
}
}
@@ -76,6 +88,18 @@ void MidiController::handleMessage(const std::vector<unsigned char>& msg) {
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
if(status == 0xB0) { // channel mode change
if((data1 & 0xF0) == 0x70) { // all notes off for this channel
for(uint8_t i = 0; i < UINT8_MAX; i++) {
noteOff(i);
}
return;
}
// TODO: msg[0] contains the channel to turn all notes off for
// since the current implementation is channel agnostic, we just turn all notes off
// eventually we might have noteOff(channelLookup(msg[0]), i);
}
// sustain pedal message event
if(status == 0xB0 && data1 == 64) {
handleSustain(data2 >= 64);
@@ -100,7 +124,7 @@ void MidiController::handleMessage(const std::vector<unsigned char>& msg) {
void MidiController::noteOn(uint8_t note, uint8_t vel) {
sustainedNotes_.erase(note);
noteQueue_.push({
noteQueue_->push({
NoteEventType::NoteOn,
static_cast<uint8_t>(note),
vel / 127.0f,
@@ -114,7 +138,7 @@ void MidiController::noteOff(uint8_t note) {
sustainedNotes_.insert(note);
return;
}
noteQueue_.push({
noteQueue_->push({
NoteEventType::NoteOff,
static_cast<uint8_t>(note),
0.0f,

View File

@@ -4,13 +4,16 @@
#include <RtMidi.h>
#include <memory>
#include "NoteQueue.hpp"
#include <unordered_set>
#include "NoteQueue.hpp"
#include "ConfigService.hpp"
#include "LoggerService.hpp"
class MidiController {
public:
MidiController(NoteQueue& queue);
MidiController(ConfigService* config, LoggerService* logger, NoteQueue* queue);
~MidiController();
bool openDefaultPort();
@@ -27,7 +30,9 @@ private:
void noteOff(uint8_t note);
std::unique_ptr<RtMidiIn> midiIn_;
NoteQueue& noteQueue_;
ConfigService* config_;
LoggerService* logger_;
NoteQueue* noteQueue_;
bool sustainDown_ = false;
std::unordered_set<uint8_t> sustainedNotes_;

View File

@@ -2,6 +2,15 @@
#include "NoteQueue.hpp"
#include <iostream>
NoteQueue::NoteQueue() {
}
NoteQueue::NoteQueue(ConfigService* config, LoggerService* logger) :
config_(config), logger_(logger) {
}
// add event to noteQueue, called by MidiController or keyboardController
bool NoteQueue::push(const NoteEvent& event) {
size_t head = head_.load(std::memory_order_relaxed);

View File

@@ -7,6 +7,9 @@
#include <cstdint>
#include <chrono>
#include "ConfigService.hpp"
#include "LoggerService.hpp"
enum NoteEventType {
NoteOn = 0,
NoteOff
@@ -22,7 +25,8 @@ struct NoteEvent {
class NoteQueue {
public:
NoteQueue() = default;
NoteQueue();
NoteQueue(ConfigService* config, LoggerService* logger);
~NoteQueue() = default;
bool push(const NoteEvent& event);
@@ -30,7 +34,10 @@ public:
private:
static constexpr size_t SYNTH_NOTE_QUEUE_SIZE = 128;
ConfigService* config_;
LoggerService* logger_;
static constexpr size_t SYNTH_NOTE_QUEUE_SIZE = 128; // TODO: config
std::array<NoteEvent, SYNTH_NOTE_QUEUE_SIZE> buffer_;
std::atomic<size_t> head_{ 0 };

View File

@@ -3,7 +3,12 @@
#include <iostream>
ScopeBuffer::ScopeBuffer(size_t size) : buffer_(size) {
ScopeBuffer::ScopeBuffer(QObject *parent) : QObject(parent) {
}
ScopeBuffer::ScopeBuffer(ConfigService* config, LoggerService* logger, size_t size) :
config_(config), logger_(logger), buffer_(size) {
}
@@ -27,21 +32,45 @@ Scope::Scope(QQuickItem *parent) : QQuickPaintedItem(parent) {
setAntialiasing(true);
}
void Scope::setBuffer(ScopeBuffer* scopeBuffer) {
scopeBuffer_ = scopeBuffer;
buffer_ = std::vector<float>(scopeBuffer_->size());
}
void Scope::paint(QPainter *painter) {
//std::cout << "onPaint" << std::endl;
while(scopeBuffer_->spinlock()) {
// wait
}
static float phase = 0.0f;
phase += 0.1f;
float sample = sin(phase);
if(scopeBuffer_ != nullptr) scopeBuffer_->read(buffer_);
QPen pen(Qt::blue, 4);
// TODO: scale to max amplitude ?
float maxAmp = 1.0f;
/*
for(float s : buffer_) {
maxAmp = std::max(maxAmp, std::abs(s));
}
*/
float scaleY = (height() * 0.45f) / maxAmp;
float midY = height() / 2.0f;
painter->fillRect(0, 0, width(), height(), QColor(20, 20, 20));
QPen pen;
pen.setWidthF(3.0f);
QColor green(50, 255, 70);
pen.setColor(green);
painter->setPen(pen);
painter->setRenderHint(QPainter::Antialiasing);
painter->drawRect(10, 10, width() - 20*sample, height() - 20*sample);
painter->setPen(QPen(Qt::red, 2));
painter->drawLine(0, 0, width(), height());
for(size_t i = 1; i < buffer_.size(); i++) {
painter->drawLine(
i * width() / buffer_.size(),
midY + buffer_[i-1] * scaleY,
i * width() / buffer_.size(),
midY + buffer_[i] * scaleY
);
}
}

View File

@@ -8,11 +8,17 @@
#include <vector>
#include <atomic>
class ScopeBuffer {
#include "ConfigService.hpp"
#include "LoggerService.hpp"
class ScopeBuffer : public QObject {
Q_OBJECT // needed to attach to a qml component
public:
ScopeBuffer(size_t size);
explicit ScopeBuffer(QObject* parent = nullptr);
ScopeBuffer(ConfigService* config, LoggerService* logger, size_t size);
~ScopeBuffer() = default;
void push(float sample);
@@ -22,31 +28,36 @@ public:
void setWavelength(size_t wavelength) { wavelength_ = wavelength; }
size_t trigger() { return trigger_; }
size_t wavelength() { return wavelength_; }
void spinlock(bool lock) { spinlock_ = lock; }
void spinlock(bool lock) { spinlock_.store(lock, std::memory_order_relaxed); }
bool spinlock() { return spinlock_.load(std::memory_order_relaxed); }
size_t size() { return buffer_.size(); }
private:
ConfigService* config_;
LoggerService* logger_;
std::vector<float> buffer_;
std::atomic<size_t> writeIndex_{0};
size_t trigger_ = 0;
size_t wavelength_ = 400;
bool spinlock_ = false;
std::atomic<bool> spinlock_ = false;
};
class Scope : public QQuickPaintedItem {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(ScopeBuffer* buffer WRITE setBuffer)
public:
explicit Scope(QQuickItem* parent = nullptr);
void paint(QPainter* painter) override;
void setBuffer(ScopeBuffer* scopeBuffer) { scopeBuffer_ = scopeBuffer; }
void setBuffer(ScopeBuffer* scopeBuffer);
std::vector<float> buffer_;
ScopeBuffer* scopeBuffer_;

View File

@@ -10,12 +10,19 @@ Synth::Synth(ConfigService* config, LoggerService* logger, ScopeBuffer* scope, N
void Synth::handleNoteEvent(const NoteEvent& event) {
Voice* v = findVoiceByNote(event.note);
if(v != nullptr) v->noteOff();
// Voice* v = findVoiceByNote(event.note);
// if(v != nullptr) v->noteOff();
// stop all voices that are currently playing this note
for(Voice& v : voices_) {
if(v.isActive() && v.note() == event.note) {
v.noteOff();
}
}
if(event.type == NoteEventType::NoteOn) {
v = findFreeVoice();
Voice* v = findFreeVoice();
if(v != nullptr) v->noteOn(event.note, event.velocity);
}
@@ -28,18 +35,22 @@ void Synth::process(float* out, size_t nFrames) {
handleNoteEvent(noteEvent);
}
// size_t lowestVoice = 0;
// float lowestFreq = 100000.0f;
// for(size_t i = 0; i < voices_.size(); i++) {
// if(!voices_[i].isActive()) continue;
// float currentFreq = voices_[i].frequency();
// if(currentFreq < lowestFreq) {
// lowestVoice = i;
// lowestFreq = currentFreq;
// }
// }
size_t lowestVoice = 0;
float lowestFreq = 100000.0f;
for(size_t i = 0; i < voices_.size(); i++) {
if(!voices_[i].isActive()) continue;
float currentFreq = voices_[i].frequency();
if(currentFreq < lowestFreq) {
lowestVoice = i;
lowestFreq = currentFreq;
}
}
scope_->spinlock(true);
float sampleOut = 0.0f;
bool triggered = false;
bool once = false;
for(size_t i = 0; i < nFrames; i++) {
float mix = 0.0f;
@@ -47,7 +58,7 @@ void Synth::process(float* out, size_t nFrames) {
bool temp = false;
//if(!voices_[j].isActive()) continue;
mix += voices_[j].process(temp);
// if(j == lowestVoice) triggered = temp;
if(j == lowestVoice) triggered = temp;
}
mix = tanh(mix/4.0f); // prevent clipping
@@ -55,14 +66,15 @@ void Synth::process(float* out, size_t nFrames) {
out[2*i] = sampleOut;
out[2*i+1] = sampleOut;
// if(scope_) scope_->push(sampleOut);
// if(triggered && !once) {
// scope_->setTrigger(i);
// once = true;
// }
if(scope_) scope_->push(sampleOut);
if(triggered && !once) {
scope_->setTrigger(i);
once = true;
}
}
scope_->spinlock(false);
}

View File

@@ -32,7 +32,7 @@ private:
ConfigService* config_;
LoggerService* logger_;
ScopeBuffer* scope_ = nullptr;
ScopeBuffer* scope_;;
NoteQueue* noteQueue_;
};

View File

@@ -21,6 +21,7 @@ public:
float process(bool& scopeTrigger);
uint8_t note() { return note_; }
float frequency() { return noteToFrequency(note_); }
private:

View File

@@ -54,6 +54,9 @@ ApplicationWindow {
width: 600
height: 300
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: -200
// this timer triggers a constant update on the scope's canvas, calls its draw() each time
Timer {
@@ -63,6 +66,8 @@ ApplicationWindow {
repeat: true
onTriggered: scope.update()
}
buffer: scopeBuffer
}
}