Compare commits

...

5 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
13 changed files with 321 additions and 26 deletions

View File

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

View File

@@ -7,6 +7,7 @@ import numpy as np
# www.halvorsen.blog/documents/programming/python/resources/powerpoints/State Space Models with Python.pdf
# simulation Parameters
x0 = [0, 0]
start = 0
stop = 30

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

@@ -22,8 +22,8 @@ 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);
MidiController midi(&config, &logger, &queue);
Synth synth(&config, &logger, &scopeBuffer, &queue);
@@ -43,7 +43,7 @@ int main(int argc, char* argv[]) {
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

@@ -30,17 +30,19 @@ float Instrument::process(bool& scopeTrigger) {
if(active_ && envelope_ < 1.0f) envelope_ += 0.01f;
if(!active_ && envelope_ > 0.0f) envelope_ -= 0.0004f;
if(!isActive()) return 0.0f;
phase_ += phaseIncrement_;
if(phase_ > 2.0f * pi) {
phase_ -= 2.0f * pi;
scopeTrigger = true;
}
if(!isActive()) return 0.0f;
// float sample = sin(phase_);
float sample = phase_ / pi - 1.0f; // saw
targetSample = phase_ / pi - 1.0f; // saw
return sample * envelope_;
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

@@ -14,7 +14,8 @@ MidiController::MidiController(ConfigService* config, LoggerService* logger, Not
#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);
}
openDefaultPort();
@@ -25,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;
}
@@ -49,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;
}
}
@@ -82,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);

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

@@ -7,7 +7,8 @@ ScopeBuffer::ScopeBuffer(QObject *parent) : QObject(parent) {
}
ScopeBuffer::ScopeBuffer(size_t size) : buffer_(size) {
ScopeBuffer::ScopeBuffer(ConfigService* config, LoggerService* logger, size_t size) :
config_(config), logger_(logger), buffer_(size) {
}

View File

@@ -8,6 +8,9 @@
#include <vector>
#include <atomic>
#include "ConfigService.hpp"
#include "LoggerService.hpp"
class ScopeBuffer : public QObject {
Q_OBJECT // needed to attach to a qml component
@@ -15,7 +18,7 @@ class ScopeBuffer : public QObject {
public:
explicit ScopeBuffer(QObject* parent = nullptr);
ScopeBuffer(size_t size);
ScopeBuffer(ConfigService* config, LoggerService* logger, size_t size);
~ScopeBuffer() = default;
void push(float sample);
@@ -31,6 +34,9 @@ public:
private:
ConfigService* config_;
LoggerService* logger_;
std::vector<float> buffer_;
std::atomic<size_t> writeIndex_{0};