The aradine package provides a modular system for digital signal processing
with an emphasis on musical applications.
This section of the documentation describes the design and implementation of the
aradine
package.
The
aradine package works in terms of a standard
discretized sampled audio
model. Informally, the system generates a
buffer
of audio
samples
at a fixed
rate
determined by the underlying audio hardware.
A continuous signal in the
time domain
can be considered as a function from time to
amplitude. At any given time
t, we can evaluate a continuous time domain signal
A(t)
to obtain the
amplitude
of the signal
A
at time
t.
A
discrete signal can be considered as a
discretized form of a
continuous
signal. In digital audio systems, in modern computing environments, both
time and
amplitude
are treated as discrete values. Time is discretized by dividing it into a fixed
sample rate
(typically specified in
samples per second). Amplitude values are discretized by
expressing them as either machine integer values of a chosen size, or floating-point
values of a chosen size.
The
aradine package expresses amplitude values as 64-bit IEEE754 floating point
values.
As an example, a continuous signal
A
could be discretized as the discrete signal
Q
as follows:
The
sample rate of a discrete signal is the number of
samples
that comprise one second of the signal. The unit of measurement is
hertz (hz).
At the time of writing, most consumer audio hardware works in terms of a
48000 hz
sample rate.
The
sample period of a discrete signal is the length of time represented by one sample
in the signal. This is a function of the
sample rate
of the signal. In a signal with sample rate
r, the sample period
p
of the signal is
p = 1 / r.
For example, in a signal that has a
48000 hz
sample rate, the sample period is 1 / 48000 ≈ 0.00002083 seconds ≈ 20.83
microseconds.
The physical realities of digital audio hardware and software mean that audio signals
are not typically produced
one-sample-at-a-time. There are many different limitations at all parts of the hardware
and software stack that
ultimately mean that it is more efficient to produce a buffer of samples
less
frequently, than a single sample
more
frequently. At a configurable (but constant) rate, the audio hardware and software
will request a buffer of
samples from applications. The
buffer size
specifies the number of samples that must be produced in a single request, and is
typically configurable
(although most hardware will have a minimum buffer size that it can handle).
The
buffer duration of a buffer is simply the number of samples in the buffer
multiplied by the
sample period
of the samples.
Buffering increases latency in the system. If an audio system is configured such that
the
buffer duration
equals
10 milliseconds, then each time the system requests a buffer of samples, the system
is essentially saying "Produce the most recent 10 milliseconds of audio for playback
right now". This means that
a listener is necessarily going to hear the audio 10 milliseconds later than the events
that actually produced
it. There is, therefore, a tension between specifying a larger buffer size in order to
reduce the hardware and software overhead of producing the audio, and specifying a
smaller
buffer size in order to reduce the effective latency of the system.
For example, in a system with sample duration
P
and a buffer duration B, at
Time = 8, the listener is going to hear the contents of the sample
buffer SB0, even though the first sample in that buffer occurred at
Time = 0:
At the time of writing, on most consumer and professional hardware, typical buffer
sizes are in the
64-512
range. This implies a typical latency of around
2
milliseconds, which is mostly below the threshold of human perception.
A
frame index is an integer index that refers to a set of samples across a set of
buffers. A
frame index
is a non-negative
relative index, with a value of
0
referring to the first sample in a buffer.
As an example, assume a buffer size of 8, and three
signals S0,
S1, and
S2. At
Time = 16, a frame index of 4 refers to the sample
values in the buffers at
Time = 20:
The term frame comes from the general term
sampling frame
in DSP. In other systems, audio signals are often divided into multiple
channels. For example, stereo audio is divided into
left
and
right
channels. In an audio signal with
n
channels, the individual sample values in each of the channels at a given time are
collectively referred to as a
sampling frame
(or frame, in short). For example, in a stereo audio signal
A, the sample value
x
in the left channel at time 0, and the sample value
y
in the right channel at time
0
can be viewed as the frame (x, y) at time
0. Conceptually, the audio signal
A
can be seen as a function
A(t) = (p, q), where
t
is time, and (p, q) is the sampling frame at time
t. The aradine package only works with audio signals
that have a single channel, but re-uses the term frame to refer to samples across
coincident audio buffers.
Much of the practical aspect of digital signal processing is concerned with avoiding
buffer
underrun. This occurs when the operations required to produce a buffer of samples take longer
to execute
than the buffer duration. Essentially, the system has asked for a buffer of samples,
but it has taken the application code longer than this duration to actually produce
the buffer. Therefore, the
buffer of samples is not ready in time and the audio system has nothing to play! This
manifests as audible
glitches in the audio output.
In the
aradine package, the underlying audio system periodically requests that a
buffer
of samples be produced for each audio output in the system. We refer to this request
as the
audio callback. More precisely, we say that the underlying audio system periodically
calls the
aradine
audio callback, and the code in the
aradine
package referenced by the callback performs all of the required processing and then
writes out the resulting
buffer of samples.
The aradine package collects DSP code into instruments, and
instruments
can be connected together arbitrarily to form an
ensemble.
Within an
ensemble, the
ports
of the instruments are connected together to form a graph. A
target port may be
connected to at most one
source port at any given time. An
source
port may be connected to zero or more
target
ports at any given time. The
types
of ports must match; it is not permitted to try to connect a port of an
audio type to a
port of a
note type, for example.
Instruments have zero or more ports. A
port
may either be a
source
port or a target port. A
target
port
consumes
data, while a
source
port
produces
data.
Ports have a type. Currently, ports may have either an
audio
type or a note type.
A
target port
I
of an
audio
type will, at
audio callback
time, consume a buffer of audio samples that was produced by whatever
source
port
O
is connected to
I. If no
source
port is connected to
I, then
I
will be presented with a buffer of audio samples where every sample value is equal
to
0.0.
A
target port
I
of a
note
type will, at
audio callback
time, consume a buffer of
note events
that was produced by whatever
source port
O
is connected to
I. If no
source
port is connected to
I, then
I
will be presented with an empty buffer.
A
source port
O
of a
audio
type will, at
audio callback
time, produce a buffer of audio samples that is then consumed by each of the target
ports connected
to
O.
A
source port
O
of a
note
type will, at
audio callback
time, produce a buffer of
note events
that is then consumed by each of the target ports connected to
O.
An event is a discrete, typed, timestamped value delivered to an instrument.
An event may be one of the following base types:
A note-typed event may be one of the following types:
A configuration-typed event may be one of the following types:
Events are typically delivered to instruments via an
event buffer. An
event buffer
is an opaque buffer type that, given a
frame index, returns all events that occurred at that
frame index.
Instruments have zero or more parameters. A
parameter
is effectively a named and typed mutable variable, the value of which can be read
and/or modified (atomically)
at any time. Parameters have unique (within an instrument) integer
identifiers, and humanly-readable string
labels. Additionally, parameters that are of numeric types are declared with
minimum
and
maximum
value range constraints. All parameters also have defined
default
values.
A parameter may be of one of the following types:
The aradine package provides a command-line interface for performing tasks such as
managing the local inventory, checking instrument files, and etc. The base
aradine-cli
command is broken into a number of subcommands which are documented over the following
sections.
All subcommands accept a --verbose parameter that may be set to one of
trace, debug, info,
warn, or error. This parameter sets the lower bound for
the severity of messages that will be logged. For example, at debug verbosity, only
messages of severity debug and above will be logged. Setting the verbosity to
trace
level effectively causes everything to be logged, and will produce large volumes of
debugging output.
The
aradine-cli command-line tool uses
quarrel
to parse command-line arguments, and therefore supports placing command-line arguments
into a file, one argument
per line, and then referencing that file with
@. For example:
All subcommands, unless otherwise specified, yield an exit code of 0 on success, and
a non-zero exit code on failure.
help
- Show usage information for a command.
The help command shows the help for a given command.
info
- Display system information.
The
info
command displays information about the current system.
check
- Check an instrument file.
The
check
command checks a given instrument file for validity.
codegen
- Generate code for an instrument file.
The
codegen
command generates code from an instrument description.
install-instrument
- Install instruments into the local inventory.
The
install-instrument
command installs an instrument into the local inventory.
install-sample-map
- Install sample maps into the local inventory.
The
install-sample-map
command installs a sample map into the local inventory.
list-instruments
- List instruments in the local inventory.
The
list-instruments
command lists instruments installed in the local inventory.
list-sample-maps
- List sample maps in the local inventory.
The
list-sample-maps
command lists sample maps installed in the local inventory.
uninstall-instrument
- Uninstall instruments from the local inventory.
The
uninstall-instrument
command uninstalls an instrument from the local inventory.
uninstall-sample-map
- Uninstall sample maps from the local inventory.
The
uninstall-sample-map
command uninstalls a sample map from the local inventory.
version
- Show the application version.
The
version
command shows the application version.
The com.io7m.aradine.envelope.table1 module contains a table-based envelope
implementation.
The envelope supports a number of different interpolation functions to produce amplitude
values between
breakpoints. For a hypothetical breakpoint B at frame
frame(B)
with amplitude amplitude(B), and a hypothetical breakpoint
C
at frame frame(C) with amplitude
amplitude(C), where
frame(B) < frame(C), the interpolation function specified for
B
is used to produce interpolated amplitude values for all frames greater than
frame(B)
and less than frame(C).
The definitions below are explained in terms of a pair of breakpoints B
and C where frame(B) = 0,
amplitude(B) = 0, frame(C) = 1000,
and amplitude(C) = 1.
If a breakpoint B uses the interpolation function
CONSTANT_CURRENT, then all frames between
B
and C will have an amplitude equal to
amplitude(B).
If a breakpoint B uses the interpolation function
CONSTANT_NEXT, then all frames between
B
and C will have an amplitude equal to
amplitude(C).
If a breakpoint B uses the interpolation function
COSINE, then all frames between
B
and C will have amplitudes produced by a cosine interpolation of
amplitude(B)
and amplitude(C).
If a breakpoint B uses the interpolation function
EXPONENTIAL, then all frames between
B
and C will have amplitudes produced by an exponential interpolation of
amplitude(B)
and
amplitude(C).
This is equivalent to
linear interpolation
where the interpolation factor is the square of the normal linear factor.
If a breakpoint B uses the interpolation function
LINEAR, then all frames between
B
and C will have amplitudes produced by a linear interpolation of
amplitude(B)
and
amplitude(C).
If a breakpoint B uses the interpolation function
LOGARITHMIC, then all frames between
B
and C will have amplitudes produced by a logarithmic interpolation of
amplitude(B)
and
amplitude(C).
This is equivalent to
linear interpolation
where the interpolation factor is the square root of the normal linear factor.
The com.io7m.aradine.filter.biquad1 module contains biquad filter implementations.
Biquad filters can be composed to produce stronger filters. The output signal of one
filter is fed into the
input of another filter in a process known as cascading. The main filter implementation
included in the com.io7m.aradine.filter.biquad1 module is a filter of order
2
(specifically, it has two poles, and two zeroes). It would be theoretically possible
to use the same algorithm
with more filter coefficients to produce a single filter of order N, but in
practice it is better to
cascade
N / 2
filters of order 2; the resulting filter will be much more numerically stable and
less subject to issues such as those involving floating point precision.
For example, the following code uses four filters of order 2 to produce an
effective filter of order 8:
Biquad filters are relatively suitable for use in EQ applications, or when the filter
cutoff value is not
expected to be modulated over time. For rapidly modulating filter cutoff values, a
state variable filter
may be more suitable.
The com.io7m.aradine.filter.recursive1 module contains simple recursive filter
implementations.
The filter implementations provided in the
com.io7m.aradine.filter.recursive1
module are extremely simple 1-pole filters. They represent the bare minimum in terms
of filter implementations and
may be useful in terms of eliminating low frequency grounds hum from audio, or reducing
high frequency noise.
The com.io7m.aradine.filter.statevar1 module contains state variable filter
implementations.
The filter implementations provided in the
com.io7m.aradine.filter.statevar1
module are fairly direct translations of the hardware filters used on many analogue
synthesizers. They offer
good resolution at all frequencies, but can become unstable at certain frequencies
unless
oversampling
is used.
State variable filters accept separate Q and
C
parameters, where Q is a positive real value, and C is
a real value in the range [0, 1]. Filters may become unstable in the sense that
particular combinations of
Q
and C values may cause the filter to start oscillating and massively increase the
gain of the signal passing through it. This can be directly observed by running the
filter on a sample of white
noise, picking various Q and C values, and seeing if
the resulting output contains one or more frequency bands where the amplitude is greater
than or equal to
0.6. In an unfiltered white noise sample, every frequency band should have an
amplitude of roughly 0.5, so any band with a value much greater than this can
indicate that the filter has begun to self oscillate. The following diagram shows
an increasing
Q
downwards along the Y axis, and an increasing
C
rightwards along the X axis:
In the diagram, a green square indicates that no frequency band in the output had
an amplitude of greater than
or equal to 0.6, whilst a red square indicates that one or more frequency bands had
an amplitude of greater than or equal to
0.6. As should be fairly obvious, much of the parameter space is essentially
unusable. Fortunately, this problem can be mitigated through
oversampling; simply evaluate the filter multiple times per input sample, and discard
all but the output sample from the last evaluation.
Applying the exact same
Q and
C values as in
the
original diagram, but evaluating the filter four
times instead of one, yields the following output instead:
The resulting audio output is not drastically different than for the single sample
case, but it is clear from
the diagram that a much wider range of Q and
C
values have become usable.
The filter implementation is derived from the description given in
Musical Applications Of Microprocessors
by Hal Chamberlin (ISBN 0-8104-5768-7).
The entire
aradine API is documented the included
JavaDoc.