Instruments#

Summary

In this tutorial, we’ll see how to create a (very) basic synthesizer from stratch using oscillators and envelopes. We’ll also give an overview of the instruments built in ipytone (copied from Tone.js).

Note: you can download this tutorial as a Jupyter notebook.

import ipytone
import matplotlib.pyplot as plt

Building a synth from scratch#

The most basic elements of a synthesizer are oscillators and envelopes. Let’s build a very simple synth using one oscillator and one amplitude envelope.

Oscillator#

Ipytone provides different oscillators (see Section Source of API reference) that can generate sound from basic waveforms modulated at a given frequency.

Let’s here use an Oscillator that we connect to the destination (speakers) so we can hear it.

osc = ipytone.Oscillator().to_destination()

By default, the type (shape) of the waveform is a sine. Ipytone Oscillators generally support other waveform types such as sawtooth, triangle, square, etc.

osc.type
'sine'

The frequency of the oscillator gives the note. It is possible to set it with a number (Hertz) or a string (note + octave), e.g.,

osc.frequency.value = "C4"

With the start() and stop() methods, this already gives a very basic synth that can trigger notes (beware of the volume of your speakers):

osc.start().stop("+0.5")
Oscillator()

That’s not very fancy, though. The audio signal changes abruptly when starting / stopping the oscillator, causing unpleasant “clicks”.

Envelope#

An envelope allows to smoothly modulate a signal over time with a curve that is generally characterized by 4 segments: attack, decay, sustain and release.

Ipytone provides the such an Envelope as well as subclasses for the two most common envelopes:

To prevent clicks when starting / stopping the oscillator, let’s connect it to an AmplitudeEnvelope:

# first disconnect the oscillator from the destination
osc.disconnect(ipytone.destination)

env = ipytone.AmplitudeEnvelope(sync_array=True)
osc.chain(env, ipytone.destination)
Oscillator()

It is possible to get or set the overall shape of the envelope using its attack, decay, sustain and release attributes, each synchronized with the front-end (more attribute allows to set the shape of the curve - linear, exponential, etc. - of each segment):

[env.attack, env.decay, env.sustain, env.release]
[0.01, 0.1, 1.0, 0.5]

Because we’ve set sync_array=True above when creating the envelope, we can get the whole envelope curve data in Python via its array attribute. It is useful for visualizing the envelope:

plt.plot(env.array);
_images/c851dba6d7cb838c774869358d113f8c3d0de689444d1ffa60b271d4270676e0.png

Let’s change the envelope shape a bit and see the result:

env.attack = 0.1
env.sustain = 0.5
plt.plot(env.array);
_images/f5cf2ff1c028208bdcbe8256450228e2bc2bb503362d6322ac7b88fcd02f72fe.png

Now that the oscillator is connected to the envelope, we shouldn’t hear any sound when starting the oscillator:

# no sound!
osc.start()
Oscillator()

We need to explicitly trigger the attack part of the envelope with trigger_attack():

env.trigger_attack()
AmplitudeEnvelope(attack=0.1, decay=0.1, sustain=0.5, release=0.5)

The oscillator signal will then modulate over the attack and decay part of the envelope until it reaches the sustain level.

To trigger the release part of the envelope, we need to call trigger_release():

env.trigger_release()
AmplitudeEnvelope(attack=0.1, decay=0.1, sustain=0.5, release=0.5)

Sometimes we want to trigger the release some specific time after having the triggered the attack. This can be done with the trigger_attack_release() method:

# trigger attack and trigger release after 1/2 second
env.trigger_attack_release(0.5)
AmplitudeEnvelope(attack=0.1, decay=0.1, sustain=0.5, release=0.5)

We won’t need the oscillator and the envelope below. Before moving on, let’s dispose them

osc.dispose()
env.dispose()
AmplitudeEnvelope(disposed=True, attack=0.1, decay=0.1, sustain=0.5, release=0.5)

Built-in instruments#

