Compare commits

...

26 Commits

Author SHA1 Message Date
54996469d1 Merge branch 'feature/wavetable' of https://git.vxbard.net/homeburger/sonobulus into feature/wavetable 2026-10-11 14:20:06 -05:00
2436946a3c scaffold some wavetable sampling logic 2026-10-11 14:19:46 -05:00
b00e7a053c Merge branch 'main' of ssh://git.vxbard.net:2222/homeburger/sonobulus into feature/wavetable 2026-09-27 19:24:00 -05:00
fdb55bb78d sync 2026-09-27 17:16:48 -05:00
8de4081a50 add oscillator 2026-09-27 14:07:06 -05:00
eb84c93b7b add wavetables 2026-09-26 19:04:32 -05:00
295a869839 integrate audio config into engine and synth 2026-07-05 00:02:53 -05:00
9365e12bb4 refactor config classes 2026-07-04 13:22:25 -05:00
269648e021 Merge branch 'main' of ssh://git.vxbard.net:2222/homeburger/sonobulus 2026-07-03 14:15:45 -05:00
41f7499c23 sync 2026-07-03 14:15:12 -05:00
b3d227dc71 cross platform fix 2026-06-24 21:00:56 -05:00
6913736476 linux + piano keyboard support 2026-06-24 20:52:16 -05:00
5bd347de83 small sound tweaks 2026-06-24 19:32:46 -05:00
e94d4c980e finally good audio 2026-06-23 20:38:08 -05:00
3e94d64f4a dynamic string tuning 2026-06-22 21:31:53 -05:00
b4df8657dd single pitch stiff string equation simulation 2026-06-20 16:08:39 -05:00
a5af4f6283 i think ive got it 2026-06-20 00:26:50 -05:00
a2df345216 sync 2026-06-19 13:50:49 -05:00
2bad7d66d8 checkpoint (still doesn't work) 2026-06-18 23:02:43 -05:00
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
56 changed files with 1728 additions and 242 deletions

4
.gitignore vendored
View File

@@ -1,2 +1,4 @@
build/*
build/*
*/__pycache__/*

View File

@@ -1,9 +1,10 @@
cmake_minimum_required(VERSION 4.0)
cmake_minimum_required(VERSION 3.21)
project(sonobulus LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++20")
# get dependencies
include(FetchContent)
@@ -45,7 +46,7 @@ find_package(Qt6 REQUIRED COMPONENTS
qt_standard_project_setup()
add_library(sonobulus_core STATIC
src/ConfigService.cpp
src/config/ConfigService.cpp
src/LoggerService.cpp
src/TimerComponent.cpp
src/synth/AudioEngine.cpp
@@ -55,14 +56,26 @@ add_library(sonobulus_core STATIC
src/synth/Scope.cpp
src/synth/Synth.cpp
src/synth/Voice.cpp
src/synth/Instrument.cpp
src/synth/Filter.cpp
src/synth/WavetableLoader.cpp
src/synth/Envelope.cpp
src/synth/instruments/Instrument.cpp
src/synth/instruments/PianoString.cpp
src/synth/instruments/WavetableOscillator.cpp
)
if (WIN32)
set(LIBCONFIG_LIB libconfig++)
else()
set(LIBCONFIG_LIB config++)
endif()
target_link_libraries(sonobulus_core PRIVATE
Qt6::Core
Qt6::Quick
rtaudio
rtmidi
libconfig++
${LIBCONFIG_LIB}
)
target_include_directories(sonobulus_core PRIVATE
@@ -80,6 +93,8 @@ qt_add_executable(sonobulus
src/main.cpp
)
set_target_properties(sonobulus PROPERTIES OUTPUT_NAME "sonobulus.out")
qt_add_qml_module(sonobulus
URI sonobulus
VERSION 1.0
@@ -87,6 +102,7 @@ qt_add_qml_module(sonobulus
)
target_include_directories(sonobulus PRIVATE
${CMAKE_SOURCE_DIR}/src/
${rtaudio_SOURCE_DIR}
${rtmidi_SOURCE_DIR}
${libconfig_SOURCE_DIR}/lib
@@ -111,6 +127,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;
@@ -19,6 +19,22 @@ Logger = (
}
);
AudioEngine = (
{
Id = "Main";
SampleRate = 44100;
Channels = 2;
StereoMode = 1;
BufferSize = 512;
PitchStandard = 440.0;
MidiHome = 69;
NotesPerOctave = 12.0;
OctaveConstant = 2.0;
MaxVoices = 32;
}
);
KeyboardController = (
{
Id = "Main";

BIN
config/wavetables/saw.wt Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
config/wavetables/sine.wt Normal file

Binary file not shown.

BIN
config/wavetables/square.wt Normal file

Binary file not shown.

Binary file not shown.

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()

256
scripts/string_model_de.py Normal file
View File

@@ -0,0 +1,256 @@
import matplotlib.pyplot as plt
import numpy as np
import math
import sounddevice as sd
# 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)
# simulation parameters
f1 = 50 # fundamental frequency
f_e = 44100 # sampling frequency
N = 200 # number of string segments
delta_t = 1/f_e # time step
L = 0.5 # string length, meters
delta_x = L/N # spatial step
H = int(f_e * 2) # length of simulation in time
# string parameters
E = 100 * 10**9 # youngs modulus, steel = 200GPa
rho = 8000 # density, steel, kg/m^3
radius = 0.005 # meters
kappa = radius/2 # radius of gyration, r/2 for a circular string
S = math.pi*radius**2 # string cross sectional area, assuming circular
mu = S*rho # linear mass density
M_S = mu*L # string mass
T = 500 # string tension, N
c = math.sqrt(T/mu) # transverse wave velocity
I = math.pi*radius**4 / 4
k = math.sqrt(E*I/(rho*S))
# stiffness = k**2 * delta_t**2 / delta_x**4 # string stiffness parameter
stiffness = 0
sigma = 1 # decay rate
tau = 1/sigma # decay time
omega = 1/f1 # angular frequency
# hammer parameters
M_H = 0.5 # hammer mass, kg
HSMR = M_H/M_S # hammer-mass string ratio
V_H_0 = 10000000000 # initial hammer velocity at t=0, m/s
x_0 = L/2 # distance of hammer from agraffe
alpha = x_0 / L # relative hammer striking position
# empirical constants
b_1 = 0 # some constant
b_3 = 0 # 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_3/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
# string
x = [0] * N # current string position
x_initial = [0] * N # assuming string at rest
last_x1 = [0] * N # string position from last timestep
last_x2 = [0] * N # string position from two timesteps ago
x_sample = int(0.8*N) # location where we sample the string position for signal
# hammer
F_H_current = 0 # current force exterted by hammer
eta_current = 0 # current hammer position
eta_last = 0 # last hammer position
i_H = 16 # hammer width
x_hammer = int(alpha * N) # location where hammer strikes
eta_0 = 0 # initial hammer displacement
x_out = np.zeros(H) # taking this as the sound output at some arbitrary point along the string
t = np.arange(0, H/f_e, delta_t)
def plot_current():
plt.plot(np.arange(0, N, 1), x)
plt.title("Response")
plt.xlabel("t")
plt.ylabel("x(t, x_sample)")
plt.grid()
plt.show()
def spatial_window(i):
if(i < x_hammer+i_H/2 and i > x_hammer-i_H/2):
return abs(i-x_hammer-i_H/2)
else:
return 0
# first iteration, n = 1
last_x1 = x_initial.copy()
eta_current = V_H_0 * delta_t + eta_0
for i in range(1, N-1):
x[i] = (last_x1[i+1] + last_x1[i-1])/2 # irrelavant if string is at rest at hammer strike, but relevant if a repeat hit
last_x1 = x.copy()
F_H_current = K * abs(eta_current - x[x_hammer])**p
# second iteration, n = 2
eta_next = 2*eta_current - eta_0 - (delta_t**2*N*F_H_current)/M_S
eta_last = eta_current
eta_current = eta_next
for i in range(1, N-1):
x[i] = last_x1[i+1] + last_x1[i-1] - x_initial[i] + ((delta_t**2)*N*F_H_current*spatial_window(i))/M_S
last_x2 = last_x1.copy()
last_x1 = x.copy()
F_H_current = K * abs(eta_current - x[x_hammer])**p
# rest of the iterations
for n in range(H): # time
if(n <= 2):
continue
eta_next = 2*eta_current - eta_last - (delta_t**2*N*F_H_current)/M_S
eta_last = eta_current
eta_current = eta_next
push = eta_current - x[x_hammer]
if(push > 0):
F_H_current = K * abs(push)**p
else:
F_H_current = 0
F_H_current = 0
next_x = [0] * N
for i in range(2, N-2): # space
# term_1 = a_1*x[i] + a_2*last_x1[i]
# term_2 = a_3*(x[i+1] + x[i-1]) + a_4*(x[i+2] + x[i-2])
# term_3 = a_5*(last_x1[i+1] + last_x1[i-1] + last_x2[i])
# term_4 = (delta_t**2 * N*F_H_current * spatial_window(i))/M_S
# next_x[i] = term_1 + term_2 + term_3 + term_4
next_x[i] = 2*(1-r**2)*x[i] + r**2 * (x[i+1] + x[i-1]) - last_x1[i]
x[0] = 0
x[1] = 0
x[N-2] = 0
x[N-1] = 0
last_x2 = last_x1.copy()
last_x1 = x.copy()
x = next_x.copy()
x_out[n] = x[x_sample]
# plotting
plt.plot(t, x_out)
plt.title("Response")
plt.xlabel("t")
plt.ylabel("x(t, x_sample)")
plt.grid()
plt.show()
sd.play(x_out, f_e)
sd.wait()

120
scripts/string_model_fdm.py Normal file
View File

@@ -0,0 +1,120 @@
import math
import numpy as np
import matplotlib.pyplot as plt
import sounddevice as sd
import time
sample_rate = 44100
seconds = 10
N = 100 # number of string segments
I = int(sample_rate * seconds) # number of samples to simulate
# string parameters
rho = 8000 # density, steel, kg/m^3
radius = 0.001 # meters
S = math.pi*radius**2 # string cross sectional area, assuming circular
mu = S*rho # linear mass density
T = 1200 # string tension, N
c = math.sqrt(T/mu) # transverse wave velocity
kappa = 0.001 # stiffness coefficient
sigma = 0.5 # damping coefficient
L = 1.0 # length of string
strike_position = 0.2 # x of impulse location
impulse_width = 0.02 # x of impulse width
impulse_velocity = 1000.0 # x/t of impulse magnitude
sample_position = 0.1 # percentage along L of sampling for audio
f_0 = c / (2*L) # fundamental frequency of a non-stiff string
f_1 = f_0 * math.sqrt(1 + kappa) # fundamental frequency of the stiff string
print("fundamental frequency =", f_1)
dx = L / N # delta x
dt = 1/sample_rate
#dt = 0.2 * dx / c
if(dx**2 < (c*dt)**2 + 4*(kappa*dt/(dx**2))**2):
print("warning: possibly unstable, increase sample rate or decrease string segments")
# derived constants
r1 = c * dt/dx
r2 = (c * dt/dx) ** 2
s1 = kappa * dt/dx**2
s2 = (kappa * dt/dx**2) ** 2
a1 = 2 - 2*sigma*dt
a2 = 2*sigma*dt - 1
# string grid
x = np.linspace(0, L, N + 1) # linspace my beloved
# state vectors
y_last = np.zeros(N + 1)
y_current = np.zeros(N + 1)
y_next = np.zeros(N + 1)
# output
y_sample = np.zeros(I)
# vector of initial velocity across the string as a result of the impulse
v0 = impulse_velocity * np.exp(-((x - strike_position) ** 2)/(2 * impulse_width ** 2))
# initial conditions:
# y(x, 0) = 0 and dy/dt(x, 0) = v0(x)
# boundary conditions: y(0, t) = y(L, t) = 0 as well as all dy/dts thereafter
def show_plot():
plt.plot(np.arange(0, N+1, 1), y_current)
plt.grid()
plt.show()
start_time = time.perf_counter()
def applyImpulse():
for n in range(2, N-2):
y_current[n] = y_current[n] + dt*v0[n]
# first iteration
for n in range(2, N-2):
y_current[n] = 0.5 * r1**2 * (y_last[n-1] - 2*y_last[n] + y_last[n+1])
applyImpulse()
n_sample = int(sample_position * L * N)
y_sample[0] = y_last[n_sample]
y_sample[1] = y_current[n_sample]
# rest of the iterations
for i in range(2, I):
for n in range(2, N-2):
# stiff wave equation
y_xx = y_current[n+1] - 2*y_current[n] + y_current[n-1]
y_xxxx = y_current[n-2] - 4*y_current[n-1] + 6*y_current[n] - 4*y_current[n+1] + y_current[n+2]
y_next[n] = a1 * y_current[n] + a2 * y_last[n] + r2 * y_xx - s2 * y_xxxx
y_next[0] = 0
y_next[1] = 0
y_next[N-1] = 0
y_next[N-2] = 0
# y_sample[i] = math.tanh(y_next[n_sample])
y_sample[i] = y_next[n_sample]
y_last = y_current.copy()
y_current = y_next.copy()
if(i == 120000):
applyImpulse()
if(i % 10000*seconds == 0):
print(f"{i/I * 100:4.4}% complete")
end_time = time.perf_counter()
elapsed = end_time - start_time
print(f"Executed in {elapsed:.3f} seconds. {elapsed/seconds*100:.2f}% overshoot")
plt.plot(np.arange(0, I, 1), y_sample)
plt.grid()
plt.show()
sd.play(y_sample, sample_rate)
sd.wait()

View File

@@ -0,0 +1,95 @@
import scipy.signal as sig
import matplotlib.pyplot as plt
import numpy as np
import sounddevice as sd
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 = 100 # fundamental frequency
def f_n(n):
return f_1 * n
omega_0 = 2*math.pi*f_1
b = 1.5
c = 2
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
return c*n*omega_0*math.sqrt(1+b*(n-1)**2)
# 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.001 # i guessed
zeta_2 = 1.5 * zeta_1
zeta_3 = 2 * 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.002
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)
for i in range(t.size):
y[i] = math.tanh(y[i])/2
if(y[i] > 1):
print("what", y[i])
# plotting
plt.plot(t, y)
plt.title("Step Response")
plt.xlabel("t")
plt.ylabel("y")
plt.grid()
plt.show()
sd.play(y, 44100)
sd.wait()

View File

@@ -0,0 +1,52 @@
import math
from generate_wavetable import generateWavetable
WAVETABLE_FILE_NAME = "config/wavetables/shark_fin.wt"
# process expects a waveform from x=[0, 2pi) centered around f(x)=0
# normalization is handled by the wavetable generator
def sine(phase):
return math.sin(phase)
def square(phase):
sample = 1.0
if(phase <= math.pi):
sample = -1
return sample
def saw(phase):
return (phase / math.pi) - 1.0
def triangle(phase):
sample = 0.0
if(phase <= math.pi/2.0):
sample = phase * 2.0/math.pi
elif(phase <= 3.0*math.pi/2.0):
sample = phase * (-2.0/math.pi) + 2.0
else:
sample = phase * 2.0/math.pi - 4.0
return sample
def sharkFin(phase):
k = 10.0
sample = 0.0
if(phase <= math.pi):
sample = 2.0 * (phase/math.pi) ** k - 1.0
else:
sample = -2.0 * (phase/math.pi - 1) ** k + 1.0
return sample
def sigmoid(phase):
k = 4.0
a = -k * (phase - math.pi)
sample = 2.0 / (1.0 + math.exp(a)) - 1.0
return sample
# pass in the name of your wavetable file and the process function
def main():
generateWavetable(WAVETABLE_FILE_NAME, sharkFin)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,67 @@
# this is a script for generating a wavetable file
# a wavetable file consists of a one-dimensional array of samples representing one period of a waveform
# metadata includes:
# - file version (for program compatibilty)
# - binary format (float, double, int32, etc.)
# - domain (eg phase is represented from [0, 2pi) )
# - range (determined by datatype, e.g. float=[-1,1], int32=[-2^15, 2^15-1])
# - waveform RMS (for loudness normalization)
# - sample count
# the synth program uses the filename, not any metadata
# this script uses the input function to calculate samples
# if you want a custom wavetable, copt/edit/modify the example function (i used desmos for brainstorming)
# import this script and call generateWavetable(processFunction) to generate a custom wavetable
from array import array
import math
wavetableLength = 2048
def createFile(name):
filename = name
print("Creating file " + filename)
file = open(filename, "wb")
return file
def writeMetadata(file):
print(">> im writing metadata")
def writeData(file, processFunction):
print(">> im generating the wavetable")
# init variables
data_list = [None] * wavetableLength
phaseInc = 2*math.pi / wavetableLength
x = 0
accumulator = 0
# generate each discrete sample
for i in range(wavetableLength):
sample = processFunction(x)
accumulator += sample * sample
x += phaseInc
data_list[i] = sample
# normalize by rms
rms = math.sqrt(accumulator/wavetableLength)
print(">> wavetable RMS: ", rms)
for i in range(wavetableLength):
data_list[i] /= rms
# write to file
binary_data = array("f", data_list)
file.write(binary_data)
def closeFile(file):
print(">> finishing up")
file.close()
def generateWavetable(name, processFunction):
print("Hello main")
file = createFile(name)
writeMetadata(file)
writeData(file, processFunction)
closeFile(file)
print("Done")

View File

@@ -1,128 +0,0 @@
#pragma once
#include <libconfig.h++>
#include <optional>
#include <string>
#include <vector>
#include <iostream>
#include <unordered_map>
struct LoggerConfig {
std::string id;
std::vector<std::string> flagsEnabled;
bool showTime = false;
bool showSourceTrace = false;
bool coutEnabled = false;
bool fileEnabled = false;
std::string filePath;
bool parseConfig(const libconfig::Setting& setting) {
if (!setting.lookupValue("Id", id)) {
return false;
}
try {
const libconfig::Setting& flags = setting.lookup("FlagsEnabled");
for (int index = 0; index < flags.getLength(); ++index) {
flagsEnabled.push_back(static_cast<const char*>(flags[index]));
}
} catch (const libconfig::SettingException&) {
flagsEnabled.clear();
}
setting.lookupValue("ShowTime", showTime);
setting.lookupValue("ShowSourceTrace", showSourceTrace);
setting.lookupValue("CoutEnabled", coutEnabled);
setting.lookupValue("FileEnabled", fileEnabled);
setting.lookupValue("FilePath", filePath);
return true;
}
};
struct AudioConfig {
uint32_t sampleRate;
uint32_t channels;
uint32_t stereoMode;
uint32_t bufferSize;
float pitchStandard;
int32_t midiHome;
int32_t notesPerOctave;
};
struct KeymapConfig {
std::string id;
std::unordered_map<int32_t, uint8_t> keymap;
bool parseConfig(const libconfig::Setting& setting) {
if (!setting.lookupValue("Id", id)) {
return false;
}
try {
const libconfig::Setting& keymapGroup = setting.lookup("Keymap");
const libconfig::Setting& notesGroup = setting.lookup("Notes");
const libconfig::Setting& keysGroup = setting.lookup("Keys");
for(size_t i = 0; i < keymapGroup.getLength(); i++) {
libconfig::Setting& item = keymapGroup[i];
std::string key = item.getName();
std::string note = "";
if(item.getType() == libconfig::Setting::TypeString) note = std::string(item.c_str());
int8_t noteId = static_cast<int>(notesGroup[note]) % INT8_MAX;
int32_t keyId = static_cast<int>(keysGroup[key]) % INT32_MAX;
keymap.emplace(keyId, noteId);
}
} catch (const libconfig::SettingException&) {
return false;
}
return true;
}
};
class ConfigService {
public:
ConfigService(const std::string& filePath);
~ConfigService() = default;
bool loadFromFile(const std::string& filePath);
template<typename T>
bool getConfig(const std::string& type, const std::string& id, T* config) const {
try {
const libconfig::Setting& configs = config_.lookup(type);
for (int index = 0; index < configs.getLength(); ++index) {
const libconfig::Setting& configSetting = configs[index];
std::string configId;
if (!configSetting.lookupValue("Id", configId) || configId != id) {
continue;
}
return config->parseConfig(configSetting);
}
} catch (const libconfig::SettingException& ex) {
std::cout << "libconfig setting exception: " << ex.what() << std::endl;
return false;
}
return false;
}
const std::string& lastError() const;
private:
libconfig::Config config_;
std::string lastError_;
};

View File

@@ -7,21 +7,16 @@
#include <iomanip>
#include <fstream>
#include <filesystem>
#include <algorithm>
namespace fs = std::filesystem;
LoggerService::LoggerService(ConfigService* config, const std::string& loggerId) {
if(!(config->getConfig<LoggerConfig>("Logger", loggerId, &configuration_))) {
std::cout << "Failed to get logger configuration fom config service" << std::endl;
if(!(config->getConfig<LoggerParams, LoggerConfig>("Logger", loggerId, &configuration_))) {
std::cout << "Failed to get logger configuration from config service" << std::endl;
return;
}
standardOutputEnabled_ = configuration_.coutEnabled;
fileOutputEnabled_ = configuration_.fileEnabled;
additionaldetailsEnabled_ = configuration_.showSourceTrace;
timeEnabled_ = configuration_.showTime;
id_ = configuration_.id;
for(std::string& flag : configuration_.flagsEnabled) {
bool found = false;
@@ -54,7 +49,7 @@ LoggerService::LoggerService(ConfigService* config, const std::string& loggerId)
fs::create_directories(std::string(BINARY_DIR) + "/" + finaltime);
std::string logPath = std::string(BINARY_DIR) + "/" + finaltime + "/" + configuration_.filePath;
if(fileOutputEnabled_) outfile_.open(logPath);
if(configuration_.fileEnabled) outfile_.open(logPath);
log("Logger", LogFlag::Info, "Logger initialized.");
}
@@ -77,11 +72,11 @@ void LoggerService::log(std::string component, LogFlag flag, std::string message
std::string finalmessage = "";
if(timeEnabled_) {
if(configuration_.showTime) {
finalmessage = finalmessage + "[" + "Not Implemented" + "] ";
}
std::string componentTrace = "[" + id_ + ": " + component + "] ";
std::string componentTrace = "[" + configuration_.id + ": " + component + "] ";
finalmessage += componentTrace; // component is the section of the program (For example Mesh or Engine) that is calling the logger
std::string level = "";
@@ -93,15 +88,15 @@ void LoggerService::log(std::string component, LogFlag flag, std::string message
finalmessage = finalmessage + "[" + level + "] ";
finalmessage = finalmessage + message + " ";
if (additionaldetailsEnabled_) {
if (configuration_.showSourceTrace) {
finalmessage = finalmessage + "[Function: " + Source.function_name() + "]" + " " + "[Line: " + std::to_string(Source.line()) + "]" + " " + "[File: " + Source.file_name() + "]";
}
if(standardOutputEnabled_) {
if(configuration_.coutEnabled) {
std::cout << finalmessage << std::endl;
}
if(fileOutputEnabled_) {
if(configuration_.fileEnabled) {
outfile_ << finalmessage << std::endl;
}
return;

View File

@@ -5,7 +5,8 @@
#include <fstream>
#include <source_location>
#include "ConfigService.hpp"
#include "config/ConfigService.hpp"
#include "config/LoggerConfig.hpp"
enum LogFlag {
Debug,
@@ -33,14 +34,8 @@ public:
private:
bool standardOutputEnabled_;
bool fileOutputEnabled_;
bool additionaldetailsEnabled_;
bool timeEnabled_;
std::ofstream outfile_;
std::string id_;
std::vector<LogFlag> activeFlags_;
LoggerConfig configuration_;
};
LoggerParams configuration_;
};

18
src/ParameterStore.cpp Normal file
View File

@@ -0,0 +1,18 @@
#include "ParameterStore.hpp"
ParameterStore::ParameterStore() {
}
// set parameter value
void ParameterStore::set(ParamId id, float value) {
Param oldParam = values_[static_cast<size_t>(id)].load(std::memory_order_relaxed);
Param updatedParam = { value, oldParam.min, oldParam.max };
values_[static_cast<size_t>(id)].store(updatedParam, std::memory_order_relaxed);
}
// get a single parameter
float ParameterStore::get(ParamId id) const {
return values_[static_cast<size_t>(id)].load(std::memory_order_relaxed).val;
}

37
src/ParameterStore.hpp Normal file
View File

@@ -0,0 +1,37 @@
#pragma once
#include <vector>
#include <cstdint>
enum class ParamId : uint8_t {
MasterGain,
Count
};
struct Param {
float val;
float min;
float max;
};
constexpr size_t PARAM_COUNT = static_cast<size_t>(ParamId::Count);
class ParameterStore {
public:
ParameterStore();
~ParameterStore() = default;
void set(ParamId id, float value);
void set(ParamId id, int32_t value) { set(id, static_cast<float>(value)); }
void set(EnvelopeId id, float depth, float a, float d, float s, float r);
float get(ParamId id) const;
int32_t getInt(ParamId id) const { return static_cast<int32_t>(get(id)); }
private:
std::array<std::atomic<Param>, PARAM_COUNT> values_;
};

View File

@@ -0,0 +1,45 @@
#pragma once
#include "IConfig.hpp"
struct AudioParams : IParams {
std::string id;
uint32_t sampleRate;
uint32_t channels;
uint32_t stereoMode;
uint32_t bufferSize;
float pitchStandard;
int32_t midiHome;
float notesPerOctave;
float octaveConstant;
size_t maxVoices;
};
class AudioConfig : public IConfig<AudioParams> {
public:
using IConfig<AudioParams>::IConfig;
bool parseConfig(const libconfig::Setting& setting) override {
if (!setting.lookupValue("Id", params_->id)) {
return false;
}
setting.lookupValue("SampleRate", params_->sampleRate);
setting.lookupValue("StereoMode", params_->stereoMode);
setting.lookupValue("BufferSize", params_->bufferSize);
setting.lookupValue("Channels", params_->channels);
setting.lookupValue("PitchStandard", params_->pitchStandard);
setting.lookupValue("MidiHome", params_->midiHome);
setting.lookupValue("NotesPerOctave", params_->notesPerOctave);
setting.lookupValue("OctaveConstant", params_->octaveConstant);
setting.lookupValue("MaxVoices", params_->maxVoices);
return true;
}
};

View File

@@ -0,0 +1,56 @@
#pragma once
#include <libconfig.h++>
#include <optional>
#include <string>
#include <vector>
#include <iostream>
#include <unordered_map>
#include "config/IConfig.hpp"
// TODO: would be cool for the config file to include other config files so theyre a bit more compartmentalized
// then the main file will be like a hub and would be more like each component having its own config file
class ConfigService {
public:
ConfigService(const std::string& filePath);
~ConfigService() = default;
bool loadFromFile(const std::string& filePath);
template<typename T1, typename T2>
bool getConfig(const std::string& type, const std::string& id, T1* params) const {
T2 config { params };
try {
const libconfig::Setting& configs = config_.lookup(type);
for (int index = 0; index < configs.getLength(); ++index) {
const libconfig::Setting& configSetting = configs[index];
std::string configId;
if (!configSetting.lookupValue("Id", configId) || configId != id) {
continue;
}
return config.parseConfig(configSetting);
}
} catch (const libconfig::SettingException& ex) {
std::cout << "libconfig setting exception: " << ex.what() << std::endl;
return false;
}
return false;
}
const std::string& lastError() const;
private:
libconfig::Config config_;
std::string lastError_;
};

24
src/config/IConfig.hpp Normal file
View File

@@ -0,0 +1,24 @@
#pragma once
#include <libconfig.h++>
struct IParams {
};
template <typename T>
class IConfig {
public:
IConfig(T* params) : params_(params) { }
~IConfig() = default;
virtual bool parseConfig(const libconfig::Setting& setting) = 0;
IParams* params() { return params_; }
protected:
T* params_;
};

View File

@@ -0,0 +1,47 @@
#pragma once
#include "IConfig.hpp"
struct KeymapParams : IParams {
std::string id;
std::unordered_map<int32_t, uint8_t> keymap;
};
class KeymapConfig : public IConfig<KeymapParams> {
public:
using IConfig<KeymapParams>::IConfig;
bool parseConfig(const libconfig::Setting& setting) override {
if (!setting.lookupValue("Id", params_->id)) {
return false;
}
try {
const libconfig::Setting& keymapGroup = setting.lookup("Keymap");
const libconfig::Setting& notesGroup = setting.lookup("Notes");
const libconfig::Setting& keysGroup = setting.lookup("Keys");
for(size_t i = 0; i < keymapGroup.getLength(); i++) {
libconfig::Setting& item = keymapGroup[i];
std::string key = item.getName();
std::string note = "";
if(item.getType() == libconfig::Setting::TypeString) note = std::string(item.c_str());
int8_t noteId = static_cast<int>(notesGroup[note]) % INT8_MAX;
int32_t keyId = static_cast<int>(keysGroup[key]) % INT32_MAX;
params_->keymap.emplace(keyId, noteId);
}
} catch (const libconfig::SettingException&) {
return false;
}
return true;
}
};

View File

@@ -0,0 +1,47 @@
#pragma once
#include "IConfig.hpp"
struct LoggerParams : IParams {
std::string id;
std::vector<std::string> flagsEnabled;
bool showTime = false;
bool showSourceTrace = false;
bool coutEnabled = false;
bool fileEnabled = false;
std::string filePath;
};
class LoggerConfig : public IConfig<LoggerParams> {
public:
using IConfig<LoggerParams>::IConfig;
bool parseConfig(const libconfig::Setting& setting) override {
if (!setting.lookupValue("Id", params_->id)) {
return false;
}
try {
const libconfig::Setting& flags = setting.lookup("FlagsEnabled");
for (int index = 0; index < flags.getLength(); ++index) {
params_->flagsEnabled.push_back(static_cast<const char*>(flags[index]));
}
} catch (const libconfig::SettingException&) {
params_->flagsEnabled.clear();
return false;
}
setting.lookupValue("ShowTime", params_->showTime);
setting.lookupValue("ShowSourceTrace", params_->showSourceTrace);
setting.lookupValue("CoutEnabled", params_->coutEnabled);
setting.lookupValue("FileEnabled", params_->fileEnabled);
setting.lookupValue("FilePath", params_->filePath);
return true;
}
};

View File

@@ -7,10 +7,11 @@
#include "TimerComponent.hpp"
#include "LoggerService.hpp"
#include "ConfigService.hpp"
#include "config/ConfigService.hpp"
#include "synth/AudioEngine.hpp"
#include "synth/KeyboardController.hpp"
#include "synth/Scope.hpp"
#include "synth/MidiController.hpp"
int main(int argc, char* argv[]) {
@@ -21,9 +22,10 @@ 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);
// audio synthesizer doohickey
@@ -33,19 +35,19 @@ int main(int argc, char* argv[]) {
// attach backend gui components
qmlRegisterType<TimerComponent>("AppDemo", 1, 0, "TimerComponent");
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;
}
// execute app
return app.exec();
}

View File

@@ -8,12 +8,15 @@
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;
// load audio settings from config service
if(!(config->getConfig<AudioParams, AudioConfig>("AudioEngine", "Main", &configuration_))) {
logger_->log("Audio", LogFlag::Error, "Failed to get logger configuration from config service");
return;
}
if(logger_ == nullptr) std::cout << "err: logger nullptr" << std::endl;
if(audioDevice_.getDeviceCount() < 1) {
logger_->log("Audio", LogFlag::Error, "No audio devices found.");
}
}
AudioEngine::~AudioEngine() {
@@ -25,26 +28,35 @@ bool AudioEngine::start() {
// initialize the audio engine
RtAudio::StreamParameters params;
params.deviceId = audioDevice_.getDefaultOutputDevice();
params.nChannels = channels_; // we're doing two duplicate channels for pseudo-mono
params.nChannels = configuration_.channels; // we're doing two duplicate channels for pseudo-mono
params.firstChannel = 0;
RtAudio::StreamOptions options;
options.flags = RTAUDIO_MINIMIZE_LATENCY;
RtAudioErrorType status = audioDevice_.openStream(&params, nullptr, RTAUDIO_FLOAT32, sampleRate_, &bufferFrames_, &AudioEngine::audioCallback, this, &options);
RtAudioErrorType status = audioDevice_.openStream(
&params,
nullptr,
RTAUDIO_FLOAT32,
configuration_.sampleRate,
&configuration_.bufferSize,
&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;
}
// sanity check
std::string msg = "sample rate: " + std::to_string(sampleRate_) + ", buffer frames: " + std::to_string(bufferFrames_);
std::string msg = "sample rate: " + std::to_string(configuration_.sampleRate) + ", buffer frames: " + std::to_string(configuration_.bufferSize);
logger_->log("AudioEngine", LogFlag::Info, msg);
return true;

View File

@@ -6,7 +6,8 @@
#include <RtAudio.h>
#include "LoggerService.hpp"
#include "ConfigService.hpp"
#include "config/ConfigService.hpp"
#include "config/AudioConfig.hpp"
#include "Synth.hpp"
#include "NoteQueue.hpp"
@@ -35,13 +36,10 @@ private:
RtAudio audioDevice_ { AUDIO_API };
// TODO: make these configurable
uint32_t sampleRate_ = 44100;
uint32_t bufferFrames_ = 512;
uint32_t channels_ = 2;
LoggerService* logger_;
ConfigService* config_;
AudioParams configuration_;
Synth* synth_;
};

24
src/synth/Envelope.cpp Normal file
View File

@@ -0,0 +1,24 @@
#include "Envelope.hpp"
Envelope::Envelope() {
}
Envelope::Envelope(float sampleRate) {
}
void Envelope::noteOn() {
}
void Envelope::noteOff() {
}
float Envelope::process() {
return 0.0f;
}

55
src/synth/Envelope.hpp Normal file
View File

@@ -0,0 +1,55 @@
#pragma once
#define ENVELOPE_MIN_TIME 0.001f // 1 ms
#include <cstdint>
#include <algorithm>
class Envelope {
enum class State {
Idle,
Attack,
Decay,
Sustain,
Release
};
public:
Envelope(); // we shouldn't need services for this
Envelope(float sampleRate);
~Envelope() = default;
void setSampleRate(float sampleRate) { sampleRate_ = sampleRate; }
void setAttack(float seconds) { attack_ = std::max(seconds, ENVELOPE_MIN_TIME); }
void setDecay(float seconds) { decay_ = std::max(seconds, ENVELOPE_MIN_TIME); }
void setSustain(float level) { sustain_ = level; }
void setRelease(float seconds) { release_ = std::max(seconds, ENVELOPE_MIN_TIME); }
// values close to zero cause some popping sounds on noteOns/noteOffs
State state() { return state_; }
// note events
void noteOn(); // transitions from idle state to attack
void noteOff(); // transitions from sustain to release
float process();
bool isActive() { return state_ != State::Idle; }
private:
State state_ = State::Idle;
float sampleRate_ = 44100.0f;
float attack_ = 0.0f;
float decay_ = 0.0f;
float sustain_ = 0.0f;
float release_ = 0.0f;
float value_ = 0.0f;
};

86
src/synth/Filter.cpp Normal file
View File

@@ -0,0 +1,86 @@
#include "Filter.hpp"
#include <cmath>
#include <algorithm>
void Filter::setSampleRate(float sampleRate) {
sampleRate_ = 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);
q_ = q;
calculateCoefficients();
}
// update current state and output filtered value
float Filter::biquadProcess(float in) {
// calculate filtered sample
float out = b0_ * in + z1_;
// update states
z1_ = b1_ * in - a1_ * out + z2_;
z2_ = b2_ * in - a2_ * out;
return out;
}
// internal control system emulation
void Filter::calculateCoefficients() {
if(q_ < 0.001f) q_ = 0.001f;
float omega = 2.0f * pi * frequency_ / sampleRate_;
float sinOmega = std::sin(omega);
float cosOmega = std::cos(omega);
float alpha = sinOmega / (2.0f * q_);
float b0, b1, b2, a0, a1, a2;
switch (type_) {
case Type::BiquadLowpass:
b0 = (1.0f - cosOmega) * 0.5f;
b1 = 1.0f - cosOmega;
b2 = (1.0f - cosOmega) * 0.5f;
a0 = 1.0f + alpha;
a1 = -2.0f * cosOmega;
a2 = 1.0f - alpha;
break;
case Type::BiquadHighpass:
b0 = (1.0f + cosOmega) * 0.5f;
b1 = -(1.0f + cosOmega);
b2 = (1.0f + cosOmega) * 0.5f;
a0 = 1.0f + alpha;
a1 = -2.0f * cosOmega;
a2 = 1.0f - alpha;
break;
case Type::BiquadBandpass:
b0 = sinOmega * 0.5f;
b1 = 0.0f;
b2 = -sinOmega * 0.5f;
a0 = 1.0f + alpha;
a1 = -2.0f * cosOmega;
a2 = 1.0f - alpha;
break;
}
// values need to be normalized
b0_ = b0 / a0;
b1_ = b1 / a0;
b2_ = b2 / a0;
a1_ = a1 / a0;
a2_ = a2 / a0;
}
void Filter::resetSate() {
z1_ = 0.0f;
z2_ = 0.0f;
}

46
src/synth/Filter.hpp Normal file
View File

@@ -0,0 +1,46 @@
#pragma once
#include <cstdint>
class Filter {
public:
enum class Type : uint16_t {
BiquadLowpass,
BiquadNotch,
BiquadBandpass,
BiquadHighpass
};
Filter() = default;
~Filter() = default;
void setSampleRate(float sampleRate);
void setParams(Type type, float frequency, float q);
float biquadProcess(float in);
void resetSate();
// TODO: add more filter types here
// high pass
// band pass
// notch
// https://webaudio.github.io/Audio-EQ-Cookbook/audio-eq-cookbook.html
// instead of making different calculate functions, have an enum which specifies the filter type
// one public calculate which the enum is passed through and a private calculate for the specific types, switch statement to choose
private:
void calculateCoefficients();
Type type_ = Type::BiquadLowpass;
float sampleRate_ = 1.0f;
float frequency_ = 6000.0f;
float q_ = 0.707f;
static constexpr float pi = 3.14159265358979323846;
// biquad filter structure
float a1_, a2_, b0_, b1_, b2_;
float z1_, z2_;
};

View File

@@ -3,8 +3,8 @@
#include "string"
Instrument::Instrument(ConfigService* config, LoggerService* logger) :
config_(config), logger_(logger) {
Instrument::Instrument(ConfigService* config, LoggerService* logger, float sampleRate) :
config_(config), logger_(logger), sampleRate_(sampleRate) {
}
@@ -28,7 +28,9 @@ bool Instrument::isActive() {
float Instrument::process(bool& scopeTrigger) {
if(active_ && envelope_ < 1.0f) envelope_ += 0.01f;
if(!active_ && envelope_ > 0.0f) envelope_ -= 0.01f;
if(!active_ && envelope_ > 0.0f) envelope_ -= 0.0004f;
if(!isActive()) return 0.0f;
phase_ += phaseIncrement_;
if(phase_ > 2.0f * pi) {
@@ -36,7 +38,11 @@ float Instrument::process(bool& scopeTrigger) {
scopeTrigger = true;
}
if(!isActive()) return 0.0f;
return sin(phase_) * envelope_;
// float sample = sin(phase_);
targetSample = phase_ / pi - 1.0f; // saw
currentSample = (1.0f - responsiveness_) * currentSample + responsiveness_ * targetSample;
return currentSample * envelope_;
}

View File

@@ -1,29 +1,29 @@
#pragma once
#include "ConfigService.hpp"
#include "config/ConfigService.hpp"
#include "LoggerService.hpp"
// an instrument is our state space model. it calculates discrete samples as a function of time, the current states, and the inputs
// an instrumeent is owned by a voice
// an instrument is owned by a voice
class Instrument {
public:
Instrument() = default;
Instrument(ConfigService* config, LoggerService* logger);
Instrument(ConfigService* config, LoggerService* logger, float sampleRate);
~Instrument() = default;
void noteOn(float frequency, float velocity);
void noteOff();
virtual void noteOn(float frequency, float velocity);
virtual void noteOff();
bool isActive();
virtual bool isActive();
float process(bool& scopeTrigger);
virtual float process(bool& scopeTrigger);
private:
protected:
float sampleRate_ = 44100.0f;
float sampleRate_ = 1.0f;
bool active_ = false;
ConfigService* config_;
@@ -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

@@ -0,0 +1,133 @@
#include "PianoString.hpp"
PianoString::PianoString(ConfigService* config, LoggerService* logger, float sampleRate) : Instrument(config, logger, sampleRate) {
stringY_current_.resize(segmentCount_ + 1);
stringY_previous_.resize(segmentCount_ + 1);
stringY_next_.resize(segmentCount_ + 1);
stringX_.resize(segmentCount_ + 1);
}
void PianoString::noteOn(float frequency, float velocity) {
active_ = true;
frequency_ = frequency / 1.03746174945f; // empirical adjustment (original was 83 cents sharp)
damping_ = 0.5f;
rms_ = 5.0f;
recalculateConstants();
// resize the state vectors so that they are stable at the specified frequency
stringY_current_.resize(segmentCount_ + 1);
stringY_previous_.resize(segmentCount_ + 1);
stringY_next_.resize(segmentCount_ + 1);
stringX_.resize(segmentCount_ + 1);
// because stringGrid is resized, reevaluate
dx_ = stringLength_ / static_cast<float>(segmentCount_);
for(size_t i = 0; i < segmentCount_ + 1; i++) {
stringX_[i] = i * dx_;
}
// first iteration
for(size_t i = 2; i < segmentCount_ - 2; i++) {
stringY_current_[i] = 0.5f * r1_*r1_ * (stringY_previous_[i-1] - 2.0f*stringY_previous_[i] + stringY_previous_[i+1]);
}
// apply the velocity impulse
for(size_t i = 0; i < segmentCount_ + 1; i++) {
float v0 = velocity * impulseVelocity_ * std::exp(-1.0f * (stringX_[i] - strikePosition_*L_)*(stringX_[i] - strikePosition_*L_) / ((2.0f * impulseWidth_)*(2.0f * impulseWidth_)));
stringY_current_[i] = stringY_current_[i] + dt_ * v0;
}
}
void PianoString::noteOff() {
damping_ = 8.0f;
recalculateConstants();
}
bool PianoString::isActive() {
if(active_ && (std::abs(rms_) < 0.0000001f)) {
active_ = false;
// reset state to stationary
for(size_t i = 0; i < segmentCount_ + 1; i++) {
stringY_current_[i] = 0.0f;
stringY_previous_[i] = 0.0f;
stringY_next_[i] = 0.0f;
}
}
return active_;
}
float PianoString::process(bool& scopeTrigger) {
// supersample
for(uint32_t n = 0; n < samplingSteps_; n++) {
// simulate over string
for(size_t i = 2; i < segmentCount_ - 2; i++) {
float y_xx = stringY_current_[i-1] - 2.0f*stringY_current_[i] + stringY_current_[i+1];
float y_xxxx = stringY_current_[i-2] - 4.0f*stringY_current_[i-1] + 6.0f*stringY_current_[i] - 4.0f*stringY_current_[i+1] + stringY_current_[i+2];
stringY_next_[i] = a1_ * stringY_current_[i] + a2_ * stringY_previous_[i] + r2_ * y_xx - s2_ * y_xxxx;
}
stringY_next_[0] = 0.0f;
stringY_next_[1] = 0.0f;
stringY_next_[segmentCount_-1] = 0.0f;
stringY_next_[segmentCount_-2] = 0.0f;
stringY_previous_ = stringY_current_;
stringY_current_ = stringY_next_;
}
phaseTracker_ += frequency_ / sampleRate_;
if(phaseTracker_ > 2.0f) {
phaseTracker_ -= 2.0f;
if(!scopeTrigger) scopeTrigger = true;
}
float sampleOut = stringY_next_[static_cast<size_t>(samplePosition_*segmentCount_)];
rms_ = 0.99f * rms_ + 0.01f * sampleOut*sampleOut;
return sampleOut * gain_;
}
void PianoString::recalculateConstants() {
L_ = stringLength_ * std::pow(1.001f, -frequency_ + 20.0f) + 0.5f;
waveVelocity_ = 2.0f * frequency_ * L_;
// calculate number of super-sampling steps needed to maintain string stability
samplingSteps_ = 0;
do {
samplingSteps_++;
float effectiveSampleRate_ = sampleRate_ * static_cast<float>(samplingSteps_);
dt_ = 1.0f / effectiveSampleRate_;
float a = (waveVelocity_*dt_/L_)*(waveVelocity_*dt_/L_);
float b = 4.0f*(stiffness_*dt_/(L_*L_))*(stiffness_*dt_/(L_*L_));
if(stiffness_ < 0.00001f) { // avoid divide by zero
segmentCount_ = static_cast<size_t>((L_ * effectiveSampleRate_)/waveVelocity_) - 1;
} else {
segmentCount_ = static_cast<size_t>(std::sqrt((-a+std::sqrt(a*a+4.0*b))/(2.0*b))) - 1;
}
} while (segmentCount_ < 80);
// cap segment count at 80 if above
segmentCount_ = std::min(segmentCount_, static_cast<size_t>(80));
dx_ = L_ / static_cast<float>(segmentCount_);
r1_ = waveVelocity_ * dt_/dx_;
r2_ = (waveVelocity_ * dt_/dx_)*(waveVelocity_ * dt_/dx_);
s1_ = stiffness_ * dt_/(dx_*dx_);
s2_ = (stiffness_ * dt_/(dx_*dx_))*(stiffness_ * dt_/(dx_*dx_));
a1_ = 2.0f - 2.0f * damping_ * dt_;
a2_ = 2.0f * damping_ * dt_ - 1.0f;
}

View File

@@ -0,0 +1,75 @@
#pragma once
#include <cmath>
#include "synth/instruments/Instrument.hpp"
class PianoString : public Instrument {
public:
PianoString() = default;
PianoString(ConfigService* config, LoggerService* logger, float sampleRate);
~PianoString() = default;
void noteOn(float frequency, float velocity) override;
void noteOff() override;
bool isActive() override;
float process(bool& scopeTrigger) override;
private:
void recalculateConstants();
// states
float frequency_ = 0.0f;
std::vector<float> stringY_current_;
std::vector<float> stringY_previous_;
std::vector<float> stringY_next_;
std::vector<float> stringX_;
bool active_ = false;
// constants
// string parameters
size_t segmentCount_ = 80;
static constexpr float rho_ = 8000.0f; // density, steel, kg/m^3
static constexpr float radius_ = 0.001f; // meters
static constexpr float stringTension_ = 1200.0f; // string tension, N
static constexpr float stiffness_ = 0.0f; // stiffness coefficient
float damping_ = 0.5f; // damping coefficient
static constexpr float stringLength_ = 4.0f; // length of string at lowest frequency (20 hz)
static constexpr float strikePosition_ = 0.8f; // x of impulse location
static constexpr float impulseWidth_ = 0.1f; // x% of impulse width
static constexpr float impulseVelocity_ = 10000.0f; // x/t of impulse magnitude
static constexpr float samplePosition_ = 0.2f; // percentage along L of sampling for audio
float crossSectionalArea_ = pi * std::pow(radius_, 2.0f); // string cross sectional area, assuming circular
float mu_ = crossSectionalArea_ * rho_; // linear mass density
float waveVelocity_ = std::sqrt(stringTension_ / mu_); // transverse wave velocity
float L_ = 1.0f; // length of string at a given frequency
float gain_ = 0.5f;
// f0_ = waveVelocity_ / (2.0f * stringLength_); // fundamental frequency of a non-stiff string
// f1_ = f0_ * std::sqrt(1.0f + stiffness_); // fundamental frequency of the stiff string
uint32_t samplingSteps_ = 1;
float phaseTracker_ = 0.0f;
float dx_ = L_ / static_cast<float>(segmentCount_);
float dt_ = 1.0f / sampleRate_;
// derived constants
float r1_ = waveVelocity_ * dt_/dx_;
float r2_ = std::pow(waveVelocity_ * dt_/dx_, 2.0f);
float s1_ = stiffness_ * dt_/std::pow(dx_, 2.0f);
float s2_ = std::pow(stiffness_ * dt_/std::pow(dx_, 2.0f), 2.0f);
float a1_ = 2.0f - 2.0f * damping_ * dt_;
float a2_ = 2.0f * damping_ * dt_ - 1.0f;
// keeping track of the string's activeness
float rms_ = 0.0f;
};

View File

@@ -0,0 +1,30 @@
#include "WavetableOscillator.hpp"
WavetableOscillator::WavetableOscillator(ConfigService* config, LoggerService* logger, float sampleRate) {
}
void WavetableOscillator::noteOn(float frequency, float velocity) {
envelope_.noteOn(); // hmm we can scale this by velocity
}
void WavetableOscillator::noteOff() {
envelope_.noteOff();
}
bool WavetableOscillator::isActive() {
return envelope_.isActive();
}
float WavetableOscillator::process(bool& scopeTrigger) {
// return wavetable_.process() * envelope_.process();
// more like
return 0.0f;
}
bool WavetableOscillator::loadWavetable(std::string filename) {
return false;
}

View File

@@ -0,0 +1,32 @@
#pragma once
#include <cmath>
#include "synth/instruments/Instrument.hpp"
#include "synth/Envelope.hpp"
class WavetableOscillator : public Instrument {
public:
WavetableOscillator() = default;
WavetableOscillator(ConfigService* config, LoggerService* logger, float sampleRate);
~WavetableOscillator() = default;
void noteOn(float frequency, float velocity) override;
void noteOff() override;
bool isActive() override;
float process(bool& scopeTrigger) override;
private:
bool loadWavetable(std::string filename); // TODO: load from conf file instead
std::vector<float> wavetable_;
Envelope envelope_;
};

View File

@@ -12,8 +12,8 @@ KeyboardController::KeyboardController(QObject* parent) : QObject(parent) {
KeyboardController::KeyboardController(ConfigService* config, LoggerService* logger, NoteQueue* queue) : config_(config), logger_(logger), queue_(queue) {
// load keymap from config service
if(!(config->getConfig<KeymapConfig>("KeyboardController", "Main", &configuration_))) {
logger_->log("Keyboard", LogFlag::Error, "Failed to get logger configuration fom config service");
if(!(config->getConfig<KeymapParams, KeymapConfig>("KeyboardController", "Main", &configuration_))) {
logger_->log("Keyboard", LogFlag::Error, "Failed to get logger configuration from config service");
return;
}

View File

@@ -7,7 +7,8 @@
#include <unordered_map>
#include "NoteQueue.hpp"
#include "ConfigService.hpp"
#include "config/ConfigService.hpp"
#include "config/KeymapConfig.hpp"
#include "LoggerService.hpp"
// The keyboardcontroller handles user inputs from a keyboard and maps them to note events
@@ -32,7 +33,7 @@ private:
LoggerService* logger_;
// keymap is key -> midi note id
KeymapConfig configuration_;
KeymapParams configuration_;
static constexpr float defaultVelocity_ = 0.8f;

View File

@@ -4,14 +4,25 @@
#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
#ifdef WIN32
openDefaultPort();
#else
openPort(1);
#endif
}
MidiController::~MidiController() {
@@ -19,18 +30,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 +57,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 +92,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 +128,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 +142,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 "config/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 "config/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

@@ -4,10 +4,11 @@
#include <iostream>
ScopeBuffer::ScopeBuffer(QObject *parent) : QObject(parent) {
// timer is set in qml
}
ScopeBuffer::ScopeBuffer(size_t size) : buffer_(size) {
ScopeBuffer::ScopeBuffer(ConfigService* config, LoggerService* logger, size_t size) :
config_(config), logger_(logger), buffer_(size) {
}
@@ -44,7 +45,7 @@ void Scope::paint(QPainter *painter) {
if(scopeBuffer_ != nullptr) scopeBuffer_->read(buffer_);
// TODO: scale to max amplitude ?
// TODO: scale to max amplitude ? probably make it configurable if you want to make scope options
float maxAmp = 1.0f;
/*
for(float s : buffer_) {

View File

@@ -8,6 +8,9 @@
#include <vector>
#include <atomic>
#include "config/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};

View File

@@ -4,18 +4,33 @@
Synth::Synth(ConfigService* config, LoggerService* logger, ScopeBuffer* scope, NoteQueue* queue) :
config_(config), logger_(logger), scope_(scope), noteQueue_(queue) {
voices_.fill(Voice(config_, logger_));
// load audio settings from config service
if(!(config->getConfig<AudioParams, AudioConfig>("AudioEngine", "Main", &engineConfiguration_))) {
logger_->log("Audio", LogFlag::Error, "Failed to get logger configuration from config service");
return;
}
// note: this is the audio engine's configuration so we can grab sample rate
// it includes some synth params as well, so when synth gets its own config node they all need to be moved
voices_.assign(engineConfiguration_.maxVoices, Voice(config_, logger_, static_cast<float>(engineConfiguration_.sampleRate), &engineConfiguration_));
filter_.setSampleRate(engineConfiguration_.sampleRate);
filter_.setParams(Filter::Type::BiquadLowpass, 4000.0f, 0.707f);
}
void Synth::handleNoteEvent(const NoteEvent& event) {
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();
}
}
// call note-on for an idle voice for the note-event
if(event.type == NoteEventType::NoteOn) {
v = findFreeVoice();
Voice* v = findFreeVoice();
if(v != nullptr) v->noteOn(event.note, event.velocity);
}
@@ -28,8 +43,9 @@ void Synth::process(float* out, size_t nFrames) {
handleNoteEvent(noteEvent);
}
// find lowest frequency voice for scope triggering
size_t lowestVoice = 0;
float lowestFreq = 100000.0f;
float lowestFreq = FLT_MAX;
for(size_t i = 0; i < voices_.size(); i++) {
if(!voices_[i].isActive()) continue;
float currentFreq = voices_[i].frequency();
@@ -44,18 +60,21 @@ void Synth::process(float* out, size_t nFrames) {
float sampleOut = 0.0f;
bool triggered = false;
bool once = false;
// generate n samples for the output buffer
for(size_t i = 0; i < nFrames; i++) {
// process all active voices and mix their produced samples
float mix = 0.0f;
for(size_t j = 0; j < voices_.size(); j++) {
bool temp = false;
//if(!voices_[j].isActive()) continue;
if(!voices_[j].isActive()) continue;
mix += voices_[j].process(temp);
if(j == lowestVoice) triggered = temp;
}
mix = tanh(mix/4.0f); // prevent clipping
mix = tanh(mix); // prevent clipping
sampleOut = mix;
sampleOut = filter_.biquadProcess(mix);
out[2*i] = sampleOut;
out[2*i+1] = sampleOut;
@@ -68,7 +87,6 @@ void Synth::process(float* out, size_t nFrames) {
scope_->spinlock(false);
}
Voice* Synth::findFreeVoice() {
@@ -89,4 +107,4 @@ Voice* Synth::findVoiceByNote(uint8_t note) {
}
}
return nullptr;
}
}

View File

@@ -1,11 +1,13 @@
#pragma once
#include "ConfigService.hpp"
#include "config/ConfigService.hpp"
#include "config/AudioConfig.hpp"
#include "LoggerService.hpp"
#include "NoteQueue.hpp"
#include "Voice.hpp"
#include "Scope.hpp"
#include "Filter.hpp"
#include <vector>
@@ -25,12 +27,14 @@ private:
Voice* findVoiceByNote(uint8_t note);
std::vector<uint8_t> sustainedNotes_;
Filter filter_;
// voices
static constexpr size_t MAX_VOICES = 32;
std::array<Voice, MAX_VOICES> voices_;
size_t maxVoices;
std::vector<Voice> voices_;
ConfigService* config_;
AudioParams engineConfiguration_;
LoggerService* logger_;
ScopeBuffer* scope_;;
NoteQueue* noteQueue_;

View File

@@ -1,10 +1,11 @@
#include "Voice.hpp"
Voice::Voice(ConfigService* config, LoggerService* logger) :
config_(config), logger_(logger) {
Voice::Voice(ConfigService* config, LoggerService* logger, float sampleRate, AudioParams* params) :
config_(config), logger_(logger), sampleRate_(sampleRate), params_(params) {
instrument_ = Instrument(config_, logger_);
// TODO: instrument factory << WHAT DO YOU MEAN JUST TODO A WHOLE FACTORY
instrument_ = PianoString(config_, logger_, sampleRate_);
}

View File

@@ -3,7 +3,9 @@
#include <stdint.h>
#include "Instrument.hpp"
#include "instruments/Instrument.hpp"
#include "instruments/PianoString.hpp"
#include "config/AudioConfig.hpp"
// a voice is a tone generator that the synth uses for polyphony
// the synth mixes multiple voices together into a polyphonic audio. calculations for samples are handled in the instrument
@@ -12,7 +14,7 @@ class Voice {
public:
Voice() = default;
Voice(ConfigService* config, LoggerService* logger);
Voice(ConfigService* config, LoggerService* logger, float sampleRate, AudioParams* params);
~Voice() = default;
void noteOn(uint8_t midiNote, float velocity);
@@ -25,18 +27,18 @@ public:
private:
float sampleRate_ = 44100.0f;
inline float noteToFrequency(uint8_t note) {
return 440.0f * pow(2.0f, static_cast<float>(note - 69) / static_cast<float>(12));
return params_->pitchStandard * pow(params_->octaveConstant, static_cast<float>(note - params_->midiHome) / params_->notesPerOctave);
}
float sampleRate_ = 1.0f;
uint8_t note_ = 0;
float velocity_ = 1.0f;
bool active_ = false;
ConfigService* config_;
AudioParams* params_;
LoggerService* logger_;
Instrument instrument_;
PianoString instrument_;
};

View File

@@ -0,0 +1,58 @@
#include "WavetableLoader.hpp"
WavetableLoader::WavetableLoader(ConfigService* config, LoggerService* logger) : config_(config), logger_(logger) {
}
int32_t WavetableLoader::loadWavetables() {
// find wavetable files
std::vector<std::filesystem::path> wavetableFiles;
for(std::filesystem::directory_entry entry : std::filesystem::directory_iterator(wavetableFilepath_)) {
if(std::filesystem::is_regular_file(entry.status())) {
wavetableFiles.push_back(entry.path()); // TODO: check this file to see if it is valid
}
}
wavetableCount_ = wavetableFiles.size();
wavetables_.resize(wavetableCount_);
// load from file to vector
for(size_t i = 0; i < wavetableCount_; i++) {
// logger->log(loading wavetable file...)
std::ifstream inputFile(wavetableFiles[i], std::ios::in | std::ios::binary);
if(!inputFile) // logger->log("error oppening file...")
inputFile.read(reinterpret_cast<char*>(wavetables_[i].data()), 2048 * sizeof(float));
}
// TODO: wavetable header should contain info like number of samples
return wavetableCount_;
}
float WavetableLoader::sample(size_t wavetableIndex, float phase) {
float sampleValue = 0.0f;
if(phase >= 0.0f) {
while(phase >= 2.0f*kPi_) {
phase -= 2.0f*kPi_;
}
} else {
while(phase <= 0.0f) {
phase += 2.0f*kPi_;
}
}
float scaledPhase = phase * static_cast<float>(2048) / (2.0f*kPi_);
// TODO: I'd like the default domain of wavetables to be [0, 1) but it should also be loaded in from the wavetable file metadata
size_t index = static_cast<size_t>(round(scaledPhase));
if(index >= 2048) index = 2048 - 1;
if(wavetableIndex >= wavetableCount_) {
wavetableIndex = wavetableCount_ - 1;
}
return wavetables_[wavetableIndex][index];
}

View File

@@ -0,0 +1,40 @@
#pragma once
// placeholder to handle loading wavetables from files
// so that wavetables can be shared to each voice/instrument
// only need to need to load once for x number of voices
#include <vector>
#include <cstdint>
#include <filesystem>
#include "config/ConfigService.hpp"
#include "LoggerService.hpp"
class WavetableLoader {
public:
WavetableLoader(ConfigService* config, LoggerService* logger);
~WavetableLoader() = default;
float sample(size_t wavetableIndex, float phase);
private:
int32_t loadWavetables();
size_t wavetableCount_ = 0;
std::vector<std::vector<float>> wavetables_;
// TODO: this will be configurable
const std::filesystem::path wavetableFilepath_ = "./config/wavetables";
LoggerService* logger_;
ConfigService* config_;
static constexpr float kPi_ = 3.14159265358979323846;
};

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 {

View File