Compare commits

...

28 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
cfc8cb2b51 tweaks 2026-06-14 12:48:42 -05:00
397e1fe7dc midi checkpoint 2026-06-13 22:04:27 -05:00
e9fff9691b add visual scope 2026-06-13 19:31:46 -05:00
61 changed files with 1993 additions and 419 deletions

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

View File

@@ -67,9 +67,12 @@ $ ./build/sonobulus
``` ```
## Configurations ## 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 ## 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. 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 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. 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

@@ -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

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,22 +7,17 @@
#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;
for(const char* validFlag : LogFlagStrings) { for(const char* validFlag : LogFlagStrings) {
@@ -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,10 +7,11 @@
#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"
#include "synth/MidiController.hpp"
int main(int argc, char* argv[]) { int main(int argc, char* argv[]) {
@@ -21,29 +22,28 @@ 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);
Synth synth(&config, &logger, nullptr, &queue); MidiController midi(&config, &logger, &queue);
Synth synth(&config, &logger, &scopeBuffer, &queue);
// audio synthesizer doohickey // audio synthesizer doohickey
AudioEngine audioEngine = AudioEngine(&config, &logger, &synth); AudioEngine audioEngine = AudioEngine(&config, &logger, &synth);
audioEngine.start(); audioEngine.start();
// attach backend gui components // attach backend gui components
//Scope scope; //scope.setBuffer(&scopeBuffer);
qmlRegisterType<TimerComponent>("AppDemo", 1, 0, "TimerComponent"); qmlRegisterType<TimerComponent>("AppDemo", 1, 0, "TimerComponent");
//qmlRegisterSingletonInstance("AppDemo", 1, 0, "Scope", &scope);
qmlRegisterType<Scope>("AppDemo", 1, 0, "Scope"); qmlRegisterType<Scope>("AppDemo", 1, 0, "Scope");
// attach backend services to qml
engine.rootContext()->setContextProperty("keyboardController", &keyboard); engine.rootContext()->setContextProperty("keyboardController", &keyboard);
engine.rootContext()->setContextProperty("scopeBuffer", &scopeBuffer);
// load qml // load qml
//engine.loadFromModule("sonobulus", "Main");
//engine.load(QUrl("qrc:/Main.qml"));
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,39 +0,0 @@
#include "Instrument.hpp"
#include "string"
Instrument::Instrument(ConfigService* config, LoggerService* logger) :
config_(config), logger_(logger) {
}
void Instrument::noteOn(float frequency, float velocity) {
phaseIncrement_ = 2.0f * pi * frequency / sampleRate_;
envelope_ += 0.01f; // so it triggers as active
active_ = true;
}
void Instrument::noteOff() {
active_ = false;
}
bool Instrument::isActive() {
return (envelope_ > 0.0f);
}
float Instrument::process(bool& scopeTrigger) {
if(active_ && envelope_ < 1.0f) envelope_ += 0.01f;
if(!active_ && envelope_ > 0.0f) envelope_ -= 0.01f;
phase_ += phaseIncrement_;
if(phase_ > 2.0f * pi) phase_ -= 2.0f * pi;
if(!isActive()) return 0.0f;
return sin(phase_) * envelope_;
}

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

@@ -0,0 +1,56 @@
#include "Instrument.hpp"
#include "string"
Instrument::Instrument(ConfigService* config, LoggerService* logger, float sampleRate, AudioParams* params) :
config_(config), logger_(logger), sampleRate_(sampleRate), params_(params) {
}
void Instrument::startNote(uint8_t midiNote, float velocity) {
note_ = midiNote;
noteOn(noteToFrequency(midiNote), velocity);
}
void Instrument::noteOn(float frequency, float velocity) {
phaseIncrement_ = 2.0f * pi * frequency / sampleRate_;
envelope_ += 0.01f; // so it triggers as active
active_ = true;
}
void Instrument::noteOff() {
active_ = false;
}
bool Instrument::isActive() {
return (envelope_ > 0.0f);
}
float Instrument::process(bool& scopeTrigger) {
if(active_ && envelope_ < 1.0f) envelope_ += 0.01f;
if(!active_ && envelope_ > 0.0f) envelope_ -= 0.0004f;
if(!isActive()) return 0.0f;
phase_ += phaseIncrement_;
if(phase_ > 2.0f * pi) {
phase_ -= 2.0f * pi;
scopeTrigger = true;
}
// float sample = sin(phase_);
targetSample = phase_ / pi - 1.0f; // saw
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

@@ -4,14 +4,25 @@
#include <iostream> #include <iostream>
#include <chrono> #include <chrono>
MidiController::MidiController(NoteQueue& queue) : noteQueue_(queue) { MidiController::MidiController(ConfigService* config, LoggerService* logger, NoteQueue* queue) :
config_(config), logger_(logger), noteQueue_(queue) {
try { try {
#ifdef WIN32
midiIn_ = std::make_unique<RtMidiIn>(RtMidi::WINDOWS_MM);
#else
midiIn_ = std::make_unique<RtMidiIn>(RtMidi::LINUX_ALSA); midiIn_ = std::make_unique<RtMidiIn>(RtMidi::LINUX_ALSA);
#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);
} }
// TODO: this still doesnt work on windows
#ifdef WIN32
openDefaultPort();
#else
openPort(1);
#endif
} }
MidiController::~MidiController() { MidiController::~MidiController() {
@@ -19,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;
} }
@@ -43,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;
} }
} }
@@ -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 == 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);
@@ -100,7 +128,7 @@ void MidiController::handleMessage(const std::vector<unsigned char>& msg) {
void MidiController::noteOn(uint8_t note, uint8_t vel) { void MidiController::noteOn(uint8_t note, uint8_t vel) {
sustainedNotes_.erase(note); sustainedNotes_.erase(note);
noteQueue_.push({ noteQueue_->push({
NoteEventType::NoteOn, NoteEventType::NoteOn,
static_cast<uint8_t>(note), static_cast<uint8_t>(note),
vel / 127.0f, vel / 127.0f,
@@ -114,7 +142,7 @@ void MidiController::noteOff(uint8_t note) {
sustainedNotes_.insert(note); sustainedNotes_.insert(note);
return; return;
} }
noteQueue_.push({ noteQueue_->push({
NoteEventType::NoteOff, NoteEventType::NoteOff,
static_cast<uint8_t>(note), static_cast<uint8_t>(note),
0.0f, 0.0f,

View File

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

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

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

View File

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

View File

@@ -2,21 +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); // stop all voices that are currently playing this note
if(v != nullptr) v->noteOff(); for(Instrument* i : instruments_) {
if(i->isActive() && i->note() == event.note) {
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();
v = findFreeVoice(); if(i != nullptr) i->startNote(event.note, event.velocity);
if(v != nullptr) v->noteOn(event.note, event.velocity);
} }
} }
@@ -28,59 +50,67 @@ void Synth::process(float* out, size_t nFrames) {
handleNoteEvent(noteEvent); handleNoteEvent(noteEvent);
} }
// size_t lowestVoice = 0; // find lowest frequency voice for scope triggering
// float lowestFreq = 100000.0f; size_t lowestVoice = 0;
// for(size_t i = 0; i < voices_.size(); i++) { float lowestFreq = FLT_MAX;
// if(!voices_[i].isActive()) continue; for(size_t i = 0; i < instruments_.size(); i++) {
// float currentFreq = voices_[i].frequency(); if(!instruments_[i]->isActive()) continue;
// if(currentFreq < lowestFreq) { float currentFreq = instruments_[i]->frequency();
// lowestVoice = i; if(currentFreq < lowestFreq) {
// lowestFreq = currentFreq; lowestVoice = i;
// } lowestFreq = currentFreq;
// } }
}
scope_->spinlock(true);
float sampleOut = 0.0f; 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++) { 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;
// if(scope_) scope_->push(sampleOut); if(scope_) scope_->push(sampleOut);
// if(triggered && !once) { if(triggered && !once) {
// scope_->setTrigger(i); scope_->setTrigger(i);
// once = true; once = true;
// } }
}
scope_->spinlock(false);
} }
Instrument* Synth::findFreeVoice() {
} for(Instrument* i : instruments_) {
if(!i->isActive()) {
Voice* Synth::findFreeVoice() { return i;
for(Voice& v : voices_) {
if(!v.isActive()) {
return &v;
} }
} }
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_ = nullptr; 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,41 +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_; }
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

@@ -54,6 +54,9 @@ ApplicationWindow {
width: 600 width: 600
height: 300 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 // this timer triggers a constant update on the scope's canvas, calls its draw() each time
Timer { Timer {
@@ -63,6 +66,8 @@ ApplicationWindow {
repeat: true repeat: true
onTriggered: scope.update() onTriggered: scope.update()
} }
buffer: scopeBuffer
} }
} }

View File