Ipytone provides a few built-in instruments (see Section Instrument of API reference) that perform basic or more advanced sound synthesis from a combination of connected nodes (oscillators, envelopes, etc.). Ipytone also provides a Sampler.

Let’s use the Synth here:

synth = ipytone.Synth()

Instruments behave like audio nodes, i.e., we can connect them to other nodes:

# connect synth to the speakers
synth.connect(ipytone.destination)
Synth()

All instruments can be played via a common interface including trigger_attack(), trigger_release() and trigger_attack_release() methods, e.g,

# trigger a "C4" note for 1/2 second
synth.trigger_attack_release("C4", 0.5)
Synth()

Each instrument may expose its own components. The Synth used here combines an oscillator and an amplitude envelope just like we did above.

synth.oscillator
OmniOscillator()
synth.envelope
AmplitudeEnvelope(attack=0.005, decay=0.1, sustain=0.3, release=1.0)
# finished now with synth
synth.dispose()
Synth(disposed=True)

Monophonic synths#

Monophonic synthesizers can only play one note at a time. They all have a portamento attribute, which allows smoothly sliding the frequency between two triggered notes.

Let’s create a MonoSynth:

msynth = ipytone.MonoSynth().to_destination()
# without portamento
msynth.trigger_attack_release("C3", 0.5)
msynth.trigger_attack_release("C5", 0.5, time="+0.25")
MonoSynth()
msynth.portamento = 0.3
# with portamento
msynth.trigger_attack_release("C3", 0.5)
msynth.trigger_attack_release("C5", 0.5, time="+0.25")
MonoSynth()
msynth.dispose()
MonoSynth(disposed=True)

Polyphonic synths#

PolySynth allows turning any monophonic synthesizer into a polyphonic synthesizer, i.e., an instrument that can play multiple notes at the same time.

Let’s create a polyphonic synth from a Synth:

psynth = ipytone.PolySynth(voice=ipytone.Synth, volume=-8).to_destination()

A list of notes can be passed to the trigger methods to play a chord:

psynth.trigger_attack_release(["C3", "C4", "E4", "G4"], 0.5)
psynth.trigger_attack_release(["G2", "G4", "B4", "D4"], 0.5, time="+0.5")
psynth.trigger_attack_release(["C3", "C5", "E5", "G5"], 0.5, time="+1")
PolySynth()

We can also pass a list of duration times to play the chord with some “expression”:

psynth.trigger_attack_release(["C3", "C4", "E4", "G4"], [0.5, 0.7, 0.9, 1])
PolySynth()

It is possible to change the parameters of the polyphonic synth via its voice property, which returns a single instance of the mono synth. This instance is deactivated (it doesn’t make any sound), but changing the value of an attribute of one of its components will apply to all voices of the polyphonic synth.

psynth.voice.envelope.attack = 0.4
# play each chord with a slow attack
psynth.trigger_attack_release(["C3", "C4", "E4", "G4"], 0.5)
psynth.trigger_attack_release(["G2", "G4", "B4", "D4"], 0.5, time="+0.5")
psynth.trigger_attack_release(["C3", "C5", "E5", "G5"], 0.5, time="+1")
PolySynth()

Note

Changing the settings of some components of the PolySynth.voice may have no effect. This generally works with common components like the oscillator and the amplitude envelope.

In addition to trigger_attack(), trigger_release() and trigger_attack_release(), PolySynth provides a release_all() method that will trigger release for all the active voices

psynth.trigger_attack(["C3", "C4", "E4", "G4"])
psynth.trigger_attack(["G2", "G4", "B4", "D4"], time="+0.5")
psynth.trigger_attack(["C3", "C5", "E5", "G5"], time="+1")
PolySynth()
# release all chords triggered above
psynth.release_all()
PolySynth()

The maximum number of active voices is controlled by max_polyphony. When this number is reached, additional notes won’t be played.

psynth.max_polyphony = 3
# this will play only 3 notes!
psynth.trigger_attack_release(["C3", "C4", "E4", "G4"], 0.5)
PolySynth()

End of this tutorial!

psynth.dispose()
PolySynth(disposed=True)