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

More Examples

This chapter explores Nyquist through additional examples. The reader may wish to browse through these and move on to Chapter Nyquist Functions, which is a reference section describing Nyquist functions.

Stretching Sampled Sounds

This example illustrates how to stretch a sound, resampling it in the process. Because sounds in Nyquist are values that contain the sample rate, start time, etc., use sound to convert a sound into a behavior that can be stretched, e.g. sound(a-snd). This behavior stretches a sound according to the stretch factor in the environment, set using stretch. For accuracy and efficiency, Nyquist does not resample a stretched sound until absolutely necessary. The force-srate function is used to resample the result so that we end up with a “normal” sample rate that is playable on ordinary sound cards.

; if a-snd is not loaded, load sound sample:
;
if not(boundp(quote(a-snd))) then
  set a-snd = s-read("demo-snd.aiff")

; the SOUND operator shifts, stretches, clips and scales 
; a sound according to the current environment
;
define function ex23()
  play force-srate(*default-sound-srate*,  sound(a-snd) ~ 3.0)

define function down()
  return force-srate(*default-sound-srate*, 
                     seq(sound(a-snd) ~ 0.2,
                         sound(a-snd) ~ 0.3,
                         sound(a-snd) ~ 0.4,
                         sound(a-snd) ~ 0.6))
play down()

; that was so much fun, let's go back up:
;
define function up()
  return force-srate(*default-sound-srate*,
                     seq(sound(a-snd) ~ 0.5,
                         sound(a-snd) ~ 0.4,
                         sound(a-snd) ~ 0.3,
                         sound(a-snd) ~ 0.2))

; and write a sequence
;
play seq(down(), up(), down())

Notice the use of the sound behavior as opposed to cue. The cue behavior shifts and scales its sound according to *warp* and *loud*, but it does not change the duration or resample the sound. In contrast, sound not only shifts and scales its sound, but it also stretches it by resampling or changing the effective sample rate according to *warp*. If *warp* is a continuous warping function, then the sound will be stretched by time-varying amounts. (The *transpose* element of the environment is ignored by both cue and sound.)

Note: sound may use linear interpolation rather than a high-quality resampling algorithm. In some cases, this may introduce errors audible as noise. Use resample (see Section Sound Synthesis) for high-quality interpolation.

In the functions up and down, the *warp* is set by stretch (~), which simply scales time by a constant scale factor. In this case, sound can “stretch” the signal simply by changing the sample rate without any further computation. When seq tries to add the signals together, it discovers the sample rates do not match and uses linear interpolation to adjust all sample rates to match that of the first sound in the sequence. The result of seq is then converted using force-srate to convert the sample rate, again using linear interpolation. It would be slightly better, from a computational standpoint, to apply force-srate individually to each stretched sound rather than applying force-srate after seq.

Notice that the overall duration of sound(a-snd) ~ 0.5 will be half the duration of a-snd.

Saving Sound Files

So far, we have used the play command to play a sound. The play command works by writing a sound to a file while simultaneously playing it. This can be done one step at a time, and it is often convenient to save a sound to a particular file for later use:

; write the sample to a file, 
;    the file name can be any Unix filename.  Prepending a "./" tells
;    s-save to not prepend *default-sf-dir*
;
exec s-save(a-snd, 1000000000, "./a-snd-file.snd")

; play a file
; play command normally expects an expression for a sound
; but if you pass it a string, it will open and play a
; sound file
play "./a-snd-file.snd"

; delete the file (do this with care!)
; only works under Unix (not Windows)
exec system("rm ./a-snd-file.snd")

; now let's do it using a variable as the file name
;
set my-sound-file = "./a-snd-file.snd"
exec s-save(a-snd, 1000000000, my-sound-file)

; play-file is a function to open and play a sound file
exec play-file(my-sound-file)
exec system(strcat("rm ", my-sound-file))

This example shows how s-save can be used to save a sound to a file.

