Wind Chimes
In this article, we are going to create a nice soothing wind chime out of resonator filters. The algorithm is based on Perry Cook's seminal PHISEM paper. The cleverness of the algorithm is the fact that we get polyphonic overlap for free, due to generating strike envelopes and body resonances with linear systems, that is, filters.
Some Data
After some ducking around the internet, I found a nice table of wind chime resonances. Let's transcribe those into a Kronos matrix.
I also made up intuitive values for bandwidth and amplitude per mode, very casually looking at the waterfall plots.
harmonics = [(244 663 1272 2050)
(278 753 1441 2314)
(312 850 1625 2600)
(330 890 1700 2712)
(371 1000 3031 4351)]
mode-bw = (0.003 0.005 0.01 0.02)
mode-amp = (0.100 1 0.05 0.02)
Chime Model
We model each chime with a parallel resonator bank, each filter producing one harmonic. As a computational trick we are going to pull all the harmonics of a chime from a single data-parallel vectored filter.
Kronos does data-parallelism for us with help from the Vector
package. Our Chime
function is called with the excitation signal and a list of harmonics. We then put the harmonics in a vector with Vector:Cons
, along with the globally defined bandwidths and amplitudes.
When fed with a vector, Filter:Resonator
gets vectorized and outputs several resonances in parallel. We obtain the final, single-channel version by summing the vector elements with Vector:Horizontal
by the Add
reduction operation.
For now, I am just going to use a narrow low frequency pulse wave as the excitation.
Import Vector
Import Filter
Import Gen
Chime(excitation harmonics) {
; vectorize data
f = Vector:Cons(harmonics)
bw = Vector:Cons(mode-bw)
a = Vector:Cons(mode-amp)
; compute resonators
Vector:Horizontal(
Add
Filter:Resonator(
excitation
f f * bw) * a)
}
snd = Chime(Gen:Pulse(1 0.01) First(harmonics))
Strike Model
Time to refine the strike model. We are going to generate impulses randomly, at a specified rate. The sequence is pseudo-random - we need to expose the sequence seed
in order to generate different sequences.
Each impulse becomes an envelope, smeared in time. We would also like to allow the envelopes to overlap in time, despite there being just a single generator. That can be done by transforming the impulse into an envelope with a lowpass filter. Because the filter is a linear system, it convolves each impulse into a copy of its own impulse response, all of them happily, linearly overlapping.
However, we are not interested in losing high frequency energy from the lowpass filter, just its time-domain shape. We can restore the broad-band nature of the excitation by modulating the envelope with a noise source. Here, we can conveniently reuse the noise generator we used to generate the impulses in the first place.
Strike(seed rate decay-rate) {
; white-noise stream
noise = Gen:Random(seed)
; probability of triggering per sample
thresh = rate / Rate-of(noise)
; generate an impulse at said probability
impulse = (1 / thresh) & (noise < thresh)
; generate envelope shape and modulate with
; noise to recover high frequency energy
Filter:Lowpass(impulse decay-rate 0) * noise
}
snd = Chime(Strike(1 1 30) First(harmonics))
Putting it Together
Finally, let's make a bunch of distinct excitation sequences and feed each of them into a different chime model. We can do that conveniently with higher order functions Map
and Reduce
from the Algorithm
package.
; we'd like Algorithm: prefix to be implicit
Use Algorithm
strike-rate = 0.27
strike-decay = 12
; how many chimes do we have?
N = Arity(harmonics)
N#5
; let's generate a seed for each
seeds = Count(N 1000000)
seeds1000000 1000001 1000002 1000003 1000004 nil
; generate a list of strike signals
strikes = Map(seed => Strike(seed strike-rate strike-decay)
seeds)
; combine strikes and harmonics with the chime model
chimes = Zip-With(Chime strikes harmonics)
; summing mixer
snd = Average(chimes)