Quick Start
Hello, World
Here’s a quick "Hello, World" example that highlights the simplicity of the Q DSP Library: a delay effects processor.
The full example can be found here: example/delay.cpp. |
The example loads a pre-recorded wav file and plays it back with processing. The raw audio source will be played in the left channel while the delayed signal will be played in the right channel. Pretty much as straightforward as possible. The audio will be played using the currently selected audio device.
The final audio output can be heard below:
The DSP Code
// fractional delay (1)
q::delay _delay{350_ms, 44100};
// Mix the signal s, and the delayed signal (where s is the incoming sample) (2)
auto _y = s + _delay();
// Feed back the result to the delay (2)
_delay.push(_y * _feedback);
Normally, there will be a processing loop that receives the incoming samples,
s
. The code above are placed:
1 | Before the processing loop. |
2 | Inside inside the processing loop. |
Notes:
-
Typically, you encapsulate the code inside a class where
_delay
,_y
and_feedback
are member variables. -
44100 is the desired sampling rate.
-
_feedback is the amount of feedback desired (anything from 0.0 to less than 1.0, e.g. 0.85).
-
350_ms
is the delay duration.
Take note of 350_ms
. Here, we take advantage of C++ type safe
user-defined literals, instead of the usual float
or double
which can be unsafe when values of different units (e.g. frequency vs. duration) are mismatched. The Q DSP library makes abundant use of user-defined literals for units such as time, frequency and volume. For example, we use 24_dB
, instead of a unit-less 24
or worse, a non-intuitive, unit-less 15.8
—the gain equivalent of 24_dB
. Such constants also make the code very readable, another objective of this library.
Processors such as q::delay
are C++ function objects that can be composed to form more complex processors. For example if you want to filter the delayed signal with a low-pass filter with a 1 kHz cutoff frequency, you apply the q::lowpass
filter over the result of the delay:
q::lowpass _lp{1_kHz, 44100};
then insert the filter where it is needed in the processing loop:
// Add the signal s, and the delayed, low-pass filtered signal
auto _y = s + _lp(_delay());
Hello, Universe
Let us move on to a more elaborate example. How about a fully functional, bandwidth limited square wave synthesizer with ADSR envelope that controls an amplifier and a resonant filter and control the note-on and note-off using MIDI? Sounds good? This example is complete and self-contained in one .cpp file, kept as simple as possible to highlight the ease-of-use.
The full example can be found here: example/square_synth.cpp. |
In order to run this example, you will need a MIDI input device connected to your system, preferrably a keyboard. Make sure you have at least one installed. If you do not have a physical MIDI input device, you can install one of the MIDI onscreen keyboards. For example, MidiKeys is a small application for MacOS that presents a MIDI keyboard onscreen. |
Here’s a short video clip:
After building the program, make sure you have a MIDI keyboard connected before starting the application. At startup, the app will present you with a list of available MIDI hardware and will ask you what you want to use. Example:
================================================================================
Available MIDI Devices (ID : "Name" inputs/outputs):
0 : "Quantum 2626" 1/0
1 : "Code 61 USB MIDI" 1/0
2 : "Code 61 MIDI DIN" 1/0
3 : "Code 61 Mackie/HUI" 1/0
4 : "Code 61 Editor" 1/0
5 : "Quantum 2626" 0/1
6 : "Code 61 USB MIDI" 0/1
7 : "Code 61 MIDI DIN" 0/1
8 : "Code 61 Mackie/HUI" 0/1
9 : "Code 61 Editor" 0/1
================================================================================
Choose MIDI Device ID: 1
And then a list of audio devices to choose from. Example:
================================================================================
Available Audio Devices (ID : "Name" inputs/outputs):
1 : "MacBook Air Microphone" 1/0
2 : "MacBook Air Speakers" 0/2
3 : "Quantum 2626" 26/26
================================================================================
Choose Audio Device ID: 3
Take note that the demo is a console application. The Q library does not have a GUI, for good reason! We want to keep it as simple as possible. The GUI is taken cared of by other libraries (e.g. Elements).
After choosing the MIDI and Audio driver, the synth is playable. The synth is monophonic and responds to velocity only, for simplicity.
There are more demo applications in the example directory. After this quick tutorial, feel free to explore.
The Synthesizer
Here’s the actual synthesizer with the processing loop:
struct my_square_synth : q::audio_stream
{
my_square_synth(q::adsr_envelope_gen::config env_cfg, int device_id)
: audio_stream(q::audio_device::get(device_id), 0, 2)
, env(env_cfg, this->sampling_rate())
, filter(0.5, 0.8)
{}
void process(out_channels const& out)
{
auto left = out[0];
auto right = out[1];
for (auto frame : out.frames())
{
// Generate the ADSR envelope
auto env_ = env() * velocity;
// Set the filter frequency
filter.cutoff(env_);
// Synthesize the square wave
auto val = q::square(phase++);
// Apply the envelope (amplifier and filter) with soft clip
val = clip(filter(val) * env_);
// Output
right[frame] = left[frame] = val;
}
}
q::phase_iterator phase; // The phase iterator
q::adsr_envelope_gen env; // The envelope generator
q::reso_filter filter; // The resonant filter
q::soft_clip clip; // Soft clip
float velocity; // Note-on velocity
};
Our synth, a subclass of q::audio_stream
, sets up buffers for the input and output audio streams and presents those to our processing loop (the process
function above). In this example, we setup an audio stream with the selected device, no inputs and two (stereo) outputs:
audio_stream(q::audio_device::get(device_id), 0, 2)
The Oscillator
Behind the scenes, there’s a lot going on here, actually. But you will notice that emphasis is given to making the library very readable, easy to understand and follow, by breaking down complex tasks into smaller manageable tasks and using function composition at progressively higher levels, while maintaining simplicity and clarity of intent.
The synthesizer above is composed of smaller building blocks: fine grained C++ function objects. For example, here’s the square wave oscillator (bandwidth limited using poly_blep).
For now, we will skim over details such as the adsr_envelope_gen , phase , and phase_iterator , and this thing called poly_blep . The important point, exemplified here, is that we want to keep our building blocks as simple and minimal as possible. We will cover these in greater detail later.
|
The astute reader may notice that our square_osc
class does not even have state!
struct square_osc
{
constexpr float operator()(phase p, phase dt) const
{
constexpr auto middle = phase::middle();
auto r = p < middle ? 1.0f : -1.0f;
// Correct rising discontinuity
r += poly_blep(p, dt);
// Correct falling discontinuity
r -= poly_blep(p + middle, dt);
return r;
}
constexpr float operator()(phase_iterator i) const
{
return (*this)(i._phase, i._incr);
}
};
constexpr auto square = square_osc{};
Yeah, that’s the complete oscillator. That’s all there is to it!
The modern C++ savvy programmer will immediately notice the use of constexpr
, applied judiciously all throughout the library. Such modern C++ facilities allow the compiler to generate extremely efficient code, even those that are generated at compile time. That means, for this example, that one can build an oscillator at compile time if needed, perhaps with constant wavetable results stored in read-only memory.
Processing MIDI
The midi_processor
takes care of MIDI events. Your application will have its own MIDI processor that deals with MIDI events that you are interested in. For this simple example, we simply want to process note-on and note-off events. On note-on events, our MIDI processor sets `my_square_synth’s note frequency and velocity and triggers its envelope for attack. On note-off events, our MIDI processor initiates the envelope’s release.
struct my_midi_processor : midi::processor
{
using midi::processor::operator();
my_midi_processor(my_square_synth& synth)
: _synth(synth)
{}
void operator()(midi::note_on msg, std::size_t time)
{
_key = msg.key();
auto freq = midi::note_frequency(_key);
_synth.phase.set(freq, _synth.sampling_rate());
_synth.env.attack();
_synth.velocity = float(msg.velocity()) / 128;
}
void operator()(midi::note_off msg, std::size_t time)
{
if (msg.key() == _key)
_synth.env.release();
}
std::uint8_t _key;
my_square_synth& _synth;
};
The Main Function
In the main function, we instantiate my_square_synth
and my_midi_processor
. The synth constructor, in case you haven’t noticed yet, requires an envelope configuration (envelope::config
). Here, we provide our configuration. Take note that in this example, the envelope parameters are constant, for the sake of simplicity, but you can definitely have these controllable by the user by writing your own MIDI processor that deals with MIDI control change messages.
Again, take note of the abundant use of user-defined literals for units such as duration (e.g. 100_ms) and level (e.g. -12_dB).
auto env_cfg = q::envelope::config
{
100_ms, // attack rate
1_s, // decay rate
-12_dB, // sustain level
5_s, // sustain rate
1_s // release rate
};
my_square_synth synth{ env_cfg };
Then, we create my_midi_processor
, giving it a reference to my_square_synth
. We’ll also need a midi_input_stream
that receives the actual incoming MIDI messages from the chosen hardware.
q::midi_input_stream stream;
my_midi_processor proc{ synth };
Now we’re all set. We start the synth and enter a loop that exits when the user presses ctrl-c (in which case the running flag becomes false). In the loop, we give our MIDI processor a chance to process incoming MIDI events as they arrive from the MIDI stream:
synth.start();
while (running)
stream.process(proc);
synth.stop();
Copyright (c) 2014-2024 Joel de Guzman. All rights reserved. Distributed under the MIT License