Manual
SignalOperators is composed of a set of functions for generating, inspecting and operating over signals. Here, a "signal" is represented as a number of frames: each frame contains some number of channels (e.g. left and right speaker) with a sampled value (e.g. Float64
) for each channel. The values are sampled regularly in time (e.g. every 100th of a second, or 100 Hz); this is referred to as the frame rate.
Key concepts
There are several important concepts employed across the public interface. Let's step through one of the examples from the homepage (and README.md), which demonstrates most of these concepts.
sound1 = Signal(sin,ω=1kHz) |> Until(5s) |> Ramp |> Normpower |> Amplify(-20dB)
This example creates a 1 kHz pure-tone (sine wave) that lasts 5 seconds. Its amplitude is 20 dB lower than a signal with unit 1 power.
There are a few things going on here: piping, the use of units, lazy evaluation, infinite length signals and unspecified frame rates.
Piping
Almost all of the operators in SignalOperators can be piped. This means that instead of passing the first argument you can pipe it using |>
. For example, the two statements below have the same meaning.
sound1 = Signal(sin,ω=1kHz) |> Until(5s)
sound1 = Until(Signal(sin,ω=1kHz),5s)
The use of piping makes it easier to read the sequence of operations that are performed on the signal.
Units
In any place where a function needs a time or a frequency, it can be specified in appropriate units. There are many places where units can be passed. They all have a default assumed unit if a plain number without units is passed. The default units are seconds, Hertz, and radians as appropriate for the given argument.
sound1 = Signal(sin,ω=1kHz)
sound1 = Signal(sin,ω=1000)
Each unit is represented by a constant you can multiply by a number (in Julia, 10ms == 10*ms). To make use of the unit constants, you must call using SignalOperators.Units
. This exports the following units: frames
, kframes
, Hz
, kHz
s
, ms
, rad
, °
, and dB
. You can just include the ones you want using e.g. using SignalOperators.Units: Hz
, or you can include more by adding the Unitful
package to your project and adding the desired units from there. For example, using Unitful: MHz
would include mega-Hertz frequencies (not normally useful for sound signals). Most of the default units have been re-exported from Unitful
. However, the frames
unit and its derivatives (e.g. kframes
) are unique to the SignalOperators package. They allow you to specify the time in terms of the number of frames: e.g. at a frame rate of 100 Hz, 2s == 200frames
. Other powers of ten are represented for frames
, (e.g. Mframes
for mega-frames) but they are not exported (e.g. you would have to call SignalOperators.Units: Mframes
before using 20Mframes
).
You can find the available powers-of-ten for units in Unitful.prefixdict
Note that the output of functions to inspect a signal (e.g. duration
, framerate
) are bare values in the default unit (e.g. seconds or Hertz). No unit is explicitly provided by the return value.
Decibels
You can pass an amplification value as a unitless or a unitful value in dB
; a unitless value is not assumed to be in decibels. Instead, it's assumed to be the actual ratio by which you wish to multiply the signal. For example, Amplify(x,2)
will make x
twice as loud while Amplify(x,2dB)
will increase the amplitude by two decibells.
Lazy Evaluation
To ensure efficient signal generation, signal operators are lazy: no computations are performed until the actual signal data is requested. This lazy quality is reflected in the captilization of the operators: conceptually the operators define some new signal object which can be used to generate frames of data based on the input signal or signals.
To request evaluation of a lazy signal you can use an array constructor: Array
, AxisArray
, DimensinoalArray
or SampleBuf
, or you can call the more general methods sink
or sink!
. The result of sink
is itself a (non-lazy) signal. You can always specify the return type of sink
, but by default it tries to maintain the same representation of the signal or signals used as input, favoring the earlier arguments over later arguments. For example sink(SampleBuf(rand(10,2),10) |> Mix(1)) isa SampleBuf
.
The function sink
can also write data to a file. To store the five second signal in the above example to "example.wav" we could write the following.
sound1 |> ToFramerate(44.1kHz) |> sink("example.wav")
In this case sound1
had no defined frame rate, so we must specify one using ToFramerate
.
Non-lazy operators
If you prefer the result of an operator to be non-lazy, so you don't have to call sink
first, you can make use the lower case versions of the operators. These operators do not allow for piping, as this would typically be quite inefficient. If you want to combine multiple operators, they should normally be evaluated lazily.
Infinite lengths
Some of the ways you can define a signal lead to an infinite length signal. To allow for calls to sink
, you have to specify the length, using Until
. For example, when using Signal(sin)
, the signal is an infinite length sine wave. That's why, in the example above, we use Until
to specify the length, as follows.
Signal(sin,ω=1kHz) |> Until(5s)
Infinite lengths are represented as the value inflen
(e.g. when calling nframes
). This has overloaded definitions of various operators to play nicely with ordering, arithmetic etc...
Unspecified frame rates
You may notice that the above signal has no defined frame rate. Such a signal is defined by a function, and can be sampled at whatever rate you desire. If you add a signal to the chain of operations that does have a defined frame rate, the unspecified frame rate will be resolved to that same rate (see signal promotion, below).
Signal promotion
A final concept, which is not as obvious from the examples, is the use of automatic signal promotion. When multiple signals are passed to the same operator, and they have a different number of channels or different frame rate, the signals are first converted to the highest fidelity format and then operated on. This allows for a relatively seamless chain of operations where you don't have to worry about the specific format of the signal, and you won't loose information about your signals unless you explicitly request a lower fidelity signal format (e.g. using ToChannels
or ToFramerate
).
Signal generation
There are four basic types that can be interpreted as signals: numbers, arrays, functions and files. Internally the function Signal
is called on any object passed to a function that operates on a signal; you can call Signal
yourself if you want to specify more information. For example, you may want to provide the exact frame rate the signal should be interpreted to have.
Numbers
A number is treated as an infinite length signal, with unknown frame rate.
1 |> Until(1s) |> ToFramerate(10Hz) |> sink == ones(10)
Arrays
A standard array is treated as a finite signal with unknown frame rate.
rand(10,2) |> ToFramerate(10Hz) |> sink |> duration == 1
An AxisArray
, DimesnionalArray
or SampleBuf
(from SampledSignals
) is treated as a finite signal with a known frame rate (and is the default output of sink
)
using AxisArrays
x = AxisArray(rand(10,1),Axis{:time}(range(0,1,length=10)))
framerate(x) == 10
Functions
A single argument function of time (in seconds) can be treated as an infinite signal. It can be also be a function of radians if you specify a frequency using ω
(or frequency
). See Signal
's documentation for more details.
Signal(sin,ω=1kHz) |> duration |> isinf == true
A small exception to this is randn
. It can be used directly as a signal with unknown frame rate.
randn |> duration == isinf
Files
A file is interpreted as an audio file to be loaded into memory. You must include the WAV
or LibSndFile
package for this to work.
using WAV
x = Signal("example.wav")
Signal inspection
You can examine the properties of a signal using nframes
, nchannels
, framerate
, and duration
.
Signal operators
There are several categories of signal operators: extending, cutting, filtering, ramping, and mapping.
Extending
You can extend a signal using Pad
or Append
. A padded signal becomes infinite by appending the signal by a repeated value, usually one
or zero
. You can append two or more signals (or Prepend
) so they occur one after another.
Pad(x,zero) |> duration |> isinf == true
Append(x,y,z) |> duration == duration(x) + duration(y) + duration(z)
You cannot append more than one new signal within a pipe. That is, the following will throw an error.
# Don't do this!
x |> Append(y,z)
This is because Append(y,z)
does not return a function to be piped (as Append(y)
does). It returns a signal with y
followed by z
. You can instead call this as follows.
# This will do what you want!
x |> Append(y) |> Append(z)
Cutting
You can cut signals apart, removing either the end of the signal (Until
) or the beginning (After
). The operations are exact compliments of one another.
Append(Until(x,2s),After(x,2s)) |> nframes == nframes(x)
Filtering
You can filter signals, removing undesired frequencies using Filt
.
Signal(randn) |> Filt(Lowpass,20Hz)
If you write using DSP
you will have to also write dB = SignalOperators.Units.dB
if you want to make use of the proper meaning of dB
for SignalOperators
: DSP
also defines dB
.
An unusual filter is Normpower
: it computes the root mean squared power of the signal and then normalizes each frame by that value.
Ramping
A Ramp allows for smooth transitions between 0 amplitude and the full amplitude of the signal. It is useful to avoid clicks in the onset or offset of a sound. For example, pure-tones are typically ramped when presented.
Signal(sin,ω=2kHz) |> Until(5s) |> Ramp
You can ramp only the start of a signal (RampOn
), or the end of it (RampOff
) and you can use ramps to create a smooth transition between two signals (FadeTo
).
General operators
The most general operator is OperateOn
. It works a lot like map
but automatically promotes the signals, as with all operators, and it pads the end of the signal appropriately, so different length signals can be combined. The output is always the length of the longest finite-length signal.
a = Signal(sin,ω=2kHz) |> Until(2s)
b = Signal(sin,ω=1kHz) |> Until(3s)
a_minus_b = OperateOn(-,a,b)
The function OperateOn
cannot itself be piped, due to ambiguity in the arguments, but you can use the shorter Operate
for these purposes.
a_minus_b = a |> Operate(-,b)
A number of shortcuts for OperateOn
exist, and these can be piped normally. There are shortcuts for addition (Mix
) and multiplication (Amplify
).
a_plus_b = a |> Mix(b)
a_times_b = a |> Amplify(b)
There are shortcuts to add or isolate channels, AddChannel
and SelectChannel
. These set the keyword argument bychannel
of OperateOn
to false
(see OperateOn
's documentation for details).