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 float
s) 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:
void generate( /* other arguments */ , float * out, int how_many, int out_stride )
out
is the output buffer.
how_many
is the number of samples to generate.
out_stride
is the stride parameter for the output buffer.
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.
Example: Sum and Product Generators
(These generators are available in
multiplyAdd.h
and
multiplyAdd.c
.)
void generate_add( float * in1, int in1_stride, float * in2, int in2_stride, float * out, int how_many, int out_stride )
By convention, we always name the generator functions beginning with "generate", and we use underbars between words in the name.
Could you write this function?
How about this one?
void generate_mult( float * in1, int in1_stride, float * in2, int in2_stride, float * out, int how_many, int out_stride )
The generate_mult
function could be used to
apply envelope to a signal like this:
float * env; float * input; float * out; int len; /* the length of the three buffers */ generate_mult( env, 1, input, 1, output, len, 1 );
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.
void generate_thingie( float * out, int how_many, int out_stride ) { /* need a temporary buffer float TEMP[ BUF_SIZE ]; */ generate_1( TEMP, how_many, 1 ); /* can use the output buffer as the other temporary buffer, why? */ generate_2( out, how_many, out_stride ); /* is this cool or what? */ generate_mult( TEMP, 1, out, out_stride, out, how_many, out_stride ); }
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.
void generate_linenv( linenv_info * info, float * in, int in_stride, float * out, int how_many, int out_stride )
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:
linenv_info * create_linenv_info( float rise, float dur, float decay )
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?
Here's my version:
typedef struct { float value; /* the current envelope value */ float dval; /* the change per-sample in the current envelope value */ int segtime; /* the remaining number of samples in this segment */ int numSegs; /* the number of segments in this linear envelope */ int curSeg; /* the number of the current envelope segment */ unsigned int * durs; /* segment lengths in samples */ float * amps; /* segment ending amplitudes */ } linenv_info;
And how should generate_linenv
be implemented?
If it helps, asssume that you have a function advance_env
that updates the state in the linenv_info
structure to
advance the envelope to the next segment.
Here's my version. It is completely general with regard to the number of segments.
void generate_linenv( linenv_info * info, float * in, int in_stride, float * output, int howmany, int stride ) { int n = howmany; while ( n-- > 0 ) { /* compute the output sample */ *output = *in * info->value; /* advance buffer pointers */ in += in_stride; output += stride; /* update the envelope */ --info->segtime; info->value += info->dval; while ( info->curSeg < info->numSegs && info->segtime == 0 ) { advance_env( info ); } } }
The full implementation of linenv
is available in the
source file linenv.c
and the header is linenv.h
.
You should be able to modify it to construct and implement a linear envelope of an arbitrary number of segments.
Here is a program that applies a linear envelope to a single stream of samples read from standard input in blocks of N samples and writes the enveloped samles to standard output.
env
is a pointer to linenv_info
created
using create_linenv_info
.
do { /* read samples into buffer */ n = readN( buffer, N ); /* apply the envelope */ generate_linenv( env, buffer, 1, buffer, n, 1 ); /* dump them out */ for ( k = 0; k < n; ++k ) { printf( "%f ", buffer[ k ] ); } } while( n == N );