CLM (originally an acronym for Common Lisp Music) is a sound synthesis package in the Music V family. This file describes CLM as implemented in Snd, aiming primarily at the Scheme version. Common Lisp users should check out clm.html in the CLM tarball. CLM is based on a set of functions known as "generators". These can be packaged into "instruments", and instrument calls can be packaged into "note lists". (These names are just convenient historical artifacts). The main emphasis here is on the generators; note lists and instruments are described in sndscm.html.
| related documentation: | snd.html | extsnd.html | grfsnd.html | sndscm.html | fm.html | sndlib.html | libxm.html | index.html |
|
|
| |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
| |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
I'm not sure how to motivate this set of functions. If you try to make new sounds, or recreate and alter existing sounds, you'll find that there are some functions that seem to pop up everywhere. The basic building block of sound is the sinusoid, so we have things like oscil and polywave. Another basic thing is noise, so we have rand and rand-interp. Sounds get louder and softer, or go up and down in pitch, so we have envelopes (env). We need a way to get at them (in-any, readin), and play them (out-any, locsig). We need tons of reverb (delay, convolve). I love this stuff. |
Start Snd, open the listener (choose "Show listener" in the View menu), and:
>(load "v.scm")
#<unspecified>
>(with-sound () (fm-violin 0 1 440 .1))
"test.snd"
Snd's printout is in blue here, and your typing is in red. The load function returns "#<unspecified>" in Guile to indicate that it is happy. If all went well, you should see a graph of the fm-violin's output. Click the "play" button to hear it; click "f" to see its spectrum.
|
What if this happened instead?
>(load "v.scm")
open-file: system-error: "No such file or directory": "v.scm" (2)
Snd is telling you that "open-file" (presumably part of the load sequence) can't find v.scm. I guess it's on some other directory, so try:
>%load-path
("/usr/local/share/snd" "/usr/local/share/guile/1.9")
"%load-path" is a list of directorties that the "load" function looks at. Apparently these two directories don't have v.scm. So find out where v.scm is ("locate v.scm" is usually the quickest way), and add its directory to %load-path:
>(set! %load-path (cons "/home/bil/cl" %load-path)) ; add the "cl" directory to the search list
#<unspecified>
>(load-from-path "v.scm")
#<unspecified>
|
In Gauche, "load" returns #t if happy, #f if not, and Gauche's name for the directory search list is *load-path*. In Ruby, we'd do it this way:
>load "v.rb"
true
>with_sound() do fm_violin_rb(0, 1.0, 440.0, 0.1) end
#<With_CLM: output: "test.snd", channels: 1, srate: 22050>
and in Forth:
snd> "clm-ins.fs" file-eval
0
snd> 0.0 1.0 440.0 0.1 ' fm-violin with-sound
\ filename: test.snd
In most of this document, I'll stick with Scheme as implemented by Guile. extsnd.html and sndscm.html have numerous Ruby and Forth examples, and I'll toss some in here as I go along. You can save yourself a lot of typing by using two features of the listener. First, <TAB> (that is, the key marked TAB) tries to complete the current name, so if you type "fm-<TAB>" the listener completes the name as "fm-violin". And second, you can back up to a previous expression, edit it, move the cursor to the closing parenthesis, and type <RETURN>, and that expression will be evaluated as if you had typed all of it in from the start. Needless to say, you can paste code from this file into the Snd listener.
with-sound opens an output sound file, evaluates its body, closes the file, and then opens it in Snd. If the sound is already open, with-sound replaces it with the new version. The body of with-sound can be any size, and can include anything that you could put in a function body. For example, to get an arpeggio:
(with-sound ()
(do ((i 0 (1+ i)))
((= i 8))
(fm-violin (* i .25) .5 (* 100 (1+ i)) .1)))
|
If that seemed to take awhile, make sure you've turned on optimization:
>(set! (optimization) 6)
6
The optimizer, a macro named "run", can usually speed up computations by about a factor of 10. |
with-sound, instruments, CLM itself are all optional, of course. We could do everything by hand:
(let ((sound (new-sound "test.snd" :size 22050))
(increment (/ (* 440.0 2.0 pi) 22050.0))
(current-phase 0.0))
(map-channel (lambda (y)
(let ((val (* .1 (sin current-phase))))
(set! current-phase (+ current-phase increment))
val))))
This opens a sound file (via new-sound) and fills it with a .1 amplitude sine wave at 440 Hz. The "increment" calculation turns 440 Hz into a phase increment in radians (we could also use the function hz->radians). The "oscil" generator keeps track of the phase increment for us, so essentially the same thing using with-sound and oscil is:
(with-sound ()
(let ((osc (make-oscil 440.0)))
(do ((i 0 (1+ i)))
((= i 44100))
(outa i (* .1 (oscil osc)) *output*))))
*output* is the file opened by with-sound, and outa is a function that adds its second argument (the sinusoid) into the current output at the sample given by its first argument ("i" in this case). oscil is our sinusoid generator, created by make-oscil. You don't need to worry about freeing the oscil; we can depend on the Scheme garbage collector to deal with that. All the generators are like oscil in that each is a function that on each call returns the next sample in an infinite stream of samples. An oscillator, for example, returns an endless sine wave, one sample at a time. Each generator consists of a set of functions: make-<gen> sets up the data structure associated with the generator; <gen> produces a new sample; <gen>? checks whether a variable is that kind of generator. Current generator state is accessible via various generic functions such as mus-frequency:
(set! oscillator (make-oscil :frequency 330))
prepares "oscillator" to produce a sine wave when set in motion via
(oscil oscillator)
The make-<gen> function takes a number of optional arguments, setting whatever state the given generator needs to operate on. The run-time function's first argument is always its associated structure. Its second argument is nearly always something like an FM input or whatever run-time modulation might be desired. Frequency sweeps of all kinds (vibrato, glissando, breath noise, FM proper) are all forms of frequency modulation. So, in normal usage, our oscillator looks something like:
(oscil oscillator (+ vibrato glissando frequency-modulation))
One special aspect of each make-<gen> function is the way it read its arguments. I use the word optional-key in the function definitions in this document to indicate that the arguments are keywords, but the keywords themselves are optional. Take the make-oscil call, defined as:
make-oscil :optional-key (frequency *clm-default-frequency*) (initial-phase 0.0)
This says that make-oscil has two optional arguments, frequency (in Hz), and initial-phase (in radians). The keywords associated with these values are :frequency and :initial-phase. When make-oscil is called, it scans its arguments; if a keyword is seen, that argument and all following arguments are passed unchanged, but if a value is seen, the corresponding keyword is prepended in the argument list:
(make-oscil :frequency 440.0)
(make-oscil :frequency 440.0 :initial-phase 0.0)
(make-oscil 440.0)
(make-oscil 440.0 :initial-phase 0.0)
(make-oscil 440.0 0.0)
are all equivalent, but
(make-oscil :frequency 440.0 0.0)
(make-oscil :initial-phase 0.0 440.0)
are in error, because once we see any keyword, all the rest of the arguments have to use keywords too (we can't reliably make any assumptions after that point about argument ordering). This style of argument passing is very similar to the "Optional Positional and Named Parameters" extension of scheme: SRFI-89.
Since we often want to use a given sound-producing algorithm many times (in a note list, for example), it is convenient to package up that code into a function. Our sinewave could be rewritten:
(define (simp start end freq amp) (let ((os (make-oscil freq))) (do ((i start (1+ i))) ((= i end)) (outa i (* amp (oscil os)))))) ; outa output defaults to *output* so we can omit it |
Now to hear our sine wave:
(with-sound (:play #t) (simp 0 44100 330 .1))
This version of "simp" forces you to think in terms of sample numbers ("start" and "end") which are dependent on the overall sampling rate changes. Our first enhancement is to use seconds:
(define (simp beg dur freq amp) (let* ((os (make-oscil freq)) (start (seconds->samples beg)) (end (+ start (seconds->samples dur)))) (do ((i start (1+ i))) ((= i end)) (outa i (* amp (oscil os)))))) |
Now we can use any sampling rate, and call "simp" using seconds:
(with-sound (:srate 44100) (simp 0 1.0 440.0 0.1))
Our next improvement adds the "run" macro to speed up processing by about a factor of 10:
(define (simp beg dur freq amp) (let* ((os (make-oscil freq)) (start (seconds->samples beg)) (end (+ start (seconds->samples dur)))) (run (lambda () (do ((i start (1+ i))) ((= i end)) (outa i (* amp (oscil os)))))))) |
Next we turn the "simp" function into an "instrument". An instrument is a function that has a variety of built-in actions within with-sound. The only change is the word "definstrument":
(definstrument (simp beg dur freq amp) (let* ((os (make-oscil freq)) (start (seconds->samples beg)) (end (+ start (seconds->samples dur)))) (run (lambda () (do ((i start (1+ i))) ((= i end)) (outa i (* amp (oscil os)))))))) |
Now we can simulate a telephone:
(define (telephone start telephone-number)
(let ((touch-tab-1 '(0 697 697 697 770 770 770 852 852 852 941 941 941))
(touch-tab-2 '(0 1209 1336 1477 1209 1336 1477 1209 1336 1477 1209 1336 1477)))
(do ((i 0 (1+ i)))
((= i (length telephone-number)))
(let* ((num (list-ref telephone-number i))
(frq1 (list-ref touch-tab-1 num))
(frq2 (list-ref touch-tab-2 num)))
(simp (+ start (* i .4)) .3 frq1 .1)
(simp (+ start (* i .4)) .3 frq2 .1)))))
(with-sound () (telephone 0.0 '(7 2 3 4 9 7 1)))
|
As a last change, let's add an amplitude envelope:
(definstrument (simp beg dur freq amp envelope) (let* ((os (make-oscil freq)) (amp-env (make-env envelope :duration dur :scaler amp)) (start (seconds->samples beg)) (end (+ start (seconds->samples dur)))) (run (lambda () (do ((i start (1+ i))) ((= i end)) (outa i (* (env amp-env) (oscil os)))))))) |
A CLM envelope is a list of (x y) break-point pairs. The x-axis bounds are arbitrary, but it is conventional (here at ccrma) to go from 0 to 1.0. The y-axis values are normally between -1.0 and 1.0, to make it easier to figure out how to apply the envelope in various different situations.
(with-sound () (simp 0 2 440 .1 '(0 0 0.1 1.0 1.0 0.0)))
Add a few more oscils and envs, and you've got the fm-violin. You can try out a generator or a patch of generators quickly by plugging it into the following with-sound call:
(with-sound ()
(let ((sqr (make-square-wave 100))) ; test a square-wave generator
(do ((i 0 (1+ i)))
((= i 10000))
(outa i (square-wave sqr)))))
|
By the way, there's nothing special about a generator in CLM: it is a function, or perhaps more accurately, a closure. If such a function happens to restrict itself to functions that the "run" macro can handle (and this includes most of Scheme), then it will run nearly as fast as any built-in function. If it needs to keep on-going state around, it is simplest to use a vct as the generator object:
(define (make-my-oscil frequency) ; we want our own oscil! (vct 0.0 (hz->radians frequency))) ; current phase and frequency-based phase increment (define (my-oscil gen fm) ; the corresponding generator (let ((result (sin (vct-ref gen 0)))) ; return sin(current-phase) (vct-set! gen 0 (+ (vct-ref gen 0) ; increment current phase (vct-ref gen 1) ; by frequency fm)) ; and FM result)) ; return sine wave (with-sound () (run (lambda () (let ((osc (make-my-oscil 440.0))) (do ((i 0 (1+ i))) ((= i 44100)) (outa i (my-oscil osc 0.0))))))) |
There are many more such generators scattered around the Snd package. For more sophisticated situations, you can use def-clm-struct, and defgenerator.
Generators |
oscil |
make-oscil :optional-key (frequency *clm-default-frequency*) (initial-phase 0.0) oscil os :optional (fm-input 0.0) (pm-input 0.0) oscil? os sine-bank amps phases
(with-sound (:play #t)
(let ((gen (make-oscil 440.0)))
(do ((i 0 (1+ i)))
((= i 44100))
(outa i (* 0.5 (oscil gen))))))
|
with_sound(:play, true) do
gen = make_oscil(440.0);
44100.times do |i|
outa(i, 0.5 * oscil(gen), $output)
end
end.output
|
44100 lambda: <{ len }>
440.0 make-oscil { gen }
len 0 ?do
i gen 0.0 0.0 oscil 0.5 f* *output* outa drop
loop
;
:play #t :channels 1 :srate 44100 with-sound
|
oscil produces a sine wave (using sin) with optional frequency change (FM). Its first argument is an oscil created by make-oscil. Oscil's second argument is the frequency change (frequency modulation), and the third argument is the phase change (phase modulation). The initial-phase argument to make-oscil is in radians. You can use degrees->radians to convert from degrees to radians. To get a cosine (as opposed to sine), set the initial-phase to (/ pi 2).
sine-bank simply loops through its arrays of amps and phases, summing (* amp (sin phase)) — it is mostly a convenience function for additive synthesis (the phase-vocoder in particular).
| mus-frequency | frequency in Hz |
| mus-phase | phase in radians |
| mus-length | 1 (no set!) |
| mus-increment | frequency in radians per sample |
(let ((result (sin (+ phase pm-input))))
(set! phase (+ phase (hz->radians frequency) fm-input))
result)
One slightly confusing aspect of oscil is that glissando has to be turned into a phase-increment envelope. This means that the frequency envelope y values should be passed through hz->radians:
(define (simp start end freq amp frq-env)
(let ((os (make-oscil freq))
(frqe (make-env frq-env :length (1+ (- end start)) :scaler (hz->radians freq))))
(do ((i start (1+ i)))
((= i end))
(outa i (* amp (oscil os (env frqe)))))))
(with-sound () (simp 0 10000 440 .1 '(0 0 1 1))) ; sweep up an octave
|
Here is an example of FM (here the hz->radians business is folded into the FM index):
(definstrument (simple-fm beg dur freq amp mc-ratio index :optional amp-env index-env) (let* ((start (seconds->samples beg)) (end (+ start (seconds->samples dur))) (cr (make-oscil freq)) ; carrier (md (make-oscil (* freq mc-ratio))) ; modulator (fm-index (hz->radians (* index mc-ratio freq))) (ampf (make-env (or amp-env '(0 0 .5 1 1 0)) :scaler amp :duration dur)) (indf (make-env (or index-env '(0 0 .5 1 1 0)) :scaler fm-index :duration dur))) (run (lambda () (do ((i start (1+ i))) ((= i end)) (outa i (* (env ampf) (oscil cr (* (env indf) (oscil md)))))))))) ;;; (with-sound () (simple-fm 0 1 440 .1 2 1.0)) |
fm.html has an introduction to FM. FM and PM behave slightly differently during a glissando; FM is the more "natural" in that, left to its own devices, it produces a spectrum that varies inversely with the pitch. Compare these two cases. Both involve a slow glissando up an octave, FM in channel 0, and PM in channel 1. In the first note, I fix up the FM index during the sweep to keep the spectra steady, and in the second, I fix up the PM index. I think the second sounds better.
(with-sound (:channels 2) (let* ((dur 2.0) (samps (seconds->samples dur)) (pitch 1000) (modpitch 100) (pm-index 4.0) (fm-index (hz->radians (* 4.0 modpitch)))) (let* ((car1 (make-oscil pitch)) (mod1 (make-oscil modpitch)) (car2 (make-oscil pitch)) (mod2 (make-oscil modpitch)) (frqf (make-env '(0 0 1 1) :duration dur)) (ampf (make-env '(0 0 1 1 20 1 21 0) :duration dur :scaler .5))) (do ((i 0 (1+ i))) ((= i samps)) (let* ((frq (env frqf)) (rfrq (hz->radians frq)) (amp (env ampf))) (outa i (* amp (oscil car1 (+ (* rfrq pitch) (* fm-index (1+ frq) ; keep spectrum the same (oscil mod1 (* rfrq modpitch))))))) (outb i (* amp (oscil car2 (* rfrq pitch) (+ (* pm-index (oscil mod2 (* rfrq modpitch))))))))) (let* ((car1 (make-oscil pitch)) (mod1 (make-oscil modpitch)) (car2 (make-oscil pitch)) (mod2 (make-oscil modpitch)) (frqf (make-env '(0 0 1 1) :duration dur)) (ampf (make-env '(0 0 1 1 20 1 21 0) :duration dur :scaler .5))) (do ((i 0 (1+ i))) ((= i samps)) (let* ((frq (env frqf)) (rfrq (hz->radians frq)) (amp (env ampf))) (outa (+ i samps) (* amp (oscil car1 (+ (* rfrq pitch) (* fm-index ; let spectrum decay (oscil mod1 (* rfrq modpitch))))))) (outb (+ i samps) (* amp (oscil car2 (* rfrq pitch) (+ (* (/ pm-index (1+ frq)) (oscil mod2 (* rfrq modpitch))))))))))))) |
|
To show CLM in its various embodiments, here are the Scheme, Common Lisp, Ruby, Forth, and C versions of the bird instrument; it produces a sinusoid with (usually very elaborate) amplitude and frequency envelopes. | |
| |
| |
| |
| |
|
Many of the synthesis functions in this document try to make it faster or more convenient to produce a lot of sinusoids, but there are times when nothing but a ton of oscils will do:
|
Actually, we could do this with mus-chebyshev-t-sum:
...
(amps (make-vct 10607))
(angle 0.0)
(freq (hz->radians 1.0))
...
(do ((i 0 (+ i 1))
(k 0 (+ k 2)))
((= i len))
(vct-set! amps (list-ref peaks k) (list-ref peaks (+ k 1))))
...
(outa i (* (env ampf) (mus-chebyshev-t-sum angle amps)))
(set! angle (+ angle freq (rand-interp vib)))
...
but it seems a bit unnatural. Related generators are ncos, nsin, asymmetric-fm, and nrxysin. Some instruments that use oscil are bird and bigbird, fm-violin (v), lbj-piano (clm-ins.scm), vox (clm-ins.scm), and fm-bell (clm-ins.scm). Interesting extensions of oscil include the various summation formulas in generators.scm. For a sine-bank example, see pvoc.scm. To goof around with FM from a graphical interface, see bess.scm and bess1.scm.
|
When oscil's frequency is high relative to the sampling rate, the waveform you'll see may not look very sinusoidal. Here, for example, is oscil at 440 Hz when the srate is 1000, 4000, and 16000:
|
env |
make-env :optional-key
envelope ; list or vct of x,y break-point pairs
(scaler 1.0) ; scaler on every y value (before offset is added)
duration ; duration in seconds
(offset 0.0) ; value added to every y value
base ; type of connecting line between break-points
end ; end sample number (obsolete -- use length)
length ; duration in samples
env e
env? e
env-interp x env :optional (base 1.0) ;value of env at x
env-any e connecting-function
(with-sound (:play #t)
(let ((gen (make-oscil 440.0))
(ampf (make-env '(0 0 .01 1 .25 .1 .5 .01 1 0)
:scaler 0.5
:length 44100)))
(do ((i 0 (1+ i)))
((= i 44100))
(outa i (* (env ampf) (oscil gen))))))
|
with_sound(:play, true) do
gen = make_oscil(440.0);
ampf = make_env([0, 0, 0.01, 1.0, 0.25, 0.1, 0.5, 0.01, 1, 0],
:scaler, 0.5,
:length, 44100);
44100.times do |i|
outa(i, env(ampf) * oscil(gen), $output)
end
end.output
|
|
|
|||||||||||||||||
An envelope is a list or vct of break point pairs: '(0 0 100 1) is
a ramp from 0 to 1 over an x-axis excursion from 0 to 100, as is (vct 0 0 100 1).
This data is passed
to make-env along with the scaler (multiplier)
applied to the y axis, the offset added to every y value,
and the time in samples or seconds that the x axis represents.
make-env returns an env generator.
env then returns the next sample of the envelope each time it is called.
Say we want a ramp moving from .3 to .5 during 1 second.
(make-env '(0 0 100 1) :scaler .2 :offset .3 :duration 1.0)
(make-env '(0 .3 1 .5) :duration 1.0)
I find the second version easier to read. The first is handy if you have a bunch of stored envelopes. To specify the breakpoints, you can also use the form '((0 0) (100 1)).
|
The base argument determines how the break-points are connected. If it is 1.0 (the
default), you get straight line segments. If base is 0.0, you get a step
function (the envelope changes its value suddenly to the new one without any
interpolation). Any other positive value affects the exponent of the exponential curve
connecting the points. A base less than 1.0 gives convex curves (i.e. bowed
out), and a base greater than 1.0 gives concave curves (i.e. sagging).
If you'd rather think in terms of e^-kt, set the base to |
|
|
You can get a lot from a couple of envelopes:
> (load "animals.scm")
#<unspecified>
> (with-sound (:play #t) (pacific-chorus-frog 0 .5))
"test.snd"
> (with-sound (:play #t) (house-finch 0 .5))
"test.snd"
There are several ways to get arbitrary connecting curves between the break points. The simplest method is to treat the output of env as the input to the connecting function. Here's an instrument that maps the line segments into sin x^3:
|
|
Another method is to write a function that traces out the curve you want. J.C.Risset's bell curve is:
(define (bell-curve x) ;; x from 0.0 to 1.0 creates bell curve between .64e-4 and nearly 1.0 ;; if x goes on from there, you get more bell curves; x can be ;; an envelope (a ramp from 0 to 1 if you want just a bell curve) (+ .64e-4 (* .1565 (- (exp (- 1.0 (cos (* 2 pi x)))) 1.0)))) |
But the most flexible method is to use env-any. env-any takes the env generator that produces the underlying envelope, and a function to "connect the dots", and returns the new envelope applying that connecting function between the break points. For example, say we want to square each envelope value:
(with-sound () (let ((e (make-env '(0 0 1 1 2 .25 3 1 4 0) :duration 0.5))) (do ((i 0 (1+ i))) ((= i 44100)) (outa i (env-any e (lambda (y) (* y y))))))) ;; or connect the dots with a sinusoid: (define (sine-env e) (env-any e (lambda (y) (* 0.5 (+ 1.0 (sin (+ (* -0.5 pi) (* pi y)))))))) (with-sound () (let ((e (make-env '(0 0 1 1 2 .25 3 1 4 0) :duration 0.5))) (run (lambda () (do ((i 0 (1+ i))) ((= i 44100)) (outa i (sine-env e))))))) |
|
The env-any connecting function takes one argument, the current envelope value treated as going between 0.0 and 1.0 between each two points. It returns a value that is then fitted back into the original (scaled, offset) envelope. There are a couple more of these functions in generators.scm, one to apply a blackman4 window between the points, and the other to cycle through a set of exponents.
mus-reset of an env causes it to start all over again from the beginning. mus-reset is called internally if you use mus-scaler to set an env's scaler (and similarly for offset and length). To jump to any position in an env, use mus-location. Here's a function that uses these methods to apply an envelope over and over:
(define (strum e) (map-channel (lambda (y) (if (> (mus-location e) (mus-length e)) ; mus-length = dur (mus-reset e)) ; start env again (default is to stick at the last value) (* y (env e))))) ;;; (strum (make-env (list 0 0 1 1 10 .6 25 .3 100 0) :length 2000)) |
To copy an env while changing one aspect (say duration), it's simplest to use make-env:
(define (change-env-dur e dur) (make-env (mus-data e) :scaler (mus-scaler e) :offset (mus-offset e) :base (mus-increment e) :duration dur)) |
make-env signals an error if the envelope breakpoints are either out of order, or an x axis value occurs twice. The default error handler in with-sound may not give you the information you need to track down the offending note, even given the original envelope. Here's one way to trap the error and get more info (in this case, the begin time and duration of the enclosing note):
(define* (make-env-with-catch beg dur :rest args) (catch 'mus-error (lambda () (apply make-env args)) (lambda args (display (format #f ";~A ~A: ~A~%" beg dur args))))) |
In cases like this, remember to set run-safety to 1 so that with-sound will be able to display the stack backtrace when an error occurs.
An envelope applied to the amplitude of a signal is a form of amplitude modulation, and glissando is frequency modulation. Both cause a broadening of the spectral components:
|
| |
multiplied by sinusoid at 50Hz | sinusoid from 100Hz to 300Hz |
The amplitude case reflects the spectrum of the amplitude envelope all by itself, translated (by multiplication) up to the sinusoid's pitch. The sidebands are about 1 Hz apart (the envelope takes 1 second to go linearly from 0 to 1). Despite appearances, we hear this (are you sitting down?) as a changing amplitude, not a timbral mess. Spectra can be tricky to interpret, and I've tried to choose parameters for this display that emphasize the broadening.
| Envelopes | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
table-lookup |
make-table-lookup :optional-key
(frequency *clm-default-frequency*) ; table repetition rate in Hz
(initial-phase 0.0) ; starting point in radians (pi = mid-table)
wave ; a vct containing the signal
(size *clm-table-size*) ; table size if wave not specified
(type mus-interp-linear) ; interpolation type
table-lookup tl :optional (fm-input 0.0)
table-lookup? tl
make-table-lookup-with-env :optional-key frequency env size
(with-sound (:play #t)
(let ((gen (make-table-lookup 440.0
:wave (partials->wave '(1 .5 2 .5)))))
(do ((i 0 (1+ i)))
((= i 44100))
(outa i (* 0.5 (table-lookup gen))))))
|
with_sound(:play, true) do
gen = make_table_lookup(440.0,
:wave, partials2wave([1.0, 0.5, 2.0, 0.5]));
44100.times do |i|
outa(i, 0.5 * table_lookup(gen), $output)
end
end.output
|
table-lookup performs interpolating table lookup with a lookup index that moves
through the table at a speed set by make-table-lookup's "frequency" argument and table-lookup's "fm-input" argument.
That is, the waveform in the table is produced repeatedly, the repetition rate set by the frequency arguments.
Table-lookup scales its
fm-input argument to make its table size appear to be two pi.
The intention here is that table-lookup with a sinusoid in the table and a given FM signal
produces the same output as oscil with that FM signal.
The "type" argument sets the type of interpolation used: mus-interp-none,
mus-interp-linear, mus-interp-lagrange, or mus-interp-hermite.
make-table-lookup-with-env (defined in generators.scm) returns a new table-lookup generator with the envelope 'env' loaded into its table.
| mus-frequency | frequency in Hz |
| mus-phase | phase in radians |
| mus-data | wave vct |
| mus-length | wave size (no set!) |
| mus-interp-type | interpolation choice (no set!) |
| mus-increment | table increment per sample |
(let ((result (array-interp wave phase))) (set! phase (+ phase (hz->radians frequency) (* fm-input (/ (length wave) (* 2 pi))))) result)
In the past, table-lookup was often used for additive synthesis, so there are two functions that make it easier to load up various such waveforms:
partials->wave synth-data :optional wave-vct (norm #t) phase-partials->wave synth-data :optional wave-vct (norm #t)
The "synth-data" argument is a list or vct of (partial amp) pairs: '(1 .5 2 .25) gives a combination of a sine wave at the carrier (partial = 1) at amplitude .5, and another at the first harmonic (partial = 2) at amplitude .25. The partial amplitudes are normalized to sum to a total amplitude of 1.0 unless the argument "norm" is #f. If the initial phases matter (they almost never do), you can use phase-partials->wave; in this case the synth-data is a list or vct of (partial amp phase) triples with phases in radians. If "wave-vct" is not passed, these functions return a new vct.
(definstrument (simple-table dur) (let ((tab (make-table-lookup :wave (partials->wave '(1 .5 2 .5))))) (do ((i 0 (1+ i))) ((= i dur)) (outa i (* .3 (table-lookup tab)))))) |
table-lookup can also be used as a sort of "freeze" function, looping through a sound repeatedly, based on some previously chosen loop positions:
(define (looper start dur sound freq amp) (let* ((beg (seconds->samples start)) (end (+ beg (seconds->samples dur))) (loop-data (mus-sound-loop-info sound))) (if (or (null? loop-data) (<= (cadr loop-data) (car loop-data))) (throw 'no-loop-positions) (let* ((loop-start (car loop-data)) (loop-end (cadr loop-data)) (loop-length (1+ (- loop-end loop-start))) (sound-section (file->array sound 0 loop-start loop-length (make-vct loop-length))) (original-loop-duration (/ loop-length (mus-sound-srate sound))) (tbl (make-table-lookup :frequency (/ freq original-loop-duration) :wave sound-section))) ;; "freq" here is how fast we read (transpose) the sound — 1.0 returns the original (run (lambda () (do ((i beg (1+ i))) ((= i end)) (outa i (* amp (table-lookup tbl)))))))))) (with-sound (:srate 44100) (looper 0 10 "/home/bil/sf1/forest.aiff" 1.0 0.5)) |
And for total confusion, here's a table-lookup that modulates a sound where we specify the modulation deviation in samples:
(definstrument (fm-table file start dur amp read-speed modulator-freq index-in-samples) (let* ((beg (seconds->samples start)) (end (+ beg (seconds->samples dur))) (table-length (mus-sound-frames file)) (tab (make-table-lookup :frequency (/ read-speed (mus-sound-duration file)) :wave (file->array file 0 0 table-length (make-vct table-length)))) (osc (make-oscil modulator-freq)) (index (/ (* (hz->radians modulator-freq) 2 pi index-in-samples) table-length))) (run (lambda () (do ((i beg (1+ i))) ((= i end)) (outa i (* amp (table-lookup tab (* index (oscil osc)))))))))) |
Lessee.. there's a factor of table-length/(2*pi) in table-lookup, so that a table with a sinusoid behaves the same as an oscil even with FM; hz->radians adds a factor of (2*pi)/srate; so we've cancelled the internal 2*pi and table-length, and we have an actual deviation of mfreq*2*pi*index/srate, which looks like FM; hmmm. See srcer below for an src-based way to do the same thing.
There is one annoying problem with table-lookup: noise.
Say we have a sine wave in a table with L elements, and we want to read it at a frequency of
f Hz at a sampling rate of Fs. This requires that we read the table at locations that are multiples of
L * f / Fs. This is ordinarily not an integer (that is, we've fallen between the
table elements). We have no data between the elements, but we can make (plenty of)
assumptions about what ought to be there. In the no-interpolation case (type = mus-interp-none), we simply take the floor of
the table-relative phase, returning a squared-off sine-wave:
In addition to the sine at 100 Hz, we're getting lots of pairs of components, each pair centered around n * L * f, (10000 = 100 * 100 is the first),
and separated from it by f, (9900 and 10100),
and the amplitude of each pair is 1/(nL): -40 dB is 1/100 for the n=1 case.
This spectrum says "amplitude modulation" (the fast square wave times the slow sinusoid).
After scribbling a bit on the back of an envelope, we announce with a confident air that
the sawtooth error signal gives us the 1/n (it is a sum of sin nx/n), and its amplitude gives us the 1/L.
Now we try linear interpolation (mus-interp-linear), and get the same components as before, but
the amplitude is going (essentially) as 1.0 / (n * n * L * L). So the interpolation
reduces the original problem by a factor of n * L:
We can view this also as amplitude modulation: the sinusoid at frequency f times the little blip during each table sample at frequency L * f. Each component is at n * L * f, as before, and split in half by the modulation. Since L * f is normally a very high frequency, and sampling rates are not in the megahertz range (as in our examples), these components alias to such an extent that they look like noise, but they are noise only in the sense that we wish they weren't there.
The table length (L above) is the "effective" length. If we store an nth harmonic in the table, each period gets L/n elements (we want to avoid clicks caused by discontinuities between the first and last table elements), so the amplitude of the nth harmonic's noise components is higher (by n^2) than the fundamental's. We either have to use enormous tables or stick to low numbered partials. To keep the noise components out of sight in 16-bit output (down 90 dB), we need 180 elements per period. So a table with a 50th harmonic has to be at least length 8192. It's odd that the cutoff here is basically the same as in the waveshaping case; a 50-th harmonic is trouble in either case. (This leaves an opening for ncos and friends even when dynamic spectra aren't the issue).
We can try fancier interpolations. mus-interp-lagrange and mus-interp-hermite
reduce the components (which are at the same frequencies as before) by about another factor of L.
But these interpolations are expensive and ugly.
If you're trying to produce a sum of sinusoids, use polywave — it makes a monkey out of table lookup in every case.
spectr.clm has a steady state spectra of several standard orchestral instruments, courtesy of James A. Moorer. The drone instrument in clm-ins.scm uses table-lookup for the bagpipe drone. two-tab in the same file interpolates between two tables. See also grani and display-scanned-synthesis.
polywave, polyshape |
make-polywave :optional-key
(frequency *clm-default-frequency*)
(partials '(1 1)) ; a list of harmonic numbers and their associated amplitudes
(type mus-chebyshev-first-kind) ; Chebyshev polynomial choice
polywave w :optional (fm 0.0)
polywave? w
make-polyshape :optional-key
(frequency *clm-default-frequency*)
(initial-phase 0.0)
coeffs
(partials '(1 1))
(kind mus-chebyshev-first-kind)
polyshape w :optional (index 1.0) (fm 0.0)
polyshape? w
partials->polynomial partials :optional (kind mus-chebyshev-first-kind)
normalize-partials partials
mus-chebyshev-tu-sum x t-coeffs u-coeffs
mus-chebyshev-t-sum x t-coeffs
mus-chebyshev-u-sum x u-coeffs
(with-sound (:play #t)
(let ((gen (make-polywave 440.0
:partials '(1 .5 2 .5))))
(do ((i 0 (1+ i)))
((= i 44100))
(outa i (* 0.5 (polywave gen))))))
|
with_sound(:play, true) do
gen = make_polywave(440.0,
:partials, [1.0, 0.5, 2.0, 0.5]);
44100.times do |i|
outa(i, 0.5 * polywave(gen), $output)
end
end.output
|
44100 lambda: <{ len }>
440.0 '( 1 0.5 2 0.5 ) 1 make-polywave { gen }
len 0 ?do
i gen 0.0 polywave 0.5 f* *output* outa drop
loop
;
:play #t :channels 1 :srate 44100 with-sound
|
These two generators drive a sum of scaled Chebyshev polynomials with a cosine, creating a sort of cross between additive synthesis and FM; see "Digital Waveshaping Synthesis" by Marc Le Brun in JAES 1979 April, vol 27, no 4, p250. The basic idea is:
We can add scaled Tns (polynomials) to get the spectrum we want, producing in the simplest case an inexpensive additive synthesis. We can vary the peak amplitude of the input (cos theta) to get effects similar to those of FM. polyshape uses a prebuilt sum of Chebyshev polynomials, whereas polywave uses the underlying Chebyshev recursion. polywave is stable and noise-free even with high partial numbers (I've tried it with 16384 harmonics). The "partials" argument to the make function can be either a list or a vct. The "type" or "kind" argument determines which kind of Chebyshev polynomial is used internally: mus-chebyshev-first-kind (Tn) which produces a sum of cosines, or mus-chebyshev-second-kind (Un), which produces a sum of sines.
normalize-partials takes the list or vct of partial number and amplitudes, and returns a vct with the amplitudes normalized so that their magnitudes add to 1.0.
>(normalize-partials '(1 1 3 2 6 1))
#<vct[len=6]: 1.000 0.250 3.000 0.500 6.000 0.250>
>(normalize-partials (vct 1 .1 2 .1 3 -.2))
#<vct[len=6]: 1.000 0.250 2.000 0.250 3.000 -0.500>
partials->polynomial takes a list or vct of partial numbers and amplitudes and returns the Chebyshev polynomial coefficients that produce that spectrum. These coefficients can be passed to polyshape (the coeffs argument), or used directly by polynomial (there are examples of both below).
>(partials->polynomial '(1 1 3 2 6 1))
#<vct[len=7]: -1.000 -5.000 18.000 8.000 -48.000 0.000 32.000>
>(partials->polynomial '(1 1 3 2 6 1) mus-chebyshev-second-kind)
#<vct[len=7]: -1.000 6.000 8.000 -32.000 0.000 32.000 0.000>
>(partials->polynomial (vct 1 .1 2 .1 3 -.2))
#<vct[len=4]: -0.100 0.700 0.200 -0.800>
mus-chebyshev-tu-sum and friends perform the same function as partials->polynomial, but use the much more stable and accurate underlying recursion (see below for a long-winded explanation). They are the innards of the polywave and polyoid generators. The arguments are "x" (normally a phase), and one or two vcts of component amplitudes. These functions makes it easy to do additive synthesis with any number of harmonics (I've tried 16384), each with arbitrary initial-phase and amplitude, and each harmonic independently changeable in phase and amplitude at run-time simply by setting a vct value.
| mus-frequency | frequency in Hz |
| mus-scaler | index (polywave only) |
| mus-phase | phase in radians |
| mus-data | polynomial coeffs |
| mus-length | number of partials |
| mus-increment | frequency in radians per sample |
(let ((result (polynomial wave (cos phase)))) (set! phase (+ phase (hz->radians frequency) fm)) result)
In its simplest use, waveshaping is additive synthesis:
(with-sound () (let ((wav (make-polyshape :frequency 500.0 :partials '(1 .5 2 .3 3 .2)))) (do ((i 0 (1+ i))) ((= i 40000)) (outa i (polyshape wav))))) |
|
Say we want every third harmonic at amplitude 1/sqrt(harmonic-number) for 5 harmonics total:
(with-sound (:clipped #f :statistics #t :play #t :scaled-to .5) (let* ((gen (make-polywave 200 (let ((harms (make-vct (* 5 2)))) ; 5 harmonics, 2 numbers for each (do ((k 1 (+ k 3)) (i 0 (+ i 2))) ((= i (* 5 2))) (vct-set! harms i k) ; harmonic number (k*freq) (vct-set! harms (+ i 1) (/ 1.0 (sqrt k)))) ; harmonic amplitude harms))) (ampf (make-env '(0 0 1 1 10 1 11 0) :duration 1.0 :scaler .5))) (do ((i 0 (1+ i))) ((= i 44100)) (outa i (* (env ampf) (polywave gen)))))) |
See animals.scm for many more examples along these lines. normalize-partials makes sure that the component amplitudes (magnitudes) add to 1.0. Its argument can be either a list or vct, but it always returns a vct. The fm-violin uses polyshape for the multiple FM section in some cases. The pqw and pqwvox instruments use both kinds of Chebyshev polynomials to produce single side-band spectra. Here is a somewhat low-level example:
(definstrument (pqw start dur spacing carrier partials) (let* ((spacing-cos (make-oscil spacing (/ pi 2.0))) (spacing-sin (make-oscil spacing)) (carrier-cos (make-oscil carrier (/ pi 2.0))) (carrier-sin (make-oscil carrier)) (sin-coeffs (partials->polynomial partials mus-chebyshev-second-kind)) (cos-coeffs (partials->polynomial partials mus-chebyshev-first-kind)) (beg (seconds->samples start)) (end (+ beg (seconds->samples dur)))) (run (lambda () (do ((i beg (1+ i))) ((= i end)) (let ((ax (oscil spacing-cos))) (outa i (- (* (oscil carrier-sin) (oscil spacing-sin) (polynomial sin-coeffs ax)) (* (oscil carrier-cos) (polynomial cos-coeffs ax)))))))))) (with-sound () (pqw 0 1 200.0 1000.0 '(2 .2 3 .3 6 .5))) |
|
We can use waveshaping to make a band-limited triangle-wave:
(def-optkey-fun (make-band-limited-triangle-wave (frequency *clm-default-frequency*) (order 1))
(let ((freqs '()))
(do ((i 1 (1+ i))
(j 1 (+ j 2)))
((> i order))
(set! freqs (cons (/ 1.0 (* j j)) (cons j freqs))))
(make-polywave frequency :partials (reverse freqs))))
(define* (band-limited-triangle-wave gen :optional (fm 0.0))
(polywave gen fm))
|
Band-limited square or sawtooth waves:
(definstrument (bl-saw start dur frequency order) (let* ((norm (if (= order 1) 1.0 ; these peak amps were determined empirically (if (= order 2) 1.3 ; actual limit is supposed to be pi/2 (G&R 1.441) (if (< order 9) 1.7 ; but Gibbs phenomenon pushes it to 1.851 1.852)))) (freqs '())) (do ((i 1 (1+ i))) ((> i order)) (set! freqs (cons (/ 1.0 (* norm i)) (cons i freqs)))) (let* ((gen (make-polywave frequency :partials (reverse freqs) :type mus-chebyshev-second-kind)) (beg (seconds->samples start)) (end (+ beg (seconds->samples dur)))) (run (lambda () (do ((i beg (1+ i))) ((= i end)) (outa i (polywave gen)))))))) |
| The "fm" argument to these generators is intended mainly for vibrato and frequency envelopes. If you use it for frequency modulation, you'll notice that the result is not the necessarily same as applying that modulation to the equivalent bank of oscillators, but it is the same as (for example) applying it to an ncos generator, or most of the other generators (table-lookup, nsin, etc). The polynomial in cos(x) produces a sum of cos(nx) for various "n", but if "x" is itself a sinusoid, its effective index includes the factor of "n" (the partial number). This is what you want if all the components should move together (as in vibrato). If you need better control of the FM spectrum, use a bank of oscils where you can set each index independently. Here we used '(1 1 2 1 3 1) and polyshape with sinusoidal FM with an index of 1. |
|
The same thing happens if you use polyshape or ncos (or whatever) as the (complex) modulating signal to an oscil (the reverse of the situation above). The effective index of each partial is divided by the partial number (and in ncos, for example, the output is scaled to be -1..1, so that adds another layer of confusion). There's a longer discussion of this under ncos.
To get the FM effect of a spectrum centered around a carrier, multiply the waveshaping output by the carrier (the 0Hz term gives us the carrier):
(with-sound () (let ((modulator (make-polyshape 100.0 :partials (list 0 .4 1 .4 2 .1 3 .05 4 .05))) (carrier (make-oscil 1000.0))) (do ((i 0 (1+ i))) ((= i 20000)) (outa i (* .5 (oscil carrier) (polyshape modulator)))))) |
The simplest way to get get changing spectra is to interpolate between two or more sets of coefficients.
(+ (* interp (polywave p1 ...)) ; see animals.scm for many examples
(* (- 1.0 interp) (polywave p2 ...)))
Or use mus-chebyshev-*-sum and set the component amplitudes directly:
(with-sound () (let* ((dur 1.0) (samps (seconds->samples dur)) (coeffs (vct 0.0 0.5 0.25 0.125 0.125)) (x 0.0) (incr (hz->radians 100.0)) (ampf (make-env '(0 0 1 1 10 1 11 0) :duration dur :scaler .5)) (harmf (make-env '(0 .125 1 .25) :duration dur))) (do ((i 0 (1+ i))) ((= i samps)) (let ((harm (env harmf))) (vct-set! coeffs 3 harm) (vct-set! coeffs 4 (- .25 harm)) (outa i (* (env ampf) (mus-chebyshev-t-sum x coeffs))) (set! x (+ x incr)))))) |
But we can also vary the index (the amplitude of the cosine driving the sum of polynomials), much as in FM. The kth partial's amplitude at a given index, given a set h[k] of coefficients, is:
(This formula is implemented by cheby-hka in dsp.scm). The function traced out by the harmonic (analogous to the role the Bessel function Jn plays in FM) is a polynomial in the index whose order depends on the number of coefficients. When the index is less than 1.0, energy appears in lower harmonics even if they are not included in the index=1.0 list:
> (cheby-hka 3 0.25 (vct 0 0 0 0 1.0 1.0))
-0.0732421875
> (cheby-hka 2 0.25 (vct 0 0 0 0 1.0 1.0))
-0.234375
> (cheby-hka 1 0.25 (vct 0 0 0 0 1.0 1.0))
1.025390625
> (cheby-hka 0 0.25 (vct 0 0 0 0 1.0 1.0))
1.5234375
Below we sweep the index from 0.0 to 1.0 (sticking at 1.0 for a moment at the end), with a partials list of '(11 1.0 20 1.0). These numbers were chosen to show that the even and odd harmonics are independent:
(with-sound () (let ((gen (make-polyshape 100.0 :partials (list 11 1 20 1))) (ampf (make-env '(0 0 1 1 20 1 21 0) :scaler .4 :length 88200)) (indf (make-env '(0 0 1 1 1.1 1) :length 88200))) (do ((i 0 (1+ i))) ((= i 88200)) (outa i (* (env ampf) (polyshape gen (env indf))))))) | |
|
|
You can see there's another annoying "gotcha": the DC component can be arbitrarily large. If we don't counteract it in some way, we lose dynamic range, and we get a big click when the generator stops. In addition (as the right graph shows, although in this case the effect is minor), the peak amplitude is dependent on the index. We can reduce this problem somewhat by changing the signs of the harmonics to follow the pattern + + - -:
(list 1 .5 2 .25 3 -.125 4 -.125) ; squeeze the amplitude change toward index=0
but now the peak amplitude is hard to predict (it's .6242 in this example). Perhaps flatten-partials would be a better choice here. To follow an amplitude envelope despite a changing index, we can use a moving-max generator (from generators.scm):
(with-sound () (let ((gen (make-polyshape 1000.0 :partials (list 1 .25 2 .25 3 .125 4 .125 5 .25))) (indf (make-env '(0 0 1 1 2 0) :duration 2.0)) ; index env (ampf (make-env '(0 0 1 1 2 1 3 0) :duration 2.0)) ; desired amp env (mx (make-moving-max 256)) ; track actual current amp (samps (seconds->samples 2.0))) (do ((i 0 (1+ i))) ((= i samps)) (let ((val (polyshape gen (env indf)))) ; polyshape with index env (outa i (/ (* (env ampf) val) (max 0.001 (moving-max mx val)))))))) |
The harmonic amplitude formula for the Chebyshev polynomials of the second kind is:
On a related topic, if we drive the sum of Chebyshev polynomials with more than one sinusoid, we get sum and difference tones, much as in complex FM:
(with-sound () (let ((pcoeffs (partials->polynomial (vct 5 1))) (gen1 (make-oscil 100.0)) (gen2 (make-oscil 2000.0))) (do ((i 0 (1+ i))) ((= i 44100)) (outa i (polynomial pcoeffs (+ (* 0.5 (oscil gen1)) (* 0.5 (oscil gen2)))))))) ![]() |
|
This kind of output is typical; I get the impression that the cross products are much more noticeable here than in FM. Of course, we can take advantage of that:
(with-sound (:channels 2) (let* ((dur 2.0) (samps (seconds->samples dur)) (p1 (make-polywave 800 (list 1 .1 2 .3 3 .4 5 .2))) (p2 (make-polywave 400 (list 1 .1 2 .3 3 .4 5 .2))) (interpf (make-env '(0 0 1 1) :duration dur)) (p3 (partials->polynomial (list 1 .1 2 .3 3 .4 5 .2))) (g1 (make-oscil 800)) (g2 (make-oscil 400)) (ampf (make-env '(0 0 1 1 10 1 11 0) :duration dur))) (do ((i 0 (1+ i))) ((= i samps)) (let ((interp (env interpf)) (amp (env ampf))) ;; chan A: interpolate from one spectrum to the next directly (outa i (* amp (+ (* interp (polywave p1)) (* (- 1.0 interp) (polywave p2))))) ;; chan B: interpolate inside the sum of Tns! (outb i (* amp (polynomial p3 (+ (* interp (oscil g1)) (* (- 1.0 interp) (oscil g2)))))))))) |
If we use an arbitrary sound as the argument to the polynomial, the output is a brightened or distorted version of the original:
(define (brighten-slightly coeffs)
(let ((pcoeffs (partials->polynomial coeffs))
(mx (maxamp)))
(map-channel
(lambda (y)
(* mx (polynomial pcoeffs (/ y mx)))))))
but watch out for clicks from the DC component if any of the "n" in the Tn are even. When I use this idea, I either use only odd numbered partials in the partials->polynomial list, or add an amplitude envelope to make sure the result ends at 0. I suppose you could also subtract out the DC term (coeffs[0]), but I haven't tried this.
If you push the polyshape generator into high harmonics (above say 30), you'll
run into numerical trouble (the polywave generator is immune to this bug).
Where does the trouble lie?
The polynomials are related to each other
via the recursion:
, so the first
few polynomials are:
![]() |
![]() |
The first coefficient is 2^n or 2^(n-1). This is bad news if "n" is large because we are expecting a bunch of huge numbers to add up to something in the vicinity of 0.0 or 1.0. If we're using 32-bit floats, the first sign of trouble comes when the order is around 26. If you look at some of the coefficients, you'll see numbers like -129026688.000 (in the 32 bit case), which should be -129026680.721 — we have run out of bits in the mantissa! Even if we build Snd --with-doubles, we can only push the order up to around 46. polywave, on the other hand, builds up the sum of sines from the underlying recursion, which is only slightly slower than using the polynomial, and it is not bothered by these numerical problems I have run polywave with 16384 harmonics, and the maximum error compared to the equivalent sum of sinusoids was around 5.0e-12.
Since it is primarily used for additive synthesis, and we can always do that with oscils or table-lookup, we might ask why we'd want polywave at all. Leaving aside speed (the Chebyshev computation is 10 to 20 times faster than the equivalent sum of oscils) and memory (the defunct table-lookup based waveshape generator and table-lookup itself use a table that has to be loaded), the main reason to use polywave is accuracy. polywave produces output that is as clean as the equivalent sum of oscils, whereas table-lookup and poor old waveshape, both of which interpolate into a sampled version of the desired function, are noisy. To make the difference almost appalling, here are spectra comparing a sum of oscils, polyshape, (table-lookup based) waveshape, and table-lookup.
|
The table size is 512, but that almost doesn't matter; you'd have to use a table size of at least 8192 to approach the oscil and polyshape cases. The FFT size is 1048576, with no data window ("rectangular"), and the y-axis is in dB, going down to -120 dB. The choice of fft window can make a big difference; using no window, but a huge fft seems like the least confusing way to present this result. Notice the lower peaks in the table-lookup case. partials->wave puts n periods of the nth harmonic in the table, so the nth harmonic has an effective table length of table-length/n. n * 1/n = 1, so all our components have their first interpolation noise peak centered (in this case) around 7100 Hz ((512 * 100) mod 22050). Since the 1600 Hz component has an effective table size of only 32 samples, it creates big sidebands at 5500 Hz and 8700 Hz. The 800 Hz component makes smaller peaks (by a factor of 4, since this is proportional to n^2) at 6300 Hz and 7900 Hz, and the 100 Hz cases are at 7000 Hz and 7200 Hz (down in amplitude by 16^2). The highest peaks are down only 60 dB. See table-lookup for more discussion of interpolation noise (it's actually amplitude modulation of the stored signal and the linear interpolating signal with severe aliasing). The waveshaping noise is much worse because the polynomial is so sensitive numerically. Here is a portion of the error signal at the point where the driving sinusoid is at its maximum:
|
See also polyoid and noid in generators.scm.
sawtooth-wave, triangle-wave, pulse-train, square-wave |
make-triangle-wave :optional-key
(frequency *clm-default-frequency*) (amplitude 1.0) (initial-phase pi)
triangle-wave s :optional (fm 0.0)
triangle-wave? s
make-square-wave :optional-key
(frequency *clm-default-frequency*) (amplitude 1.0) (initial-phase 0)
square-wave s :optional (fm 0.0)
square-wave? s
make-sawtooth-wave :optional-key
(frequency *clm-default-frequency*) (amplitude 1.0) (initial-phase pi)
sawtooth-wave s :optional (fm 0.0)
sawtooth-wave? s
make-pulse-train :optional-key
(frequency *clm-default-frequency*) (amplitude 1.0) (initial-phase (* 2 pi))
pulse-train s :optional (fm 0.0)
pulse-train? s
(with-sound (:play #t)
(let ((gen (make-triangle-wave 440.0)))
(do ((i 0 (1+ i)))
((= i 44100))
(outa i (* 0.5
(triangle-wave gen))))))
|
with_sound(:play, true) do
gen = make_triangle_wave(440.0);
44100.times do |i|
outa(i, 0.5 * triangle_wave(gen),
$output)
end
end.output
|
44100 lambda: <{ len }>
440.0 make-triangle-wave { gen }
len 0 ?do
i gen 0.0 triangle-wave 0.5 f* *output* outa drop
loop
;
:play #t :channels 1 :srate 44100 with-sound
|
| mus-frequency | frequency in Hz |
| mus-phase | phase in radians |
| mus-scaler | amplitude arg used in make-<gen> |
| mus-width | width of square-wave pulse (0.0 to 1.0) |
| mus-increment | frequency in radians per sample |
One popular kind of vibrato is: (+ (triangle-wave pervib) (rand-interp ranvib))
These generators produce some standard old-timey wave forms that are still occasionally useful (well, triangle-wave is useful; the others are silly). sawtooth-wave ramps from -1 to 1, then goes immediately bac