Signal Routing
Overview
Signal Routing
refers to the process of taking
the output signal of a unit generator, and transporting
it to be used as the input signal of another unit
generator.
Due to the lower-level nature of sndkit, routing is a bit more involved compared to other softare modular synthesis environments. But, with a little bit of time, working with signals in sndkit becomes second nature.
A brief summary of how sndkit patches work
A "modular patch" constructed in sndkit has a more formal
name in the CS world: a directed acyclic graph, or DAG.
To make these graphs, sndkit uses a library called
graforge
.
In a typical modular environment, one creates a bunch of nodes, and then strings them together with cables. The library then analyzes those connections and determines which order to render the nodes. If A modulates B modulates C, C can't be computed without first computing A and B. In Graforge, this intermediate representation of nodes and connections is skipped, and render order list is populated directly. So, from above: a sndkit program would explicitely compute A, take A and use it to compute B, then take B and compute C. The render order is explicit rather than implicit. This takes some getting used to, but it makes for a much simpler codebase.
Signals are rendered in tiny chunks at time. These chunks
are known as buffers
. Graforge uses a fixed-size
buffer pool
that unit generators can read and write to.
A buffer stack
interface is built on top of the buffer
pool as a way to manage signals.
Routing a signal in sndkit boils down to writing to a buffer, and making sure that particular buffer can be read by another unit generator later before it gets overwritten by another signal.
Serial Routing Using The Stack
Most signal routing tends to be done in serial
, where
signals go right into eachother. An oscillator going
into a filter going into a gain control is an example
of a serial connection.
For serial connections, the stack is often invisible:
butlp [saw [mtof [add [sine 6 0.25] 60]] [param 0.5]] 300
This patch is very serial. A signal that gets generated immediately gets used by the next unit generator. Under the hood, generated signals are being pushed onto the stack, and then immediately popped by the next unit generator.
Taking advantage of the 'zz' operator, one can break this patch out into multiple lines:
# LFO for vibrato
sine 6 0.25
# bias vibrato to MIDI note 60 (middle C)
add zz 60
# convert from MIDI to frequency
mtof zz
# use frequency to control sawtooth
blsaw zz
# lowpass filter the sawtooth
butlp zz 300
The 'zz' operator tells sndkit to pop the last item on the stack as the parameter rather than use a parameter.
Sometimes, there will be situations where a patch is nearly serial, but has signal that wants to get used more than once. For that, operations like dup/swap can come in handy, which those familiar with Forth will recognize.
The dup
operation will "duplicate" (a link, not
a deep copy) the last item
on the stack. And "swap" will take the last two items
on the stack and swap their positions.
This patch below uses dup/swap to make a clock signal
generated by metro
to control an envelope and a
random number generator for frequency in a sine wave:
metro 1
# stack: m
dup
# stack: m m
env zz 0.001 0.001 0.1
# stack: m e
swap
# stack: e m
trand zz 200 800
# stack: e t
sine zz 0.5
# stack: e s
mul zz zz
dup
and swap
are the only stack operations currently
implemented. There are two main reasons for this. One is
that the stack abstraction used in sndkit holds other
types other than buffers and implementing other operations
would be complicated. The second reason is that excessive
stack operations are discouraged. In sndkit,
they are easy to mess up, and difficult to debug. For
complex signal routing situations, buffer holding and
registers are recommended.
Buffer holding and Registers
Most non-trivial patches will need to use a signal more than once. The most common way to do this is through the use of holding buffers and storing their address in register space.
Every audio-rate cable in graforge contains inside
of it a buffer, which is (usually) from the buffer pool.
By default, this buffer is released automatically when
it done being used, allowing it to be available to be
written to again. Buffer holding
is an operation that
explicitly tells the buffer not to be returned to the pool
to be re-used. While held, the signal in the buffer
can be read as many times as needed without being
overwritten. Any held buffers must be unheld, otherwise
this could result in the buffer being unretrievable.
The hold
command will hold the buffer inside the
last cable on the stack.
After being held, a buffer is usually stored in a register
for retrieval later via regset
. There are 16 registers
available for use. To retrieve the signal in a register,
use regget
.
When a signal is done being used, unhold
is called.
The following patch belows holds and stores a clock signal generated by metro into register 0, which is then use to control an envelope and a triggerable random number generator
metro 2
hold zz
regset zz 0
regget 0
env zz 0.001 0.001 0.1
regget 0
trand zz 200 500
sine zz 0.5
mul zz zz
regget 0
unhold zz
Using Cabnew
It is possible to use up all the buffers in the buffer pool in the patch. A request for a buffer when there are no buffers available will cause an error. This is occasionally referred to as "stressing out the buffer pool".
As patches grow in size, the likelihood of this
happening can increase.
To mitigate this problem, it is possible to dynamically
allocate new buffers on the fly with cabnew
.
cabnew
will take in an input signal, and make
a copy of it inside of an allocated buffer.
The following example takes an LFO, copies it to
an allocated buffer via cabnew, and then uses
the signal to modulate frequency, reverb size,
and filter cutoff. NOTE: hold
and unhold
will be ignored since the buffer is not from
the buffer pool. It is good practice to keep them
in, because forgetting to use them when you actually
need them can cause hard to trace bugs and
resource leaks in patches.
# create an LFO
sine 0.2 1
# cabnew it: save it and store it in a buffer
cabnew zz
# will be ignored, but a good practice
hold zz
regset zz 0
# modulate the frequency
biscale [regget 0] 40 500
blsaw zz
# modulate the filter cutoff
butlp zz [biscale [regget 0] 1000 200]
mul zz [dblin -6]
dup
dup
# modulate reverb size
bigverb zz zz [biscale [regget 0] 0.98 0.8] [param 10000]
drop
mul zz [dblin -10]
dcblocker zz
add zz zz
wavout zz test.wav
# also ignored
unhold [regget 0]
computes 10
Creating sends/throws
Sends or "throws" allow signals to be summed together
and sent into an effect processor. This works by
intializing and holding a cable, and then using cabmix
to mix signals into that cable.
mix
takes in the following parameters: the input
signal to be mixed, the cable to mix into, and the mix
amount.
The following patch demonstrates a send to reverb, with post-fader gain reduction on the dry signal applied to give a sense of distance.
The send cable is initialized with zero and held in register 0. The dry signal (a clocked modal resonator filter is then dup'd and mixed into the send cable in register 0 via mix. After the mix (post-fader), the signal is attenuated.
A similar dry signal is also mixed in to the send cable at a different level.
The reverb reads from the send cable in register 0.
# create reverb send cable
hold [zero]
regset zz 0
# sound object 1
metro [rline 1 30 3]
modalres zz 500 100
# copy sound object 1 and send to reverb
dup
mix zz [regget 0] [dblin -10]
mul zz [dblin [biscale [sine 0.3 1] -24 0]]
# sound object 2
metro 1
tdiv zz 4 0
modalres zz 2000 300
# copy sound object 2 and send to reverb
dup
mix zz [regget 0] [dblin -3]
mul zz [dblin -10]
# add sound objects 1 and 2 to make dry signal
add zz zz
# retrieve send cable, and process with reverb
regget 0
dup
bigverb zz zz 0.93 8000
drop
dcblocker zz
# add wet + dry signals
add zz zz
# attenuate everything by 3dB
mul zz [dblin -3]
# release buffer the send cable back to the pool
unhold [regget 0]
# write to WAV file
wavout zz test.wav
# compute 10 seconds of audio
computes 10