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)