Previous Section | Next Section | Table of Contents | Index | Title Page

Continuous Transformations and Time Warps

Nyquist transformations were discussed in the previous chapter, but all of the examples used scalar values. For example, we saw the loud transformation used to change loudness by a fixed amount. What if we want to specify a crescendo, where the loudness changes gradually over time?

It turns out that all transformations can accept signals as well as numbers, so transformations can be continuous over time. This raises some interesting questions about how to interpret continuous transformations. Should a loudness transformation apply to the internal details of a note or only affect the initial loudness? It might seem unnatural for a decaying piano note to perform a crescendo. On the other hand, a sustained trumpet sound should probably crescendo continuously. In the case of time warping (tempo changes), it might be best for a drum roll to maintain a steady rate, a trill may or may not change rates with tempo, and a run of sixteenth notes will surely change its rate.

These issues are complex, and Nyquist cannot hope to automatically do the right thing in all cases. However, the concept of behavioral abstraction provides an elegant solution. Since transformations merely modify the environment, behaviors are not forced to implement any particular style of transformation. Nyquist is designed so that the default transformation is usually the right one, but it is always possible to override the default transformation to achieve a particular effect.

Simple Transformations

The "simple" transformations affect some parameter, but have no effect on time itself. The simple transformations that support continuously changing parameters are: sustain, loud, and transpose.

As a first example, Let us use transpose to create a chromatic scale. First define a sequence of tones at a steady pitch. The seqrep "function" works like seq except that it creates copies of a sound by evaluating an expression multiple times. Here, i takes on 16 values from 0 to 15, and the expression for the sound could potentially use i. Technically, seqrep is not really a function but an abbreviation for a special kind of loop construct.

define function tone-seq()
  return seqrep(i, 16,
                osc-note(c4) ~ 0.25)
Now define a linearly increasing ramp to serve as a transposition function: define function pitch-rise() return sustain-abs(1.0, 16 * ramp() ~ 4) This ramp has a duration of 4 seconds, and over that interval it rises from 0 to 16 (corresponding to the 16 semitones we want to transpose). The ramp is inside a sustain-abs transformation, which prevents a sustain transformation from having any effect on the ramp. (One of the drawbacks of behavioral abstraction is that built-in behaviors sometimes do the wrong thing implicitly, requiring some explicit correction to turn off the unwanted transformation.) Now, pitch-rise is used to transpose tone-seq: define function chromatic-scale() return transpose(pitch-rise(), tone-seq())

Similar transformations can be constructed to change the sustain or "duty factor" of notes and their loudness. The following expression plays the chromatic-scale behavior with increasing note durations. The rhythm is unchanged, but the note length changes from staccato to legato: play sustain((0.2 + ramp()) ~ 4, chromatic-scale()) The resulting sustain function will ramp from 0.2 to 1.2. A sustain of 1.2 denotes a 20 percent overlap between notes. The sum has a stretch factor of 4, so it will extend over the 4 second duration of chromatic-scale.

If you try this, you will discover that the chromatic-scale no longer plays a chromatic scale. You will hear the first 4 notes going up in intervals of 5 semitones (perfect fourths) followed by repeated pitches. What is happening is that the sustain operation applies to pitch-rise in addition to tone-seq, so now the 4s ramp from 0 to 16 becomes a 0.8s ramp. To fix this problem, we need to shield pitch-rise from the effect of sustain using the sustain-abs transformation. Here is a corrected version of chromatic-scale: define function chromatic-scale() return transpose(sustain-abs(1, pitch-rise()), tone-seq())

What do these transformations mean? How did the system know to produce a pitch rise rather than a continuous glissando? This all relates to the idea of behavioral abstraction. It is possible to design sounds that do glissando under the transpose transform, and you can even make sounds that ignore transpose altogether. As explained in Chapter "Behavioral Abstraction", the transformations modify the environment, and behaviors can reference the environment to determine what signals to generate. All built-in functions, such as osc, have a default behavior.

The default behavior for sound primitives under transpose, sustain, and loud transformations is to sample the environment at the beginning of the note. Transposition is not quantized to semitones or any other scale, but in our example, we arranged for the transposition to work out to integer numbers of semitones, so we obtained a chromatic scale anyway.

Transposition only applies to the oscillator and sampling primitives osc, partial, sampler, sine, fmosc, and amosc. Sustain applies to osc, env, ramp, and pwl. (Note that partial, amosc, and fmosc get their durations from the modulation signal, so they may indirectly depend upon the sustain.) Loud applies to osc, sampler, cue, sound, fmosc, and amosc. (But not pwl or env.)

Time Warps

The most interesting transformations have to do with transforming time itself. The warp transformation provides a mapping function from logical (score) time to real time. The slope of this function tells us how many units of real time are covered by one unit of score time. This is proportional to 1/tempo. A higher slope corresponds to a slower tempo.

To demonstrate warp, we will define a time warp function using pwl:

define function warper()
  return pwl(0.25, .4, .75, .6, 1.0, 1.0, 2.0, 2.0, 2.0)
This function has an initial slope of .4/.25 = 1.6. It may be easier to think in reciprocal terms: the initial tempo is .25/.4 = .625. Between 0.25 and 0.75, the tempo is .5/.2 = 2.5, and from 0.75 to 1.0, the tempo is again .625. It is important for warp functions to completely span the interval of interest (in our case it will be 0 to 1), and it is safest to extend a bit beyond the interval, so we extend the function on to 2.0 with a tempo of 1.0. Next, we stretch and scale the warper function to cover 4 seconds of score time and 4 seconds of real time:
define function warp4()
  return 4 * warper() ~ 4