The last line of this example shows how the system function can be used to invoke Unix shell commands, such as a command to play a file or remove it. Finally, notice that strcat can be used to concatenate a command name to a file name to create a complete command that is then passed to system. (This is convenient if the sound file name is stored in a parameter or variable.)

Memory Space and Normalization

Sound samples take up lots of memory, and often, there is not enough primary (RAM) memory to hold a complete composition. For this reason, Nyquist can compute sounds incrementally, saving the final result on disk. However, Nyquist can also save sounds in memory so that they can be reused efficiently. In general, if a sound is saved in a global variable, memory will be allocated as needed to save and reuse it.

The standard way to compute a sound and write it to disk is to pass an expression to the play command:

play my-composition()

Often it is nice to normalize sounds so that they use the full available dynamic range of 16 bits. Nyquist has an automated facility to help with normalization. By default, Nyquist computes up to 1 million samples (using about 4MB of memory) looking for the peak. The entire sound is normalized so that this peak will not cause clipping. If the sound has less than 1 million samples, or if the first million samples are a good indication of the overall peak, then the signal will not clip.

With this automated normalization technique, you can choose the desired peak value by setting *autonorm-target*, which is initialized to 0.9. The number of samples examined is *autonorm-max-samples*, initially 1 million. You can turn this feature off by executing:

exec autonorm-off()

and turn it back on by typing:

exec autonorm-on()

This normalization technique is in effect when *autonorm-type* is quote(lookahead), which is the default.

An alternative normalization method uses the peak value from the previous call to play. After playing a file, Nyquist can adjust an internal scale factor so that if you play the same file again, the peak amplitude will be *autonorm-target*, which is initialized to 0.9. This can be useful if you want to carefully normalize a big sound that does not have its peak near the beginning. To select this style of normalization, set *autonorm-type* to the (quoted) atom quote(previous).

You can also create your own normalization method in Nyquist. The peak function computes the maximum value of a sound. The peak value is also returned from the play macro. You can normalize in memory if you have enough memory; otherwise you can compute the sound twice. The two techniques are illustrated here:

; normalize in memory.  First, assign the sound to a variable so
; it will be retained:
set mysound = sim(osc(c4), osc(c5))
; now compute the maximum value (ny:all is 1 giga-samples, you may want a
; smaller constant if you have less than 4GB of memory:
set mymax = snd-max(mysound, NY:ALL)
display "Computed max", mymax
; now write out and play the sound from memory with a scale factor:
play mysound * (0.9 / mymax)

; if you don't have space in memory, here's how to do it:
define function myscore()
  return sim(osc(c4), osc(c5))
; compute the maximum:
set mymax = snd-max(list(quote(myscore)), NY:ALL)
display "Computed max", mymax
; now we know the max, but we don't have a the sound (it was garbage
; collected and never existed all at once in memory).  Compute the sound
; again, this time with a scale factor:
play myscore() * (0.9 / mymax)

You can also write a sound as a floating point file. This file can then be converted to 16-bit integer with the proper scaling applied. If a long computation was involved, it should be much faster to scale the saved sound file than to recompute the sound from scratch. Although not implemented yet in Nyquist, some header formats can store maximum amplitudes, and some soundfile player programs can rescale floating point files on the fly, allowing normalized soundfile playback without an extra normalization pass (but at a cost of twice the disk space of 16-bit samples). You can use Nyquist to rescale a floating point file and convert it to 16-bit samples for playback.

Frequency Modulation

The next example uses the Nyquist frequency modulation behavior fmosc to generate various sounds. The parameters to fmosc are:

fmosc(pitch, modulator, table, phase)

Note that pitch is the number of half-steps, e.g. c4 has the value of 60 which is middle-C, and phase is in degrees. Only the first two parameters are required:

; make a short sine tone with no frequency modulation
;
play fmosc(c4, pwl(0.1))

; make a longer sine tone -- note that the duration of
;   the modulator determines the duration of the tone
;
play fmosc(c4, pwl(0.5))

In the example above, pwl (for Piece-Wise Linear) is used to generate sounds that are zero for the durations of 0.1 and 0.5 seconds, respectively. In effect, we are using an FM oscillator with no modulation input, and the result is a sine tone. The duration of the modulation determines the duration of the generated tone (when the modulation signal ends, the oscillator stops).

