Teaching Unit Generators (TUGs)

TUGs is a collection of elementary sound synthesis modules that
I found useful when I taught an undergraduate elective course in
sound synthesis and processing for students of Electrical Engineering
and Computer Science.

These modules are not intended to be production-quality DSP code,
but they are reasonably efficient real-time implementations of
their respective functionality. They are intended to demonstrate a
clear and concise algorithm that is consistent with good design principles
for real-time audio signal processing programming in C code.

This document describes the common interface and programming
conventions used by the modules in this package. It is adapted
from the online course material I used when I used these
modules for teaching.

Objectives

You should understand the interface and conventions used
for writing the sound synthesis and processing modules in TUGs.

You should be able to implement a line segment envelope module
that adheres to these conventions for describing and using
sound synthesis and processing modules.

You should be able to write programs that use sound synthesis
and processing modules that follow our conventions to perform
simple signal processing tasks in a block processing scheme.

Unit Generators

We often try to build sound synthesis and processing tools
up from simple components. These components are often called
unit generators to emphasize their primitive and general
nature. Unit generators (like lots of Unix programs) typically
have a single, specific, elementary function. An example is a gain
unit, or a line segment envelope.

Unit generators have a single output, but they may have any number of
inputs. Some of these inputs represent audio signals, others
represent time-varying parameters.
They can also have static (constant) parameters.

In the example of a line segment envelope, the parameters are the
segment durations and vertex amplitudes. The input to the envelope
could be the signal to which the envelope is applied.

Some unit generators have no inputs, they only generate signals. A unit
generator that reads a samples file is an example. The line segment
envelope could also be written as a unit generator with no inputs,
it would simply generate the envelope amplitude waveform.

Writing Unit Generators

TUGs consists of a collection of unit generators written in C code.
To make them
easy to use, we adopt a common interface for all of these
generator functions. That means that we always express the inputs
and output and parameters in the same way, so that when we encounter
a new unit generator, we already know how to use it in C code.

All of our generators have a single output, some number
(maybe zero) of inputs, and often some sort of data structure
representing the state of the generator.

Output Specification

Since we often be generating and processing an unspecified
amount of sound, and we often want to hear it in real time
(as it is generated), we write all of our algorithms as block
processing functions. They will operate on a sequence of
blocks of samples, and each time we need more samples, we
call the function to fill another buffer with a specified
number of samples.

In the case of the line segment envelope, this means that we cannot
write a function that generates all of the samples in the envelope
waveform at once. The function we write will generate just
as many samples as we request, one block at a time.

When we request samples, we will pass a buffer (a pointer to an
array of floats) and the number of samples to generate
(usually the size of the buffer in sample frames).

The Stride Parameter

One problem that we would quickly encounter is the difficulty of
writing programs that synthesize multiple channels of sound (stereo).
This is a problem because each unit generator generates a single
stream of output.

There are several ways to address this problem. A bad one is to shift
the complexity of multi-channel synthesis into the unit generator, and
write each unit generator in such a way that it can synthesize any
number of channels. This allows each generator to perform synthesis
in multiple channels differently, but few generators will do anything
more than copy the signal to every channel.

Another solution is to generate samples in a temporary buffer and combine
them into the final output buffer using a mixing function. This might
require lots of extra buffers for a complex algorithm.

A common solution to this problem that is very efficient and adds
no complexity to the unit generators uses another parameter related
to the buffer, called stride.

The stride parameter is equivalent to the number of samples in each
frame of the buffer, or the number of channels. By passing a stride
parameter to the unit generator, we can tell it how many cells in the
output buffer to skip over between writing.

For example, if stride is 1, then the generator will advance by
one buffer cell each sample, and will write in consecutive buffer positions.
If stride is 2, however, the generator will only write to every other
cell in the buffer.

In code, this means that we can use a pointer into the buffer, and
advance it by stride:

/* out is a float * */
*out = /* next sample */ ;
out += stride; /* rather than ++out */

Since we want all of our generators to have a common interface,
well always pass them the same three final arguments:

Input Specification

Most algorithms will have some inputs that may vary
over time. They may even be generated by other unit generators.
It makes sense, therefore to represent those inputs using a
protocol similar to the one we used for the output buffer.

The only difference is that we do not need to specify the number
of samples again (how_many). Why? Because in a
block processing algorithm, it usually makes little sense for the
number of samples in the input and output streams to differ.
Therefore, we use a single parameter to specify the number of
samples written to the output buffer and consumed from all input
buffers.

A unit generator may have any number of inputs, and they will
be specified by pairs of arguments specifying the buffer and
its stride. Since we may be using auxiliary buffers, we cannot
be sure (and need not require) that all buffers will use the
same stride.

Another useful convention we adopt is this: we always
write our generators such that the input and output buffers
could be the same array. This means that we
step through the input and output buffers at the same rate,
and we never try to use earlier samples in the input
buffer to compute the current sample in the output buffer.
If we need to remember past samples, we have to store them
somewhere else.

Suppose we wanted to write a generator function that
multiplies the output of two other generator functions.

What if we just wanted to multiply some input signal by a
constant gain factor, like 0.5? How could we use
generate_mult? (Hint: use the stride parameter.)

Generator State

Many unit generators need a data structure associated
with them, storing implementation data. For example, a line
segment envelope generator needs to store the segment durations
and vertex amplitudes. It also needs to keep track of its
current position (remember, we generate the envelope a block
at a time, we need to remember our place between blocks).

By convention, we always pass these
structures (by pointer) as the first argument to a generator
function.

We name these structures by appending _info to the name
of the generator name.

Finally, by convention, we always provide functions for creating
and destroying these info structures. The creation function will
prepend create_ to the name of the structure, and will
have arguments that correspond to the parameters (not inputs) of the
generator, for example:

The destruction function will prepend delete_ to the name of
the structure, as in

void delete_linenv_info( linenv_info * info )

A lot of conventions, I know. But if we always do the same thing, then
its easy to write programs that combine these things, because we have
less to remember and understand.

Here's one final convention. Some algorithms will need to know the sample
rate. It is a nuisance to pass this as an argument all the time. So we
always declare a global float
variable SAMPLE_RATE in the file
with main(). Then in generator files, we can access it
through an extern declaration.

extern const float SAMPLE_RATE;

Now let's write the linenv generator. Start with the info
structure. What do we need to store in there?