Figure 2: The result of (warp4), intended to map 4 seconds of score time into 4 seconds of real time. The function extends beyond 4 seconds (the dashed lines) to make sure the function is well-defined at location (4, 4). Nyquist sounds are ordinarily open on the right.


Figure 2 shows a plot of this warp function. Now, we can warp the tempo of the tone-seq defined above using warp4:

play warp(warp4(), tone-seq())
Figure 3 shows the result graphically. Notice that the durations of the tones are warped as well as their onsets. Envelopes are not shown in detail in the figure. Because of the way env is defined, the tones will have constant attack and decay times, and the sustain will be adjusted to fit the available time.




Figure 3: When (warp4) is applied to (tone-seq-2), the note onsets and durations are warped.


Abstract Time Warps

We have seen a number of examples where the default behavior did the "right thing," making the code straightforward. This is not always the case. Suppose we want to warp the note onsets but not the durations. We will first look at an incorrect solution and discuss the error. Then we will look at a slightly more complex (but correct) solution.

The default behavior for most Nyquist built-in functions is to sample the time warp function at the nominal starting and ending score times of the primitive. For many built-in functions, including osc, the starting logical time is 0 and the ending logical time is 1, so the time warp function is evaluated at these points to yield real starting and stopping times, say 15.23 and 16.79. The difference (e.g. 1.56) becomes the signal duration, and there is no internal time warping. The pwl function behaves a little differently. Here, each breakpoint is warped individually, but the resulting function is linear between the breakpoints.

A consequence of the default behavior is that notes stretch when the tempo slows down. Returning to our example, recall that we want to warp only the note onset times and not the duration. One would think that the following would work:

define function tone-seq-2 ()
  return seqrep(i, 16,
                osc-note(c4) ~~ 0.25)

play warp(warp4(), tone-seq-2())

Here, we have redefined tone-seq, renaming it to tone-seq-2 and changing the stretch (~) to absolute stretch (~~). The absolute stretch should override the warp function and produce a fixed duration.

If you play the example, you will hear steady sixteenths and no tempo changes. What is wrong? In a sense, the "fix" works too well. Recall that sequences (including seqrep) determine the starting time of the next note from the logical stop time of the previous sound in the sequence. When we forced the stretch to 0.25, we also forced the logical stop time to 0.25 real seconds from the beginning, so every note starts 0.25 seconds after the previous one, resulting in a constant tempo.

Now let us design a proper solution. The trick is to use absolute stretch (~~) as before to control the duration, but to restore the logical stop time to a value that results in the proper inter-onset time interval:

define function tone-seq-3()
  return seqrep(i, 16,
                set-logical-stop(osc-note(c4) ~~ 0.25, 0.25))

play warp(warp4(), tone-seq-3())

Notice the addition of set-logical-stop enclosing the absolute stretch (~~) expression to set the logical stop time. A possible point of confusion here is that the logical stop time is set to 0.25, the same number given to ~~! How does setting the logical stop time to 0.25 result in a tempo change? When used within a warp transformation, the second argument to set-logical-stop refers to score time rather than real time. Therefore, the score duration of 0.25 is warped into real time, producing tempo changes according to the enviroment. Figure 4 illustrates the result graphically.




Figure 4: When (warp4) is applied to (tone-seq-3), the note onsets are warped, but not the duration, which remains a constant 0.25 seconds. In the fast middle section, this causes notes to overlap. Nyquist will sum (mix) them.


Nested Transformations

Transformations can be nested. In particular, a simple transformation such as transpose can be nested within a time warp transformation. Suppose we want to warp our chromatic scale example with the warp4 time warp function. As in the previous section, we will show an erroneous simple solution followed by a correct one.

The simplest approach to a nested transformation is to simply combine them and hope for the best:

play warp(warp4(),
          transpose(pitch-rise(), tone-seq()))
This example will not work the way you might expect. Here is why: the warp transformation applies to the (pitch-rise) expression, which is implemented using the ramp function. The default behavior of ramp is to interpolate linearly (in real time) between two points. Thus, the "warped" ramp function will not truly reflect the internal details of the intended time warp. When the notes are moving faster, they will be closer together in pitch, and the result is not chromatic. What we need is a way to properly compose the warp and ramp functions. If we continuously warp the ramp function in the same way as the note sequence, a chromatic scale should be obtained. This will lead to a correct solution.

Here is the modified code to properly warp a transposed sequence. Note that the original sequence is used without modification. The only complication is producing a properly warped transposition function:

  play warp(warp4(),
            transpose(
              control-warp(get-warp(),
                           warp-abs(nil, pitch-rise())),
              tone-seq()))
To properly warp the pitch-rise transposition function, we use control-warp, which applies a warp function to a function of score time, yielding a function of real time. We need to pass the desired function to control-warp, so we fetch it from the environment with get-warp(). Finally, since the warping is done here, we want to shield the pitch-rise expression from further warping, so we enclose it in warp-abs(nil, ...).

An aside: This last example illustrates a difficulty in the design of Nyquist. To support behavioral abstraction universally, we must rely upon behaviors to "do the right thing." In this case, we would like the ramp function to warp continuously according to the environment. But this is inefficient and unnecessary in many other cases where ramp and especially pwl are used. (pwl warps its breakpoints, but still interpolates linearly between them.) Also, if the default behavior of primitives is to warp in a continuous manner, this makes it difficult to build custom abstract behaviors. The final vote is not in.


Previous Section | Next Section | Table of Contents | Index | Title Page