The next example uses a more interesting modulation function, a ramp from zero to C4, expressed in hz. More explanation of pwl is in order. This operation constructs a piece-wise linear function sampled at the *control-srate*. The first breakpoint is always at (0, 0), so the first two parameters give the time and value of the second breakpoint, the second two parameters give the time and value of the third breakpoint, and so on. The last breakpoint has a value of 0, so only the time of the last breakpoint is given. In this case, we want the ramp to end at C4, so we cheat a bit by having the ramp return to zero “almost” instantaneously between times 0.5 and 0.501.

The pwl behavior always expects an odd number of parameters. The resulting function is shifted and stretched linearly according to *warp* in the environment. Now, here is the example:

; make a frequency sweep of one octave; the piece-wise linear function
; sweeps from 0 to (step-to-hz c4) because, when added to the c4
; fundamental, this will double the frequency and cause an octave sweep.
;
play fmosc(c4, pwl(0.5, step-to-hz(c4), 0.501))

The same idea can be applied to a non-sinusoidal carrier. Here, we assume that *fm-voice* is predefined (the next section shows how to define it):

; do the same thing with a non-sine table
;
play fmosc(cs2, pwl(0.5, step-to-hz(cs2), 0.501),
           *fm-voice*, 0.0)

The next example shows how a function can be used to make a special frequency modulation contour. In this case the contour generates a sweep from a starting pitch to a destination pitch:

; make a function to give a frequency sweep, starting
; after <delay> seconds, then sweeping from <pitch-1>
; to <pitch-2> in <sweep-time> seconds and then
; holding at <pitch-2> for <hold-time> seconds.
;
define function sweep(delay, pitch-1, sweep-time, 
                      pitch-2, hold-time)
  begin
    with interval = step-to-hz(pitch-2) - step-to-hz(pitch-1)
    return pwl(delay, 0.0,
               ; sweep from pitch 1 to pitch 2
               delay + sweep-time, interval,
               ; hold until about 1 sample from the end
               delay + sweep-time + hold-time - 0.0005, 
               interval,
               ; quickly ramp to zero (pwl always does this,
               ;    so make it short)
               delay + sweep-time + hold-time)
  end


; now try it out
;
play fmosc(cs2, sweep(0.1, cs2, 0.6, gs2, 0.5),
           *fm-voice*, 0.0)

FM can be used for vibrato as well as frequency sweeps. Here, we use the lfo function to generate vibrato. The lfo operation is similar to osc, except it generates sounds at the *control-srate*, and the parameter is hz rather than a pitch:

play fmosc(cs2, 10.0 * lfo(6.0), *fm-voice*, 0.0)

What kind of manual would this be without the obligatory FM sound? Here, a sinusoidal modulator (frequency C4) is multiplied by a slowly increasing ramp from zero to 1000.0.

set modulator = pwl(1.0, 1000.0, 1.0005) * 
                osc(c4)
; make the sound
play fmosc(c4, modulator)

For more simple examples of FM in Nyquist, see nyquist/lib/warble/warble_tutorial.htm.

Building a Wavetable

In Section Non-Sinusoidal Waveforms, we saw how to synthesize a wavetable. A wavetable for osc also can be extracted from any sound. This is especially interesting if the sound is digitized from some external sound source and loaded using the s-read function. Recall that a table is a list consisting of a sound, the pitch of that sound, and T (meaning the sound is periodic).

In the following, a sound is first read from the file demo-snd.nh. Then, the extract function is used to extract the portion of the sound between 0.110204 and 0.13932 seconds. (These numbers might be obtained by first plotting the sound and estimating the beginning and end of a period, or by using some software to look for good zero crossings.) The result of extract becomes the first element of a list. The next element is the pitch (24.848422), and the last element is T. The list is assigned to *fm-voice*.

if not(boundp(quote(a-snd))) then
  set a-snd = s-read("demo-snd.aiff")

