Synth.jl Documentation

Synth.jl provides a library of signal generators and transformers in a highly compositional form suitable for musical applications. The Signal type is intended to model processes that evolve in time and which could be finite in extent.

For example sinosc(0.5, 250.0) is a sine wave oscillator that oscillates at 250Hz with an amplitude of 0.5, forever, and sinosc(0.5, 250.0 + sinosc(100.0, 250.0)) gives you a frequency modulated sine oscillation and you can impose an envelope on it like this – sinosc(adsr(0.5, 1.0), 250.0 + sinosc(100.0, 250.0)) – which makes it finite in duration. Most operators can be composed in this manner, usually resulting in fairly efficient code as well. See the Models section for some other simple signal combinations such as Synth.Models.basicvocoder.

The above constructed finite extent signal can be rendered to a SampledSignals.SampleBuf using render like this –

v = render(sinosc(adsr(0.5, 1.0), 250.0 + sinosc(100.0, 250.0)))

(Pass an explicit duration if it's an infinite extent signal or use Synth.play.)

Signals can be rendered to SampleBuf buffers, to raw Float32 files or in real time to the computer's sound output using play. There is also some minimal support for stereo signals.

Combined with the bus mechanism, the notion of a process for producing both sound and audio events can be expressed quite nicely using this API. An example below -

using Synth

b = bus(60.0)
play(0.3 * b, 20.0)
nonsense_tests = [
         tone(ch([60,64,67]), 1.0),
         tone(ch(12 .+ [60,62,65,69]), 1.0),
         ping(72, 1.0),
         pause(0.5),
         loop(3, ping([67,72], 0.2)),
         tone(12 .+ [60, 62, 64, 65, 67, 69, 71, 72, 71, 69, 67, 65, 64, 62, 60], 1/16)
        ]
sched(b, track(nonsense_tests))
sleep(10.0)

The Signal type

Synth.SignalType
abstract type Signal end

A Signal represents a process that can be asked for a value for every tick of a clock. We use it here to represent processes that produce audio and control signals to participate in a "signal flow graph". While mathematically signals can be treated as though they were infinite in duration, signals are in practice finite in duration for both semantic and efficiency reasons (ex: managing voices as a constrained resource).

To construct signals and to wire them up in a graph, use the constructor functions provided rather than the structure constructors directly.

The protocol for a signal is given by two functions with the following signatures –

  • done(s :: Signal, t, dt) :: Bool
  • value(s :: Signal, t, dt) :: Float32

As long as you implement these two methods, you can define your own subtype of Signal and use it with the library.

The renderer will call done to check whether a signal has completed and if not, will call value to retrieve the next value. The contract with each signal type is that even if value is called for a time after the signal is complete, it should return sample value 0.0f0 or a value appropriate for the type of signal. This is so that done can be called per audio frame rather than per sample.

Addition, subtraction and multiplication operations are available to combine signals and numbers. Currently signals are single channel only.

Choice of representation

A number of approaches are used in computer music synth systems -

  • Blocks and wires paradigm: ... where blocks represent signal processing modules and wires represent connections and signal flow between these modules. Even within this paradigm there are different semantics to how the signal flow is handled, from asynchronous/non-deterministic, to fully deterministic flow, to buffer-wise computation versus sample-wise computation. The WebAudioAPI, for example, takes this approach and performs buffer-wise computation in blocks of 128 samples. It is rare to find a synth library that works sample-wise in this mode.

  • Functional: ... where signals are mathematical constructs that can be combined using operators to construct new signals. Usually this paradigm manifests as a textual programming language, like SuperCollider and Chuck (which has elements of the above approach too).

The approach taken in this library is to combine the notion of a signal with the computation that produces the signal - a rough analog of "constructive real numbers". In other words, a "signal" – i.e. a stream of values regularly spaced in time – is identified with a computation that produces the stream.

This permits manipulating signals like mathematical objects that can be combined, while modelling "signal flow" via ordinary functions in programming that call other functions recursively. Without further thought, this approach will only permit "signal flow trees", where the output of a processing step can only be fed into a single input due to the nature of function composition being used to construct the signal flow pattern. However, with the fanout operator, it becomes possible to reuse a signal as input for more than one processing block, extending the scope to include "signal flow DAGs". The feedback operator further extends this possibility through late binding of signal connections to permit loops in the graph, truly getting us "signal flow graphs" that can support feedback loops, albeit with a single sample delay.

The library exploits Julia's "optimizing just-ahead-of-time compilation" to describe each signal computation function in a per-sample fashion so that sample frames can be computed efficiently by the renderer. In other languages including AoT compiled languages like C, this combination of simplicity of API with high performance will be very hard to get. In dynamic languages, the function call overhead is even worse and not easily eliminated due to weak type systems. You'll notice how the rich type information about how a signal was constructed is maintained in the final result so that the renderer can compile it down to efficient code, often eliminating intermediate function calls.

Realtime usage necessitates some amount of dynamic dispatch, but it is still possible to mark boundaries whether the type knowledge can help create efficient code.

The end result of all this is that you can combine signals like ordinary math and expect complex signal flow graphs to work efficiently, even in realtime.

source

The definition of Signal treats signal generators and transformers like "values" which can be operated on using ordinary arithmetic +, - and *.

Models

Synth.Models.additiveFunction
additive(f0,
         amps :: AbstractVector{Union{Real,Signal}},
         detune_factor :: Signal = konst(1.0f0))

Simple additive synthesis. amps is a vector of Float32 or a vector of signals. f0 is a frequency value or a signal that evaluates to the frequency. The function constructs a signal with harmonic series based on f0 as the fundamental frequency and amplitudes determined by the array amps.

source
Synth.Models.basicvocoderMethod
basicvocoder(sig, f0, N, fnew; bwfactor = 0.2, bwfloor = 20.0)

A simple vocoder for demo purposes. Takes $N$ evenly spaced frequencies $k f_0$ and moves them over to a new set of frequencies $k f_{\text{new}}$ using a heterodyne filter. The bwfactor setting gives the fraction of the inter-frequency bandwidth to filter in. The bandwidth has a floor given by bwfloor in Hz.

source
Synth.Models.chirpMethod
chirp(amp, startfreq, endfreq, dur;
      shapename::Union{Val{:line},Val{:expon}} = Val(:line))

A "chirp" is a signal whose frequency varies from a start value to a final value over a period of time. The shape of the change can be controlled using the shapename keyword argument.

source
Synth.Models.fmFunction
fm(carrier, modulator, index, amp = konst(1.0f0))

Basic FM synth module. carrier is the carrier frequency that can itself be a signal. modulator is the modulation frequency and index is the extent of modulation. amp, if given decides the final amplitude. All of them can vary over time.

Example

play(fm(220.0f0, 550.0f0, 100.0f0), 5.0)
source
Synth.Models.ising2Method
ising2(f :: Signal, b1 :: Signal, b2 :: Signal, x1 :: Signal, x2 :: Signal, w12 :: Signal)

Experimental 2-qubit "Ising" model with controllable weights. Not very interesting at this point, but perhaps with more qubits it might get interesting as the number of frequencies that get mixed in will increase, producing a richer sound.

source
Synth.Models.snareMethod
snare(dur::Real; rng = MersenneTwister(1234))

Very simple snare hit where the dur is the "half life" of the snare's decay. Just amplitude modulates some white noise.

source
Synth.Models.toneMethod
tone(amp, freq, duration; attack_factor = 2.0, attack_secs = 0.005, decay_secs = 0.05, release_secs = 0.2)

A simple sine tone modulator by an ADSR envelope. amp is the amplitude of the sustain portion, freq is the Hz value of the frequency of the tone and duration is the duration in seconds of the sustain portion.

Envelope characteristics

  • attack_factor - the factor (usually > 1.0) that multiplies the amplitude value to determine the peak of the attack portion of the envelope.
  • attack_secs - the duration of the attack portion. This should be kept short in general.
  • decay_secs - the duration of the portion of the envelope where it decays from the peak attack value down to the sustain level.
  • release_secs - the "half life" of the release portion of the envelope. Over this time, the amplitude of the signal will decay by a factor of 2.
source

Index