Compare commits

...

25 Commits

Author SHA1 Message Date
5d86018d06 add instrument factory 2026-10-17 03:34:19 -05:00
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
58 changed files with 1820 additions and 354 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) project(sonobulus LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++20")
# get dependencies # get dependencies
include(FetchContent) include(FetchContent)
@@ -45,7 +46,7 @@ find_package(Qt6 REQUIRED COMPONENTS
qt_standard_project_setup() qt_standard_project_setup()
add_library(sonobulus_core STATIC add_library(sonobulus_core STATIC
src/ConfigService.cpp src/config/ConfigService.cpp
src/LoggerService.cpp src/LoggerService.cpp
src/TimerComponent.cpp src/TimerComponent.cpp
src/synth/AudioEngine.cpp src/synth/AudioEngine.cpp
@@ -54,15 +55,28 @@ add_library(sonobulus_core STATIC
src/synth/NoteQueue.cpp src/synth/NoteQueue.cpp
src/synth/Scope.cpp src/synth/Scope.cpp
src/synth/Synth.cpp src/synth/Synth.cpp
src/synth/Voice.cpp #src/synth/Voice.cpp
src/synth/Instrument.cpp src/synth/Filter.cpp
src/synth/WavetableLoader.cpp
src/synth/Envelope.cpp
src/synth/InstrumentFactory.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 target_link_libraries(sonobulus_core PRIVATE
Qt6::Core Qt6::Core
Qt6::Quick Qt6::Quick
rtaudio rtaudio
rtmidi rtmidi
libconfig++ ${LIBCONFIG_LIB}
) )
target_include_directories(sonobulus_core PRIVATE target_include_directories(sonobulus_core PRIVATE
@@ -80,6 +94,8 @@ qt_add_executable(sonobulus
src/main.cpp src/main.cpp
) )
set_target_properties(sonobulus PROPERTIES OUTPUT_NAME "sonobulus.out")
qt_add_qml_module(sonobulus qt_add_qml_module(sonobulus
URI sonobulus URI sonobulus
VERSION 1.0 VERSION 1.0
@@ -87,6 +103,7 @@ qt_add_qml_module(sonobulus
) )
target_include_directories(sonobulus PRIVATE target_include_directories(sonobulus PRIVATE
${CMAKE_SOURCE_DIR}/src/
${rtaudio_SOURCE_DIR} ${rtaudio_SOURCE_DIR}
${rtmidi_SOURCE_DIR} ${rtmidi_SOURCE_DIR}
${libconfig_SOURCE_DIR}/lib ${libconfig_SOURCE_DIR}/lib

View File

@@ -8,9 +8,9 @@ Logger = (
"Info", "Info",
"Warning", "Warning",
"Error" "Error"
); ); # TODO: console flags vs file flags
ShowTime = true; ShowTime = false;
ShowSourceTrace = false; ShowSourceTrace = false;
CoutEnabled = true; CoutEnabled = true;
@@ -19,6 +19,23 @@ 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;
}
);
# TODO: I don't like this being in the main config, maybe import it in (mainly the notes and keycodes)
KeyboardController = ( KeyboardController = (
{ {
Id = "Main"; 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.

View File

View File

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

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

View File

@@ -5,7 +5,8 @@
#include <fstream> #include <fstream>
#include <source_location> #include <source_location>
#include "ConfigService.hpp" #include "config/ConfigService.hpp"
#include "config/LoggerConfig.hpp"
enum LogFlag { enum LogFlag {
Debug, Debug,
@@ -33,14 +34,8 @@ public:
private: private:
bool standardOutputEnabled_;
bool fileOutputEnabled_;
bool additionaldetailsEnabled_;
bool timeEnabled_;
std::ofstream outfile_; std::ofstream outfile_;
std::string id_;
std::vector<LogFlag> activeFlags_; 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,7 +7,7 @@
#include "TimerComponent.hpp" #include "TimerComponent.hpp"
#include "LoggerService.hpp" #include "LoggerService.hpp"
#include "ConfigService.hpp" #include "config/ConfigService.hpp"
#include "synth/AudioEngine.hpp" #include "synth/AudioEngine.hpp"
#include "synth/KeyboardController.hpp" #include "synth/KeyboardController.hpp"
#include "synth/Scope.hpp" #include "synth/Scope.hpp"
@@ -22,8 +22,8 @@ int main(int argc, char* argv[]) {
// create app objects // create app objects
ConfigService config = ConfigService("config/sonobulus.cfg"); ConfigService config = ConfigService("config/sonobulus.cfg");
LoggerService logger = LoggerService(&config, "Engine"); LoggerService logger = LoggerService(&config, "Engine");
NoteQueue queue = NoteQueue(); NoteQueue queue = NoteQueue(&config, &logger);
ScopeBuffer scopeBuffer = ScopeBuffer(512); ScopeBuffer scopeBuffer = ScopeBuffer(&config, &logger, 2048);
KeyboardController keyboard(&config, &logger, &queue); KeyboardController keyboard(&config, &logger, &queue);
MidiController midi(&config, &logger, &queue); MidiController midi(&config, &logger, &queue);
Synth synth(&config, &logger, &scopeBuffer, &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 engine.load(QUrl::fromLocalFile("ui/Main.qml")); // ugh
if(engine.rootObjects().isEmpty()) { if(engine.rootObjects().isEmpty()) {
std::cout << "engine is empty" << std::endl; logger.log("Main", LogFlag::Error, "Engine is empty.");
return -1; return -1;
} }

View File

@@ -8,12 +8,15 @@
AudioEngine::AudioEngine(ConfigService* config, LoggerService* logger, Synth* synth) : config_(config), logger_(logger), synth_(synth) { AudioEngine::AudioEngine(ConfigService* config, LoggerService* logger, Synth* synth) : config_(config), logger_(logger), synth_(synth) {
if(audioDevice_.getDeviceCount() < 1) { // load audio settings from config service
std::cout << "No audio devices found" << std::endl; 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() { AudioEngine::~AudioEngine() {
@@ -25,26 +28,35 @@ bool AudioEngine::start() {
// initialize the audio engine // initialize the audio engine
RtAudio::StreamParameters params; RtAudio::StreamParameters params;
params.deviceId = audioDevice_.getDefaultOutputDevice(); 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; params.firstChannel = 0;
RtAudio::StreamOptions options; RtAudio::StreamOptions options;
options.flags = RTAUDIO_MINIMIZE_LATENCY; 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) { if(status != RTAUDIO_NO_ERROR) {
std::cout << "Error opening RtAudio stream" << std::endl; logger_->log("Audio", LogFlag::Error, "Error opening RtAudio stream.");
return false; return false;
} }
status = audioDevice_.startStream(); status = audioDevice_.startStream();
if(status != RTAUDIO_NO_ERROR) { if(status != RTAUDIO_NO_ERROR) {
std::cout << "Error starting RtAudio stream" << std::endl; logger_->log("Audio", LogFlag::Error, "Error starting RtAudio stream.");
return false; return false;
} }
// sanity check // 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); logger_->log("AudioEngine", LogFlag::Info, msg);
return true; return true;

View File

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

@@ -1,37 +0,0 @@
#pragma once
#include "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
class Instrument {
public:
Instrument() = default;
Instrument(ConfigService* config, LoggerService* logger);
~Instrument() = default;
void noteOn(float frequency, float velocity);
void noteOff();
bool isActive();
float process(bool& scopeTrigger);
private:
float sampleRate_ = 44100.0f;
bool active_ = false;
ConfigService* config_;
LoggerService* logger_;
static constexpr float pi = 3.14159265358979323846f;
float phase_ = 0.0f;
float phaseIncrement_ = 0.0f;
float envelope_ = 0.0f;
};

View File

@@ -0,0 +1,38 @@
#include "InstrumentFactory.hpp"
#include "Instruments/PianoString.hpp"
#include "Instruments/WavetableOscillator.hpp"
InstrumentFactory::InstrumentFactory(ConfigService* config, LoggerService* logger) :
config_(config), logger_(logger) {
}
Instrument* InstrumentFactory::createInstrument(InstrumentType type, float sampleRate, AudioParams* params) {
switch(type) {
case InstrumentType::Default:
return new Instrument(config_, logger_, sampleRate, params);
case InstrumentType::PianoString:
return new PianoString(config_, logger_, sampleRate, params);
case InstrumentType::WavetableOscillator:
return new WavetableOscillator(config_, logger_, wavetableLoader_, sampleRate, params);
default:
return nullptr;
}
}
int32_t InstrumentFactory::createInstruments(std::vector<Instrument*>* instruments, size_t voiceCount, float sampleRate, AudioParams* params) {
// parse the config file
// if there's wavetables then we need a wavetable loader
// initialize wavetableLoader_ here
for(size_t i = 0; i < params->maxVoices; i++) {
instruments->push_back(createInstrument(InstrumentType::PianoString, static_cast<float>(params->sampleRate), params));
}
return params->maxVoices;
}

View File

@@ -0,0 +1,35 @@
#pragma once
#include "config/ConfigService.hpp"
#include "config/AudioConfig.hpp"
#include "LoggerService.hpp"
#include "Instruments/Instrument.hpp"
#include "WavetableLoader.hpp"
class InstrumentFactory {
public:
InstrumentFactory(ConfigService* config, LoggerService* logger);
~InstrumentFactory() = default;
int32_t createInstruments(std::vector<Instrument*>* instruments, size_t voiceCount, float sampleRate, AudioParams* params);
enum class InstrumentType {
Default,
PianoString,
WavetableOscillator
};
private:
Instrument* createInstrument(InstrumentType type, float sampleRate, AudioParams* params);
ConfigService* config_;
LoggerService* logger_;
// optional creators
WavetableLoader* wavetableLoader_ = nullptr;
};

View File

@@ -3,8 +3,16 @@
#include "string" #include "string"
Instrument::Instrument(ConfigService* config, LoggerService* logger) : Instrument::Instrument(ConfigService* config, LoggerService* logger, float sampleRate, AudioParams* params) :
config_(config), logger_(logger) { config_(config), logger_(logger), sampleRate_(sampleRate), params_(params) {
}
void Instrument::startNote(uint8_t midiNote, float velocity) {
note_ = midiNote;
noteOn(noteToFrequency(midiNote), velocity);
} }
@@ -30,17 +38,19 @@ float Instrument::process(bool& scopeTrigger) {
if(active_ && envelope_ < 1.0f) envelope_ += 0.01f; if(active_ && envelope_ < 1.0f) envelope_ += 0.01f;
if(!active_ && envelope_ > 0.0f) envelope_ -= 0.0004f; if(!active_ && envelope_ > 0.0f) envelope_ -= 0.0004f;
if(!isActive()) return 0.0f;
phase_ += phaseIncrement_; phase_ += phaseIncrement_;
if(phase_ > 2.0f * pi) { if(phase_ > 2.0f * pi) {
phase_ -= 2.0f * pi; phase_ -= 2.0f * pi;
scopeTrigger = true; scopeTrigger = true;
} }
if(!isActive()) return 0.0f;
// float sample = sin(phase_); // 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

@@ -0,0 +1,53 @@
#pragma once
#include "config/ConfigService.hpp"
#include "LoggerService.hpp"
#include "config/AudioConfig.hpp"
// an instrument is our state space model. it calculates discrete samples as a function of time, the current states, and the inputs
// an instrument is owned by a voice
class Instrument {
public:
Instrument() = default;
Instrument(ConfigService* config, LoggerService* logger, float sampleRate, AudioParams* params);
~Instrument() = default;
void startNote(uint8_t midiNote, float velocity);
virtual void noteOn(float frequency, float velocity);
virtual void noteOff();
virtual bool isActive();
virtual float process(bool& scopeTrigger);
uint8_t note() { return note_; }
float frequency() { return noteToFrequency(note_); }
protected:
inline float noteToFrequency(uint8_t note) {
return params_->pitchStandard * pow(params_->octaveConstant, static_cast<float>(note - params_->midiHome) / params_->notesPerOctave);
}
float sampleRate_ = 1.0f;
bool active_ = false;
ConfigService* config_;
LoggerService* logger_;
AudioParams* params_;
uint8_t note_;
static constexpr float pi = 3.14159265358979323846f;
float phase_ = 0.0f;
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, AudioParams* params) : Instrument(config, logger, sampleRate, params) {
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, AudioParams* params);
~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,41 @@
#include "WavetableOscillator.hpp"
WavetableOscillator::WavetableOscillator(ConfigService* config, LoggerService* logger, WavetableLoader* wavetabler, float sampleRate, AudioParams* params)
: Instrument(config, logger, sampleRate, params), wavetableLoader_(wavetabler) {
}
void WavetableOscillator::noteOn(float frequency, float velocity) {
envelope_.noteOn(); // hmm we can scale this by velocity
frequency_ = frequency;
phaseIncrement_ = 2.0f * kPi_ * frequency_ / sampleRate_;
}
void WavetableOscillator::noteOff() {
envelope_.noteOff();
}
bool WavetableOscillator::isActive() {
return envelope_.isActive();
}
float WavetableOscillator::process(bool& scopeTrigger) {
if(wavetableLoader_ == nullptr) {
return 0.0f;
}
float sampleOut = wavetableLoader_->sample(wavetableId_, phase_);
phase_ += phaseIncrement_;
if(phase_ > 2.0f * kPi_) {
phase_ -= 2.0f * kPi_;
scopeTrigger = true;
}
return sampleOut;
}

View File

@@ -0,0 +1,40 @@
#pragma once
#include <cmath>
#include "synth/instruments/Instrument.hpp"
#include "synth/Envelope.hpp"
#include "synth/WavetableLoader.hpp"
class WavetableOscillator : public Instrument {
public:
WavetableOscillator() = default;
WavetableOscillator(ConfigService* config, LoggerService* logger, WavetableLoader* wavetabler, float sampleRate, AudioParams* params);
~WavetableOscillator() = default;
void noteOn(float frequency, float velocity) override;
void noteOff() override;
bool isActive() override;
float process(bool& scopeTrigger) override;
bool setWavetableId(size_t id) { wavetableId_ = id; }
private:
WavetableLoader* wavetableLoader_;
size_t wavetableId_ = 0;
Envelope envelope_;
// float phase_ = 0.0f; inherited from parent class
float frequency_ = 0.0f;
float phaseIncrement = 0.0f;
static constexpr float kPi_ = 3.14159265358979323846;
};

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

View File

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

View File

@@ -14,10 +14,15 @@ MidiController::MidiController(ConfigService* config, LoggerService* logger, Not
#endif #endif
midiIn_->ignoreTypes(false, false, false); midiIn_->ignoreTypes(false, false, false);
} catch (RtMidiError& e) { } 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);
} }
#ifdef WIN32
openDefaultPort(); openDefaultPort();
#else
openPort(1);
#endif
} }
MidiController::~MidiController() { MidiController::~MidiController() {
@@ -25,18 +30,21 @@ MidiController::~MidiController() {
} }
// open the first for thats successful // open the first for thats successful
// we could also add an option in the config for a preferred port name
bool MidiController::openDefaultPort() { bool MidiController::openDefaultPort() {
if (!midiIn_) return false; if (!midiIn_) return false;
if (midiIn_->getPortCount() == 0) { 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; 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(); 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++) { 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; if(openPort(i)) return true;
} }
@@ -49,11 +57,13 @@ bool MidiController::openPort(unsigned int index) {
try { try {
midiIn_->openPort(index); midiIn_->openPort(index);
midiIn_->setCallback(&MidiController::midiCallback, this); midiIn_->setCallback(&MidiController::midiCallback, this);
std::cout << "Opened MIDI port: " << midiIn_->getPortName(index) << std::endl;
std::string msg = "Opened MIDI port: " + midiIn_->getPortName(index);
logger_->log("MIDI", LogFlag::Info, msg);
return true; return true;
} catch (RtMidiError& e) { } catch (RtMidiError& e) {
std::cout << "Midi Port error" << std::endl; std::string msg = "Midi Port error: " + e.getMessage();
std::cerr << e.getMessage() << std::endl; logger_->log("MIDI", LogFlag::Error, msg);
return false; return false;
} }
} }
@@ -82,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 == 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 == 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 // sustain pedal message event
if(status == 0xB0 && data1 == 64) { if(status == 0xB0 && data1 == 64) {
handleSustain(data2 >= 64); handleSustain(data2 >= 64);

View File

@@ -7,7 +7,7 @@
#include <unordered_set> #include <unordered_set>
#include "NoteQueue.hpp" #include "NoteQueue.hpp"
#include "ConfigService.hpp" #include "config/ConfigService.hpp"
#include "LoggerService.hpp" #include "LoggerService.hpp"
class MidiController { class MidiController {

View File

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

View File

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

View File

@@ -4,10 +4,11 @@
#include <iostream> #include <iostream>
ScopeBuffer::ScopeBuffer(QObject *parent) : QObject(parent) { 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_); 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; float maxAmp = 1.0f;
/* /*
for(float s : buffer_) { for(float s : buffer_) {

View File

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

View File

@@ -2,28 +2,43 @@
#include "Synth.hpp" #include "Synth.hpp"
Synth::Synth(ConfigService* config, LoggerService* logger, ScopeBuffer* scope, NoteQueue* queue) : Synth::Synth(ConfigService* config, LoggerService* logger, ScopeBuffer* scope, NoteQueue* queue) :
config_(config), logger_(logger), scope_(scope), noteQueue_(queue) { config_(config), logger_(logger), scope_(scope), noteQueue_(queue), instrumentFactory_(config_, logger_) {
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
int32_t instrumentsCreated = instrumentFactory_.createInstruments(&instruments_, engineConfiguration_.maxVoices, static_cast<float>(engineConfiguration_.sampleRate), &engineConfiguration_);
if(instrumentsCreated == 0) {
logger_->log("Audio", LogFlag::Error, "Failed to create any voices from factory.");
} else if(instrumentsCreated > engineConfiguration_.maxVoices) {
logger_->log("Audio", LogFlag::Warning, "Created too many voices, possible truncation or overflow.");
} else {
logger_->log("Audio", LogFlag::Info, "Created voices from factory.");
}
filter_.setSampleRate(engineConfiguration_.sampleRate);
filter_.setParams(Filter::Type::BiquadLowpass, 4000.0f, 0.707f);
} }
void Synth::handleNoteEvent(const NoteEvent& event) { 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 // stop all voices that are currently playing this note
for(Voice& v : voices_) { for(Instrument* i : instruments_) {
if(v.isActive() && v.note() == event.note) { if(i->isActive() && i->note() == event.note) {
v.noteOff(); i->noteOff();
} }
} }
// call note-on for an idle voice for the note-event
if(event.type == NoteEventType::NoteOn) { if(event.type == NoteEventType::NoteOn) {
Instrument* i = findFreeVoice();
Voice* v = findFreeVoice(); if(i != nullptr) i->startNote(event.note, event.velocity);
if(v != nullptr) v->noteOn(event.note, event.velocity);
} }
} }
@@ -35,11 +50,12 @@ void Synth::process(float* out, size_t nFrames) {
handleNoteEvent(noteEvent); handleNoteEvent(noteEvent);
} }
// find lowest frequency voice for scope triggering
size_t lowestVoice = 0; size_t lowestVoice = 0;
float lowestFreq = 100000.0f; float lowestFreq = FLT_MAX;
for(size_t i = 0; i < voices_.size(); i++) { for(size_t i = 0; i < instruments_.size(); i++) {
if(!voices_[i].isActive()) continue; if(!instruments_[i]->isActive()) continue;
float currentFreq = voices_[i].frequency(); float currentFreq = instruments_[i]->frequency();
if(currentFreq < lowestFreq) { if(currentFreq < lowestFreq) {
lowestVoice = i; lowestVoice = i;
lowestFreq = currentFreq; lowestFreq = currentFreq;
@@ -51,18 +67,21 @@ void Synth::process(float* out, size_t nFrames) {
float sampleOut = 0.0f; float sampleOut = 0.0f;
bool triggered = false; bool triggered = false;
bool once = false; bool once = false;
// generate n samples for the output buffer
for(size_t i = 0; i < nFrames; i++) { for(size_t i = 0; i < nFrames; i++) {
// process all active voices and mix their produced samples
float mix = 0.0f; float mix = 0.0f;
for(size_t j = 0; j < voices_.size(); j++) { for(size_t j = 0; j < instruments_.size(); j++) {
bool temp = false; bool temp = false;
//if(!voices_[j].isActive()) continue; if(!instruments_[j]->isActive()) continue;
mix += voices_[j].process(temp); mix += instruments_[j]->process(temp);
if(j == lowestVoice) triggered = 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] = sampleOut;
out[2*i+1] = sampleOut; out[2*i+1] = sampleOut;
@@ -75,25 +94,24 @@ void Synth::process(float* out, size_t nFrames) {
scope_->spinlock(false); scope_->spinlock(false);
} }
Voice* Synth::findFreeVoice() { Instrument* Synth::findFreeVoice() {
for(Voice& v : voices_) { for(Instrument* i : instruments_) {
if(!v.isActive()) { if(!i->isActive()) {
return &v; return i;
} }
} }
return nullptr; return nullptr;
} }
Voice* Synth::findVoiceByNote(uint8_t note) { Instrument* Synth::findVoiceByNote(uint8_t note) {
for(Voice& v : voices_) { for(Instrument* i : instruments_) {
if(v.isActive() && v.note() == note) { if(i->isActive() && i->note() == note) {
return &v; return i;
} }
} }
return nullptr; return nullptr;
} }

View File

@@ -1,11 +1,14 @@
#pragma once #pragma once
#include "ConfigService.hpp" #include "config/ConfigService.hpp"
#include "config/AudioConfig.hpp"
#include "LoggerService.hpp" #include "LoggerService.hpp"
#include "NoteQueue.hpp" #include "NoteQueue.hpp"
#include "Voice.hpp"
#include "Scope.hpp" #include "Scope.hpp"
#include "Filter.hpp"
#include "Instruments/Instrument.hpp"
#include "InstrumentFactory.hpp"
#include <vector> #include <vector>
@@ -21,18 +24,21 @@ public:
private: private:
Voice* findFreeVoice(); Instrument* findFreeVoice();
Voice* findVoiceByNote(uint8_t note); Instrument* findVoiceByNote(uint8_t note);
std::vector<uint8_t> sustainedNotes_; std::vector<uint8_t> sustainedNotes_;
Filter filter_;
// voices // voices
static constexpr size_t MAX_VOICES = 32; size_t maxVoices;
std::array<Voice, MAX_VOICES> voices_; std::vector<Instrument*> instruments_;
ConfigService* config_; ConfigService* config_;
AudioParams engineConfiguration_;
LoggerService* logger_; LoggerService* logger_;
ScopeBuffer* scope_;; ScopeBuffer* scope_;
NoteQueue* noteQueue_; NoteQueue* noteQueue_;
InstrumentFactory instrumentFactory_;
}; };

View File

@@ -1,34 +0,0 @@
#include "Voice.hpp"
Voice::Voice(ConfigService* config, LoggerService* logger) :
config_(config), logger_(logger) {
instrument_ = Instrument(config_, logger_);
}
void Voice::noteOn(uint8_t midiNote, float velocity) {
note_ = midiNote;
instrument_.noteOn(noteToFrequency(midiNote), velocity);
}
void Voice::noteOff() {
instrument_.noteOff();
}
bool Voice::isActive() {
return instrument_.isActive();
}
float Voice::process(bool& scopeTrigger) {
return instrument_.process(scopeTrigger);
}

View File

@@ -1,42 +0,0 @@
#pragma once
#include <stdint.h>
#include "Instrument.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
class Voice {
public:
Voice() = default;
Voice(ConfigService* config, LoggerService* logger);
~Voice() = default;
void noteOn(uint8_t midiNote, float velocity);
void noteOff();
bool isActive();
float process(bool& scopeTrigger);
uint8_t note() { return note_; }
float frequency() { return noteToFrequency(note_); }
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));
}
uint8_t note_ = 0;
float velocity_ = 1.0f;
bool active_ = false;
ConfigService* config_;
LoggerService* logger_;
Instrument 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,42 @@
#pragma once
#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