set *fm-voice* = list(extract(0.110204, 0.13932, cue(a-snd)),
                      24.848422,
                      #T)

The file nyquist/lib/examples.sal contains an extensive example of how to locate zero-crossings, extract a period, build a waveform, and generate a tone from it. (See ex37 through ex40 in the file.)

Filter Examples

Nyquist provides a variety of filters. All of these filters take either real numbers or signals as parameters. If you pass a signal as a filter parameter, the filter coefficients are recomputed at the sample rate of the control signal. Since filter coefficients are generally expensive to compute, you may want to select filter control rates carefully. Use control-srate-abs (Section Transformations) to specify the default control sample rate, or use force-srate (Section Sound Synthesis) to resample a signal before passing it to a filter.

Before presenting examples, let's generate some unfiltered white noise:

play noise()

Now low-pass filter the noise with a 1000Hz cutoff:

play lp(noise(), 1000.0)

The high-pass filter is the inverse of the low-pass:

play hp(noise(), 1000.0)

Here is a low-pass filter sweep from 100Hz to 2000Hz:

play lp(noise(), pwl(0.0, 100.0, 1.0, 2000.0, 1.0))

And a high-pass sweep from 50Hz to 4000Hz:

play hp(noise(), pwl(0.0, 50.0, 1.0, 4000.0, 1.0))

The band-pass filter takes a center frequency and a bandwidth parameter. This example has a 500Hz center frequency with a 20Hz bandwidth. The scale factor is necessary because, due to the resonant peak of the filter, the signal amplitude exceeds 1.0:

  
play reson(10.0 * noise(), 500.0, 20.0, 1)

In the next example, the center frequency is swept from 100 to 1000Hz, using a constant 20Hz bandwidth:

play reson(0.04 * noise(),
           pwl(0.0, 200.0, 1.0, 1000.0, 1.0),
           20.0)

For another example with explanations, see nyquist/lib/wind/wind_tutorial.htm.

DSP in Lisp

In almost any signal processing system, the vast majority of computation takes place in the inner loops of DSP algorithms, and Nyquist is designed so that these time-consuming inner loops are in highly-optimized machine code rather than relatively slow interpreted lisp code. As a result, Nyquist typically spends 95% of its time in these inner loops; the overhead of using a Lisp interpreter is negligible.

The drawback is that Nyquist must provide the DSP operations you need, or you are out of luck. When Nyquist is found lacking, you can either write a new primitive signal operation, or you can perform DSP in Lisp code. Neither option is recommended for inexperienced programmers. Instructions for extending Nyquist are given in Appendix Appendix 1: Extending Nyquist. This section describes the process of writing a new signal processing function in Lisp.

Before implementing a new DSP function, you should decide which approach is best. First, figure out how much of the new function can be implemented using existing Nyquist functions. For example, you might think that a tapped-delay line would require a new function, but in fact, it can be implemented by composing sound transformations to accomplish delays, scale factors for attenuation, and additions to combine the intermediate results. This can all be packaged into a new Lisp function, making it easy to use. If the function relies on built-in DSP primitives, it will execute very efficiently.

Assuming that built-in functions cannot be used, try to define a new operation that will be both simple and general. Usually, it makes sense to implement only the kernel of what you need, combining it with existing functions to build a complete instrument or operation. For example, if you want to implement a physical model that requires a varying breath pressure with noise and vibrato, plan to use Nyquist functions to add a basic pressure envelope to noise and vibrato signals to come up with a composite pressure signal. Pass that signal into the physical model rather than synthesizing the envelope, noise, and vibrato within the model. This not only simplifies the model, but gives you the flexibility to use all of Nyquist's operations to synthesize a suitable breath pressure signal.

Having designed the new “kernel” DSP operation that must be implemented, decide whether to use C or Lisp. (At present, SAL is not a good option because it has no support for object-oriented programming.) To use C, you must have a C compiler, the full source code for Nyquist, and you must learn about extending Nyquist by reading Appendix Appendix 1: Extending Nyquist. This is the more complex approach, but the result will be very efficient. A C implementation will deal properly with sounds that are not time-aligned or matched in sample rates. To use Lisp, you must learn something about the XLISP object system, and the result will be about 50 times slower than C. Also, it is more difficult to deal with time alignment and differences in sample rates. The remainder of this section gives an example of a Lisp version of snd-prod to illustrate how to write DSP functions for Nyquist in Lisp.

The snd-prod function is the low-level multiply routine. It has two sound parameters and returns a sound which is the product of the two. To keep things simple, we will assume that two sounds to be multiplied have a matched sample rate and matching start times. The DSP algorithm for each output sample is simply to fetch a sample from each sound, multiply them, and return the product.

To implement snd-prod in Lisp, three components are required:

  1. An object is used to store the two parameter sounds. This object will be called upon to yield samples of the result sound;
  2. Within the object, the snd-fetch routine is used to fetch samples from the two input sounds as needed;
  3. The result must be of type SOUND, so snd-fromobject is used to create the result sound.

The combined solution will work as follows: The result is a value of type sound that retains a reference to the object. When Nyquist needs samples from the sound, it invokes the sound's “fetch” function, which in turn sends an XLISP message to the object. The object will use snd-fetch to get a sample from each stored sound, multiply the samples, and return a result.

Thus the goal is to design an XLISP object that, in response to a :next message will return a proper sequence of samples. When the sound reaches the termination time, simply return NIL.

The XLISP manual (see Appendix Appendix 3: XLISP: An Object-oriented Lisp) describes the object system, but in a very terse style, so this example will include some explanation of how the object system is used. First, we need to define a class for the objects that will compute sound products. Every class is a subclass of class class, and you create a subclass by sending :new to a class.

(setf product-class (send class :new '(s1 s2)))

The parameter '(s1 s2) says that the new class will have two instance variables, s1 and s2. In other words, every object which is an instance of class product-class will have its own copy of these two variables.

Next, we will define the :next method for product-class:

(send product-class :answer :next '()
  '((let ((f1 (snd-fetch s1))
          (f2 (snd-fetch s2)))
      (cond ((and f1 f2)
             (* f1 f2))
            (t nil)))))

