Timing control - davidpanderson/Numula GitHub Wiki

Time coordinate systems

"Score time" is the coordinate system in which note durations are originally specified. The unit is a 4/4 measure; e.g., 1/4 is a quarter note.

"Performance time" is the system in which notes are performed. The unit is seconds; i.e. real time.

Each note has a start time and duration (sounding time) in each system:

n.time             # score time
n.dur
n.perf_time        # performance time
n.perf_dur

Numula supports three classes of timing adjustment, with different musical uses.

  • Tempo control: the performance times of note starts and ends are changed according to a 'tempo function, which can vary linearly or exponentially. The tempo function can include pauses before and/or after particular times. Tempo functions are represented as PFTs.

  • Articulation control: Note durations (in either score time or performance time) can be scaled or set to particular values, to express legato, portamento, and staccato. You can do this in various ways, including continuous variation of articulation using a PFT.

  • Time shifting. Notes can be shifted - moved earlier or later - in performance time. Generally the duration is changed so that the end time of the note remains fixed. Other notes are not changed. There are various functions for doing this. For example, you can "roll" a chord with specified shifts for each chord note. You can specify, using a PFT, a pattern of shifts for creating "agogic accents" in which melody notes are played slightly after accompaniment notes.

These adjustments can be layered. For example, you could use several layers of tempo adjustment, followed by time shifting. The only constraint is that adjustments to score time must precede adjustments to performance time.

In most cases these adjustments can be described by shorthand notations. Use these in preference to the lower-level interfaces described here.

Adjusting tempo with a PFT

You can vary the tempo of some or all notes in a Score using a PFT. The value of the PFT is a multiplicative factor that can be thought of as beats per minute. The PFT segments are typically Linear or ExpCurve. An additional PFT primitive is available:

from numula.nuance import *

Delta(dt: float, after=False)

This inserts a pause of dt seconds at the current PFT time. If after is True, the pause occurs after notes that begin at this time; otherwise, before.

To adjust the tempo of a set of notes:

Score.tempo_adjust_pft(
    pft: PFT, t0: float, selector: Selector=None, normalize=False, bpm=True
)

This applies the tempo adjustment defined by the given PFT, starting at score time t0, to the selected notes. If no selector is given, the adjustment is also applied to pedal events during the domain of the PFT.

The value of the PFT is in units of beats per minute.

This is typically used to set the overall (time-varying) tempo of a piece. Additional fluctuations can be layered on top of this. If "normalize" is True, the adjustment is scaled so that the adjusted notes synch up with other notes at the end of the period. This can be used, for example, to apply rubato to right hand notes without modifying the left hand.

The same units (beats per minute) are used in specifying these additional fluctuations, but their meaning is different: 120 means speed up by a factor of two, 30 means slow down by a factor of two.

Example:

Score.tempo_adjust_pft(
    [
        Linear(60, 120, 4/4),
        Delta(.1),
        Linear(120, 60, 4/4)
    ],
    normalize=True,
    selector=lambda n: 'rh' in n.tags
)

causes the right hand notes (tagged with 'rh') to speed up and slow down over 2 measures, with a slight pause in the middle, synching up with other notes at the end.

As an example, consider the following from Chopin's 1st Nocturne:

images/chopin.png

We can use Numula to play the 11 against 6 precisely, but that sounds robotic. Instead, we use tempo_adjust_pft() with normalize=True to speed up and then slow down the RH notes, and add some small pauses, while not changing the LH. The source code is here; the audio result is here.

Pauses

To add individual pauses:

Score.pause_before(t: float, dt: float, connect=True)

Add a pause of dt seconds before score time t. In other words, add dt to the start time of notes at or after t. If connect is true, earlier notes that end at or after t are elongated; e.g. legato is preserved.

Score.pause_after(t: float, dt: float)

Add a pause of dt seconds after score time t. Notes that start at t are elongated.

Score.pause_before_list(ts: list[float], dts: list[float])

ts is a list of score times, and dts is a same-sized list of gap durations. Insert gaps of those durations before those times. This is the same as a sequence of pause_before(... connect=False) calls, but it's more efficient because the score is traversed just once.

Time shifts

