Timeline#

Summary

In this tutorial, we’ll see how to schedule musical events along a global transport timeline.

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

Tone.js offers the possibility to schedule with precision sound or musical events along a global timeline. By analogy, this is very similar to how we use the arrangement view in a classic DAW to position audio or MIDI clips and/or draw automation curves.

import ipytone

Transport#

The Tone.js global timeline is exposed in Python with the Transport singleton class, which may be accessed via ipytone.transport:

t = ipytone.transport

Important

Like the audio context used by ipytone and Tone.js, the Transport timeline is exposed globally in the front-end. Consequently, if two or more notebooks with independent kernels are open in the same JupyterLab tab, they will all act on the same timeline.

Playback state, position, bpm, time signature…#

The playback state of the timeline is controlled by the start(), pause() and stop() methods.

t.start().stop("+1")
Transport()

The position property can be used to move the current “cursor” along the timeline. It accepts different kinds of values, e.g.,

  • a string in the form of "Bars:Beats:Sixteenths"

  • a string like "4m" (four measures from the beginning of the timeline) or “8n” (8-notes)

  • a float (number of seconds from the beginning of the timeline)

See the Tone.js Wiki for more details.

t.position
0.0

It is possible to define a limited segment along the timeline that will be played in loop:

t.loop = True
t.loop_start = "4:0:0"
t.loop_end = "8:0:0"

Transport has also properties for setting the BPM or the time signature:

t.bpm
Param(value=120.0, units='bpm')
t.time_signature
4

Basic scheduling#

Basic event scheduling can be done either using Transport with callbacks or using context managers provided by ipytone.

Let’s first create an instrument

synth = ipytone.Synth(volume=-5).to_destination()

Using callbacks#

Callbacks must accept one time argument (in seconds) and usually implement in their body all the events that we want be triggered at that time (e.g., instrument note, parameter automation, etc.).

def callback(time):
    synth.trigger_attack_release("C4", "16n", time=time)

Then we can pass it to one of the schedule(), schedule_repeat() and schedule_once() methods of Transport, e.g.,

# schedule the call repeatedly at every 4th bar note
event_id = t.schedule_repeat(callback, "4n")
t.start().stop("+2m")
Transport(loop=True, loop_start='4:0:0', loop_end='8:0:0')

Those schedule methods return an event id that can be used later to remove it from the transport timeline with the clear(), method, e.g.,

t.clear(event_id)
Transport(loop=True, loop_start='4:0:0', loop_end='8:0:0')
# no scheduled event, no sound
t.start().stop("+2m")
Transport(loop=True, loop_start='4:0:0', loop_end='8:0:0')

Important

Ipytone doesn’t handle those callbacks like you might expect, i.e., like functions called at each of the scheduled times. Instead, ipytone uses some tricks internally to reconstruct (more-or-less) equivalent callbacks in the front-end before passing it to Tone.js scheduling functions.

As a consequence, Python callbacks have some limitations compared to Tone.js / JS callbacks. More specifically, the Python code inside a callback will be executed only once. Here is a bad example that won’t behave as we’d like:

import random

def callback(time):
    # This will randomly choose one note once for all repeated events!!
    note = random.choice(["C4", "G4", "A4"])
    synth.trigger_attack_release(note, "16n", time=time)

There’s also very limited support for making operations with the time argument (currently, only addition is supported).

Using contexts#

For convenience, the same scheduling operations can be achieved using context managers. For example:

with ipytone.schedule_once("4n") as (time, event_id):
    synth.trigger_attack_release("A4", "16n", time=time)
    synth.trigger_attack_release("A5", "16n", time=time + "16n")
t.start().stop("+2m")
Transport(loop=True, loop_start='4:0:0', loop_end='8:0:0')

Note that unlike the example above, the two note triggers here are scheduled only once so they won’t be re-triggered after stopping and restarting the transport

# no scheduled event, no sound
t.start().stop("+2m")
Transport(loop=True, loop_start='4:0:0', loop_end='8:0:0')

Advanced scheduling#

Ipytone also exposes Tone.js event classes for more advanced and handy scheduling.

Event#