The :answer message is used to insert a new method into our new product-class. The method is described in three parts: the name (:next), a parameter list (empty in this case), and a list of expressions to be evaluated. In this case, we fetch samples from s1 and s2. If both are numbers, we return their product. If either is NIL, we terminate the sound by returning nil.

The :next method assumes that s1 and s2 hold the sounds to be multiplied. These must be installed when the object is created. Objects are created by sending :new to a class. A new object is created, and any parameters passed to :new are then sent in a :isnew message to the new object. Here is the :isnew definition for product-class:

(send product-class :answer :isnew '(p1 p2) 
  '((setf s1 (snd-copy p1))
    (setf s2 (snd-copy p2))))

Take careful note of the use of snd-copy in this initialization. The sounds s1 and s2 are modified when accessed by snd-fetch in the :next method defined above, but this destroys the illusion that sounds are immutable values. The solution is to copy the sounds before accessing them; the original sounds are therefore unchanged. (This copy also takes place implicitly in most Nyquist sound functions.)

To make this code safer for general use, we should add checks that s1 and s2 are sounds with identical starting times and sample rates; otherwise, an incorrect result might be computed.

Now we are ready to write snd-product, an approximate replacement for snd-prod:

(defun snd-product (s1 s2)
  (let (obj)
    (setf obj (send product-class :new s1 s2))
    (snd-fromobject (snd-t0 s1) (snd-srate s1) obj)))

This code first creates obj, an instance of product-class, to hold s1 and s2. Then, it uses obj to create a sound using snd-fromobject. This sound is returned from snd-product. Note that in snd-fromobject, you must also specify the starting time and sample rate as the first two parameters. These are copied from s1, again assuming that s1 and s2 have matching starting times and sample rates.

Note that in more elaborate DSP algorithms we could expect the object to have a number of instance variables to hold things such as previous samples, waveform tables, and other parameters.


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