You can move notes earlier or later in time according to the value of a PFT usings:

Score.time_shift_pft(pft: PFT, selector: Selector=None)

where selector is a selector function.

Rolled chords

Score.roll(
    t: float,
    offsets: list[float],
    is_up: bool=True,
    selector: Selector=None
)

offsets is a list of time offsets. These offsets are added to the performance start times of selected notes that start at score time t. Note durations are modified so that their end times remain the same.

If is_up is true, offsets are applied from bottom pitch upwards; otherwise from top pitch downward.

You can use the NumPy linspace() function to generate evenly-spaced lists, e.g.

import numpy as np
ns.roll(t, np.linspace(-.5, .1, 6))

does a roll with 6 offsets ranging from -.5 to .1.

Rolling a chord may cause it to collide with adjacent notes. You can prevent this with pause_before() or pause_after().

General timing functions

Score.t_adjust_list(offsets: list[float], selector: Selector)

offsets is a list of time offsets (seconds). They are added to the start times of notes satisfying the selector, in time order.

Score.t_adjust_notes(offset: float, selector: Selector)

The given time offset (seconds) is added to the start times of all notes satisfying the selector.

Score.t_adjust_func(func: NoteToFloat, selector: Selector):

For each note satisfying the selector, the given function is called with that note, and the result is added to the note's start time.

Random time perturbation

Score.t_random_uniform(
    min: float, max: float, selector: Selector=None
):
Score.t_random_normal(
    stddev: float, max_sigma: float=2, selector: Selector=None
):

These functions change the start time of selected notes by a random amount; the end time is not changed. For t_random_uniform(), the offset is chosen from a uniform distribution between min and max. For t_random_normal(), the offset is chosen from a normal distribution with mean zero and the given standard deviation. Offsets with sigma > max_sigma are not used.

Articulation

If you use textual notation to specify a score, each note's duration is the time until the next note. In other words, the default is perfect legato. You can add articulation - staccato, portamento, etc. - by adjusting note durations, in either score or performance time.

Adjusting the durations of selected notes by a constant factor

The following functions let you change note durations:

Score.perf_dur_abs(dur: float, selector: Selector=None)
Score.perf_dur_rel(factor: float, selector: Selector=None)

perf_dur_abs() sets the duration of the selected notes to the given value; pref_dur_rel() multiplies the duration by the given factor.

Adjusting the durations of selected notes by a function

Score.perf_dur_func(func: NoteToFloat, selector: Selector=None)

This calls the given function to compute the duration based on note attributes.

Adjusting the durations of selected notes with a PFT

You can adjust the articulation of a set of notes using a PFT.

Score.perf_dur_pft(pft: PFT, t0: float, selector: Selector=None, rel=True)
  • pft is a PFT that describes time-varying articulation according to rel
  • t0: the score time when the adjustment begins
  • selector: an optional selector
  • rel: if True, the duration of a note at time T is multiplied by the value of the PFT at T. If False, the duration is set to the value of the PFT.

For example:

Score.perf_dur_pft(
    [Linear(.1, 1.2, 4/4)],
    0, lambda n: 'rh' in n.tags
)

varies the articulation of right-hand notes linearly from staccato to legato over a 4/4 measure, starting at the beginning of the score.

Adjusting the score-time durations of selected notes

The above primitives adjust performance time. You can adjust note durations in score time using:

Score.score_dur_abs(dur: float, selector: Selector=None)
Score.score_dur_rel(factor: float, selector: Selector=None)

score_dur_abs() sets the duration of the selected notes to the given value; score_dur_rel() multiplies the duration by the given factor.

Score.score_dur_func(func: NoteToFloat, selector: Selector=None)

This calls the given function to compute the duration based on note attributes. For example:

Score.score_dur_func(lambda n: n.dur-1/16, lambda n: n.dur>1/8)

shortens all notes longer than an eighth by a sixteenth; i.e. it leaves a small gap until the next note.

Setting note score-time durations with a repeating pattern

Score.dur_pattern(dur_array: list[float], t0: float, t1: float)

sets the duration of notes with score times in [t0, t1) to the values in dur_array, cycling through these values indefinitely.