Signal Routing

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 unholdwill 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 cabmixto 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