A simple synth in C.
To get started, check out the examples folder. Each C file is
executable, e.g. to run beep.c
:
./examples/demo/beep.c
This requires GCC (or clang) and PortAudio
binaries to be installed. You can also use CSynth without PortAudio by replacing
play(..)
with write_mono(..)
, which writes to a WAV file:
./examples/demo/beep_wav.c
play output/beep.wav
Next, emulate a basic keyboard using the bottom row (Z, X, C, ..):
./examples/demo/keyboard_synth.c
Or, if you have a MIDI keyboard:
./examples/demo/midi_synth.c
To make music in CSynth, you can combine basic (mathematical) functions to create sounds, instruments, and compositions.
Let's create a single note with reverb. Start by writing a minimal C program that plays a 440 Hz sine wave:
#include "./src/func/all.h"
#include "./src/io/player.h"
int main(void)
{
return play(sine(A4));
}
Now run with (and stop by pressing Esc):
./examples/tutorial.c
Taking a closer look, there are three pieces here: the A4
constant represents
440 Hz, sine
generates a sine wave at that frequency, and play
samples the
sine function to your speakers.
To turn the continuous tone into a 0.3 second note, add a rectangular envelope:
func tone = sine(A4);
func note = rect_(0, .3, tone);
play(note);
This adds rect
, which multiplies tone
by 1 during the interval [0, 0.3] and
0 elsewhere, resulting in a 0.3 second A4 note. We also use func
, which
indicates a function variable tone
, allowing us split things across two lines.
Notice the underscore _
. By default, all functions take other functions as
arguments. By appending _
to the name, you can pass in a number instead.
Next, place the note in a 1.5 second loop:
func tone = sine(A4);
func note = rect_(0, .3, tone);
func looped = loop_(1.5, note);
play(looped);
Finally, add reverb (interval 0.4s, decay 0.2):
func tone = sine(A4);
func note = rect_(0, .3, tone);
func looped = loop_(1.5, note);
func revved = reverb_(.4, .2, looped);
play(revved);
Or, to make it more compact:
play(reverb_(.4,.2,loop_(1.5,rect_(0,.3,sine(A4)))));
This should sound like tutorial.mp3.
To see more of what you can do with CSynth, take a look in examples/demo. To learn more about available functions, take a look in src/func and examples/func.
If you run into audio issues, run the following to get an overview of all audio devices:
./utils/inspect_audio.c
Functions are the building blocks of CSynth. They can be combined freely,
including nesting of function in unconventional ways. The tutorial started with
play(sine(A4))
, but you can also:
play(sine(mul(A4, sine_(1))));
Or even:
play(sine(mul(A4, sine(sine(sine_(1))))));
Here, there is no distinction between audio and control (AR vs KR). There are a few helper functions, like ar and kr that scale the input to respective domains:
play(sine(kr_scale(A4, sine_(1))));
The sine function has the _
suffix to allow the
argument to be a number instead of a function. Some function take multiple
arguments in which case you may want to mix functions and numbers. This is done
by wrapping the number in _(..)
, turning it into a function with that value
(see const). This is most often the case with functions
like mul, which can take any number of function arguments:
play(mul(sine(A4), sine_(1), _(.5)));
In many cases, it is helpful to check the implementation to see the available variations of a function, including helpful short-hands. Examples for mul:
mul_(.5, sine(A4))
mul(_(.5), sine(A4))
mul(_(.5), sine(A4), sine(B4))
mul_create(3, (func[]){_(.5), sine(A4), sine(B4)})
The latter opens the door to programmatic building of sound. For example, to synthesize the sound of a G chord on the guitar using add_create:
func chord[] = {G2, B2, D3, G3, B3, G4};
func notes[6];
for (int i = 0; i < 6; i++)
{
notes[i] = delay_(.1 * i, karplus_strong_(chord[i], .5));
}
func strum = add_create(6, notes);
play(strum);
This uses the Karplus–Strong method for string synthesis. To create specific sounds like that of strings, it is often necessary to go beyond combining existing functions. The easiest way to do this is to use wrap, which takes a C function as input:
double phone_filter(double input, void *context)
{
return round(input * 10) / 10;
}
int main(void)
{
return play(wrap(phone_filter, sine(A4), NULL));
}
This approach has its limits, and in most cases the best approach is to implement a function using func_create. For example, the above can also be implemented as:
double phone_filter(size_t count, Gen **args, Eval *eval, void *context)
{
double input = gen_eval(args[0], eval);
return round(input * 10) / 10;
}
int main(void)
{
func tone = sine(A4);
func phone = func_create(NULL, phone_filter, NULL, NULL, 0, NULL, FuncFlagNone, tone);
return play(phone);
}
While this looks more convoluted, it does come with the full range of available arguments and configuration. These are all explained in detail in func.h.
Another way to learn more about func_create
is to look at the implementation
of basic functions like saw and
lpf. Some of the notation is slightly different from
the examples, e.g. sources use Func *
instead of func
and const_()
instead
of _()
. This is because the examples use short-hand helpers, while the source
avoids those. Other than that, there is no specific distinction and example code
can easily make its way into the function library.
While running play
, function parameters can be controlled with the keyboard
and displayed in the terminal. This is based on an event
system that broadcasts keyboard input and state changes.
Keyboard input is read by terminal and broadcasted to gating functions. The most basic example of this is mute, which multiplies input by 1 and 0 alternating at every space bar press.
play(mute(' ', sine(A4)));
To emulate a key on a keyboard or drum pad, use the trigger function, which resets to initial state on every key press:
play(trigger(' ', decay_(.5, sine(A4))));
There is also stepper to control with the up/down keys and selector to switch between functions:
play(selector(' ', sine(A3), sine(A4), sine(A5)));
These controls can be combined to create a keyboard:
play(keyboard(trigger, decay_(.5, sine(C4))));
Key strokes can also be recorded and replayed with
track and replay. Key
events are managed by keyboard_event. To exit, the
Esc
key is directly handled by play
.
To visualize the state of controls, basic display functionality is included for switches and numerical values.
display_keyboard(' ', "select frequency");
return play(selector(' ', sine(A3), sine(A4), sine(A5)));
While most keys can be indicated by a char, some (e.g. arrow keys) require escape codes. To see how keys map to numbers, run:
./utils/inspect_terminal.c
CSynth supports MIDI input through PortMidi. This requires linking with PortMidi binaries and using MIDI-variant of certain functions.
A basic example of this is a MIDI keyboard based on the sawtooth function:
func tone = saw(C0);
func synth = midi_keyboard(1, tone);
return play_midi(synth);
Here, play_midi
connects to the default MIDI device and
midi_keyboard listens for MIDI events on
channel 1
. Be sure to include midi_player.h and link
with portmidi
.
To make the sound more interesting, let's add a unison effect, using 5 voices and 1% detune:
func tone = unison_(5, .01, saw(C0));
func synth = midi_keyboard(1, tone);
return play_midi(synth);
Controller
events give continuous control of a function input. For example, to
add a controlled unison effect:
func range = knob_(1, 70, 0, .02);
func tone = unison(5, range, saw(C0));
func synth = midi_keyboard(1, tone);
return play_midi(synth);
The knob function listens for controller events on channel 1 and number 70 and maps it to a range of 0.0 and 0.02. No more fiddling with numbers in code!
In the above example, you might need to use a different channel or control number. To get an overview of available MIDI devices and mapping of every key, knob, or pad, run:
./utils/inspect_midi.c
To learn more about how to use MIDI, take a look at the midi_keyboard example. To see all available controls, see controls. To learn more about how MIDI is implemented, see midi.h.
Most of the examples above use player to sample a function to the system audio buffer. A player takes care of setting up PortAudio, the sampler, the terminal, and it cleans things up before exiting the program. It comes in a few variants:
play(sine(A4));
play_duration(10, sine(A4)); // 10 seconds
play_stereo(sine(A4), sine(B4));
play_stereo_duration(10, sine(A4), sine(B4));
play_channels(4, (func[]){sine(A4), sine(B4), sine(C4), sine(D4)});
Instead of playing the audio, we can also write things to a wav file using write:
write_mono(10, "output/sine.wav", sine(A4));
write_mono_(10, sine(A4)); // writes to output/default.wav
write_stereo(10, "output/sine.wav", sine(A4), sine(A4));
write_channels(10, stdout, 4, (func[]){sine(A4), sine(B4), sine(C4), sine(D4)});
Under the hood, play
and write
use sampler, which
samples functions to an audio buffer. To implement a custom audio player:
Sampler *sampler = sampler_create(44100, channel_count, channels);
for (;;) {
sample_t buffer = ...;
sampler_sample(sampler, sample_count, buffer);
}
sampler_free(sampler);
Lastly, functions that rely on sample data, like wav, use reader to load WAV data.
The func
is the primary building block, representing a function that outputs a
value over time. Almost all functions take another functions as input, allowing
the creation of complex sounds from primitives like sawtooth waves and
envelopes. These inputs can represent a signal that they transform (e.g. input
in loop()
) or a parameter that is used for generating a signal (e.g.
frequency
in saw()
).
By nesting functions, you can create a directed acyclic graph of functions. When
this is fed into the player
or writer
, the graph is traversed and
transformed into a tree of generators, together with a sample rate (as a time
delta
). The root generator then recursively samples the tree.
All of the above logic is defined in func.h and gen.h.
Almost all of CSynth is built with zero dependencies, besides the standard library. This includes all the audio synth functions file system I/O, allowing for the full range of audio synthesis.
Only functionality to stream to and from audio and midi devices relies on additional dependencies. We use PortAudio for playback, included from player.h, and PortMidi for midi, included from midi_player.h. Outside of examples, these headers are not included elsewhere.
To run tests:
./test
Example run specific test:
./test sine
Why C?
Cross platform and embedded systems.
MIT