Skip to content

leovandriel/csynth

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CSynth logo

CSynth

A simple synth in C.

Usage

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

Tutorial

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

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, 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.

UI

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

MIDI

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.

I/O

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.

How it works

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.

Dependencies

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.

Development

To run tests:

./test

Example run specific test:

./test sine

FAQ

Why C?

Because it didn't seem like a good idea at the time.

License

MIT

About

A simple synth in C

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published