Event is the base class of all events and accepts a callback that has two arguments: time and a value (i.e., a pitch or a note).

def event_clb(time, value):
    synth.trigger_attack_release(value, "16n", time=time)
event = ipytone.Event(callback=event_clb, value="A4")

It can then be started / stopped anywhere along the transport timeline and can even be looped:

event.loop = True
event.loop_start = "4:0:0"
event.loop_end = "8:0:0"
event.start()
Event(loop=True, loop_start='4:0:0', loop_end='8:0:0')

Important

Events won’t fire unless the Transport is started.

t.start()
Transport(loop=True, loop_start='4:0:0', loop_end='8:0:0')

Event also provides some other properties to control its playback rate and to randomize it.

# play it 4x faster
event.playback_rate = 4
# the probability to trigger the event
event.probability = 0.8
# apply a small random offset on the trigger time
event.humanize = 0.2

Call cancel to remove it from the transport timeline:

event.stop()
event.cancel()
Event(loop=True, loop_start='4:0:0', loop_end='8:0:0')

Loop#

Loop is a simplified event that is looped by default at a user-defined interval. Like in basic scheduling, it accepts a callback with one time argument.

loop = ipytone.Loop(callback=callback, interval="8n")
loop.start()
Loop(interval='8n')
loop.stop()
Loop(interval='8n')
loop.cancel()
Loop(interval='8n')

Part#

Part is a sequence of events that can be handled just like an Event (i.e., it is a music partition). It accepts a callback with two time and note arguments.

Important

While Tone.js accepts any arbitrary value type for the second argument, ipytone only accepts an instance of Note. The way ipytone handles Python callbacks (i.e., not “real” callbacks) imposes this restriction.

def part_clb(time, note):
     synth.trigger_attack_release(
         note.note, note.duration, time=time, velocity=note.velocity
     )

In the Part constructor, events may be specified either with instances of Note (which contain information about the actual note, time, duration and velocity) or an equivalent dictionary.

part = ipytone.Part(
    callback=part_clb,
    events=[
        ipytone.Note(0, "A4", duration="16n"),
        {"time": "8n", "note": "B4", "duration": "8n", "velocity": 0.5},
        ipytone.Note("2n", "E4", duration="16n"),
    ]
)
part.start()
Part()

Although the single events of a Part cannot be accessed directly, they can be further updated using the add, at, at, remove and clear methods.

part.add(ipytone.Note("16n", "A3", velocity=0.2))
Part()
part.at("2n", ipytone.Note("2n", "E5", duration="4n"))
part.clear()
Part()
part.stop()
Part()

Sequence#

Sequence is an alternative to Part where the note events are evenly spaced at a given subdivision.

seq = ipytone.Sequence(
    callback=event_clb,
    events=["A4", "C4", "B4", "A5"],
    subdivision="8n",
)

A sequence is looped by default.

seq.start()
Sequence(loop=True, loop_start=0.0, loop_end=0.0)

Events may be nested (the interval within a nested array corresponds to the parent subdivision divided by the length of the nested array).

seq.events = ["A4", ["C4", "E4", "D4"], "B4", "A5"]

Blank intervals may be defined by None array elements:

seq.events = ["A4", ["C4", "E4", "D4"], "B4", None]
seq.stop()
Sequence(loop=True, loop_start=0.0, loop_end=0.0)

Pattern#

Pattern is like an arpeggiator, it cycles trough an sequence of notes with a given pattern.

pat = ipytone.Pattern(
    callback=event_clb,
    values=["C3", "E3", "G3"],
    pattern="upDown",
)
pat.start()
Pattern(interval='8n', pattern='upDown')
pat.pattern="up"
pat.stop()
Pattern(interval='8n', pattern='up')

Dispose events#

Like audio nodes, events may also be disposed.

event.dispose()
loop.dispose()
part.dispose()
seq.dispose()
pat.dispose()
Pattern(disposed=True, interval='8n', pattern='up')

We’ve reached the end of this tutorial. Let’s cancel all scheduled events, stop the transport and dispose the synth.

t.cancel()
t.stop()
synth.dispose()
Synth(disposed=True)