Creating a glass harmonica emulator with Sonic Pi

Recently I visited the Musical Instrument Museum in Brussels, and amongst the 800 instruments I enjoyed listening to a glass harmonica, originally developed by Benjamin Franklin in 1761. The instrument  consists of a series of glass bowls mounted on a horizontal rod which is rotated. The surface of the bowls are kept wet and they are played by the friction of a finger pressing on the rim as they rotate. Here is a sketch of Franklin’s design.

Benjamin_Franklin's_glass_harmonica_(LoC)_edited

You can get an idea of what they sound like by visiting this link.
I had previously worked on creating sampled based voices for a piano, flute and xylophone on Sonic Pi which are detailed in other articles on my blog. But how was I to go about creating an instrument for a glass harmonica. Luckily one of the built in samples called :ambi_glass_rub is of a wine glass being rubbed to produce a note. A simple experiment playing the sample and comparing it to a note played by the :beep synth established that is was approximately of pitch :fs5, (F sharp in octave 5). For the previous voices (piano xylophone etc) I had used several samples covering the range of the instrument. In this case there was only one, and I was not sure hwo it would sound scaled over the entire frequency range. Basically you adjust the rate: at which the sample is played. Playing it at rate: 2 will double the frequency that you hear. As long as the sample is long enough you can raise the rate quite a bit and it will still sound for the duration of note you require. I would need the rate to go up to 5.55 to get a top :c8 and down to about 0.12 to get a low :f2 In fact, although this note will play it doesn’t sound all that like a rubbed piece of glass.
I put the frequencies of the notes on a piano onto a spreadsheet, and used this to calculate the factor by which the rate had to be adjusted for each note in the range :f2 up to :c8. These values, linked to the relevant note were then put into an array called sam (short for sample), and I also added to that array alternatives for some of the notes so that they could either be addressed as say :gs4 or :ab4 Depending on the tuning mode you use you could consider these the same.
I then wrote a fairly short function to play a defined note using this array to look up the rate required.

define :pl do |n,d=0.2,pan=0,v=0.8|
  sample i,rate: (sam.assoc(n)[1]),attack: d*0.1,sustain: d*0.85,release: d*0.1,amp: v,pan: pan,start: 0.04
end

In fact I played around quite a lot with the envelope settings and also adjusted the start position of the sample to get the smoothest ethereal sound I could. There are no “correct” values and they can be adjusted to taste. The term sam.assoc(n)[1] retrieves the rate factor for the note specified in n. i is the sample name :ambi_glass_rub, and d the duration of the note. In fact it overruns the allotted time d, which helps the notes to merge together.

To allow maximum flexibility I decided to write in a mechanism to allow for transposing. It was not possible to use the with_transpose command and I needed to add two functions, one to convert a midi number to a note symbol (called ntosym), and the other (called tr) to do the transposition using the ntosym function.

#converts a number to the equivalent note symbol
#nb no error checking included
define :ntosym do |n|
  @note=n % 12
  @octave = n / 12 - 1
  lookup_notes = {
    0  => :c,
    1  => :cs,
    2  => :d,
    3  => :ds,
    4  => :e,
    5  => :f,
    6  => :fs,
    7  => :g,
    8  => :gs,
    9  => :a,
    10 => :as,
  11 => :b}
  return (lookup_notes[@note].to_s + @octave.to_s).to_sym
end
#tr uses ntosym to work out transposed note symbol for a given shift
define :tr do |nv,sh|
  if sh ==0 then
    return nv
  else
    return ntosym(note(nv)+sh)
  end
end

ntosym works out the octave associated with a midi note number by doing an integer division by 12 and subtracting one. The position in the octave is worked out by taking the remainder when the number is divided by twelve. The note name is looked up from a list and then the symbolic name is generated by adding the octave number after then note name and converting the result to a symbol.
The tr function takes a note name nv, adds the transposition and then returns the answer converted to a a symbolic name again. If the transposition shift is 0 then it just returns the original note symbol.

The final function needed was one to play two associated arrays of notes and their durations, in a manner which I used in most of my music programs. It is slightly different from the synth version to accommodate the fact that we are using a sample based instrument.

#function plays an array of note and duration values (nt and dur), with parameters for transposition, volume and pan
define :plarray do |nt,dur,sh=0,vol=0.4,pan=0|
  nt.zip(dur).each do |n,d|
    if n != :r then
      pl(tr(n,sh),d,pan,vol)
    end
    sleep d
  end
end

I zip the two arrays of notes and durations together and then traverse then at the same time, taking corresponding note and duration values, which are then sent to the pl function to be played. If the note symbol is an :r then this step is missed out. Once the sample play has been initiated the function sleeps for the duration of the note before going back to fetch the next note ,duration pair of values. Notice an input parameter for any transposition (set to 0 by default) is included, also volume and pan settings for each stream played in this way.

And so to using the new instrument. I decided to render Mozart’s Adagio in C which he wrote especially for solo Glass Armonica (to give it its original correct name). He wrote this in 1791. It is an Adagio so that the slow pace give the notes time to sound with their full sonorous effect. It is quite a short piece, with two repeated sections. The score is shown below.

Mozart617ab

There are up to 5 notes playing at any one time so I used 5 single note parts played together using plarray by placing them in different threads which all started together.
I set up arrays n1 to n5 with associated note duration arrays d1 to d5 for the first section, and arrays n1b to n5d and d1b to d5b for the second section. This is achieved with a function named sec. It is used in conjunction with lar which is an array list containing the pairs of note and duration arrays which must be played together. By using this, the same function can be used to play the first section, and later the second section by using an offset p so that the second set of note duration arrays are used. Below the definition you can see how the sections are played, and also that I have used both a reverb and a lowpassfilter effect. These both help to make the sound more ethereal.

#store a list of arrays and durations to be played
lar = [n1,d1,n2,d2,n3,d3,n4,d4,n5,d5]
#add these parts to the lar list
lar.concat [n1b,d1b,n2b,d2b,n3b,d3b,n4b,d4b,n5b,d5b]

#sec is defined to play 5 pairs of note/duration arrays. p is the offset in lar list
#sh sets transposition
#parts played together using threads
define :sec do |p,sh=0,amp=0.4|
  in_thread do
    plarray(lar[8+p],lar[9+p],sh,amp,0.6)
  end
  in_thread do
    plarray(lar[6+p],lar[7+p],sh,amp,0.6)
  end
  in_thread do
    plarray(lar[4+p],lar[5+p],sh,amp,-0.6)
  end
  in_thread do
    plarray(lar[2+p],lar[3+p],sh,amp,-0.6)
  end
  plarray(lar[0+p],lar[1+p],sh,amp,-0.6)
end

#now play first and second sections
#shift set at start adjusts transpose. 0 and 10 are offsets in lar and last parameter is volume
with_fx :reverb, room: 0.6 do
  with_fx :lpf, cutoff: 85 do
    sec(0,shift,0.4)
    sec(0,shift,0.3)
    sec(10,shift,0.3)
    sec(10,shift,0.4)
  end
end

The complete program is shown below. It will work on both the Mac and RaspberryPi versions of Sonic Pi (tested on v2.0.1) {close to size limit on a Mac. Can delete comments if you want more room}

#Sonic Pi plays Mozart's Adagio in C for Glass Harmonica, programmed by Robin Newman October 2014
#the program sets up the ambi_glass_rub sample as a musical instrument playable just like a
#built in synth. notes are played by adjusting the rate of the sample and calculating the value
#required from a table of note frequencies. The rates for each note are stored in an array.
#duplicate values are put in for sharps and flats eg :fs5 and :gb5
#a function pl performs in a similar fashion to the play command for a synth
#you can play with the shift setting, the speed setting s, the envelope (varying the attack for example)
#and the reverb setting (fx_effects :reverb and the volume for each section (in the call to sec)
#also I have added an lpf effect, and chopped the start of the sample
i = :ambi_glass_rub
s=1.0/9.333 #sets speed about 70bpm
shift = 0 #set transpose if required
load_sample i
#set up array of notes and associated rate value
sam = [[:f2,0.117984321388561],[:fs2,0.124999966215714],[:g2,0.132432914543324],[:gs2,0.140307491057299]]
sam.concat [[:a2,0.14865085832357],[:as2,0.157490178908065],[:b2,0.16685518298245],[:c3,0.176776952089828]]
sam.concat [[:cs3,0.187287919144744],[:d3,0.198424571176058],[:ds3,0.210223395212632],[:e3,0.222724932397644]]
sam.concat [[:f3,0.235968372502835],[:fs3,0.24999966215714],[:g3,0.264866099360936],[:gs3,0.280614982114599]]
sam.concat [[:a3,0.297301716647139],[:as3,0.31498035781613],[:b3,0.333710365964899],[:c4,0.353553904179657]]
sam.concat [[:cs4,0.374577189660927],[:d4,0.396850493723555],[:ds4,0.420448141796702],[:e4,0.445449864795287]]
sam.concat [[:f4,0.471936745005669],[:fs4,0.49999932431428],[:g4,0.529730847350434],[:gs4,0.561231315600637]]
sam.concat [[:a4,0.594603433294279],[:as4,0.629960715632259],[:b4,0.66741938055836],[:c5,0.707106456987874]]
sam.concat [[:cs5,0.749153027950415],[:d5,0.793700987447111],[:ds5,0.840896283593405],[:e5,0.890898378219136]]
sam.concat [[:f5,0.943873490011338],[:fs5,1],[:g5,1.05946304607231],[:gs5,1.12246127982984]]
sam.concat [[:a5,1.18920686658856],[:as5,1.25992143126452],[:b5,1.33484011248816],[:c6,1.41421021123287]]
sam.concat [[:cs6,1.49830605590083],[:d6,1.58740197489422],[:ds6,1.68179526992969],[:e6,1.78179675643827]]
sam.concat [[:f6,1.8877442772798],[:fs6,2.00000270274288],[:g6,2.11892338940173],[:gs6,2.24492526240255]]
sam.concat [[:a6,2.37841373317711],[:as6,2.51984826801479],[:b6,2.66967481949056],[:c7,2.82842042246574]]
sam.concat [[:cs7,2.99661211180166],[:d7,3.17480394978844],[:ds7,3.36359053985938],[:e7,3.56359351287654]]
sam.concat [[:f7,3.77550206827399],[:fs7,4.00000540548576],[:g7,4.23784677880347],[:gs7,4.4898505248051]]
sam.concat [[:a7,4.75682746635423],[:as7,5.0396830223152],[:b7,5.33936315269551],[:c8,5.65685435864587]]
puts sam
#set up lists of flats and the associated sharp with which they will be played
flat=[:gb2,:ab2,:bb2,:cb3,:db3,:eb3,:fb3,:gb3,:ab3,:bb3,:cb3,:db4,:eb4,:fb4,:gb4,:ab4,:bb4,:cb4,:db5,:eb5,:fb5,:gb5,:ab5,:bb5,:cb5,:db6,:eb6,:fb6,:gb6,:ab6,:bb6,:cb7,:db7,:eb7,:fb7,:gb7,:ab7,:bb7,:cb8]
sharp=[:fs2,:gs2,:as2,:b2,:cs3,:ds3,:e3,:fs3,:gs3,:as3,:b3,:cs4,:ds4,:e4,:fs4,:gs4,:as4,:b4,:cs5,:ds5,:e5,:fs5,:gs5,:as5,:b5,:cs6,:ds6,:e6,:fs6,:gs6,:as6,:b6,:cs7,:ds7,:e7,:fs7,:gs7,:as7,:b7]
#add es and bs with aliases
flat.concat [:es2,:es3,:es4,:es5,:es6,:es7,:bs2,:bs3,:bs4,:bs5,:bs6,:bs7]
sharp.concat [:f2,:f3,:f4,:f5,:f6,:f7,:c3,:c4,:c5,:c6,:c7,:c8]
#initialise array for extra values
extra=[]

flat.zip(sharp).each do |f,s|
  # adds element with flat name and rate factor looked up from associated sharp entry
  extra.concat [[f,(sam.assoc(s)[1])]]
end
sam = sam + extra #add extras in

s
#set up function to play a given sample note.
define :pl do |n,d=0.2,pan=0,v=0.8|
  sample i,rate: (sam.assoc(n)[1]),attack: d*0.1,sustain: d*0.85,release: d*0.1,amp: v,pan: pan,start: 0.04
  #use one of line above OR below
  #sample i,rate: (sam.assoc(n)[1]),attack: d*0,sustain: d*0.9,release: d*0.1,amp: v,pan: pan,start: 0.00

end
#converts a number to the equivalent note symbol
#nb no error checking included
define :ntosym do |n|
  @note=n % 12
  @octave = n / 12 - 1
  lookup_notes = {
    0  => :c,
    1  => :cs,
    2  => :d,
    3  => :ds,
    4  => :e,
    5  => :f,
    6  => :fs,
    7  => :g,
    8  => :gs,
    9  => :a,
    10 => :as,
  11 => :b}
  return (lookup_notes[@note].to_s + @octave.to_s).to_sym
end
#tr uses ntosym to work out transposed note symbol
define :tr do |nv,sh|
  if sh ==0 then
    return nv
  else
    return ntosym(note(nv)+sh)
  end
end
#define note relative durations. Factor s sets the speed
dsq = 1 * s
sq = 2 * s
sqd = 3 * s
q = 4 * s
qt = 2.0/3*q
qd = 6 * s
qdd = 7 * s
c = 8 * s
cd = 12 * s
cdd = 14 * s
m = 16 * s
md = 24 * s
mdd = 28 * s
b = 32 * s
bd = 48 * s
#function plays an array of note and duration values (nt and dur), with parameters for transposition, volume and pan
define :plarray do |nt,dur,sh=0,vol=0.4,pan=0|
  nt.zip(dur).each do |n,d|
    if n != :r then
      pl(tr(n,sh),d,pan,vol)
    end
    sleep d
  end
end

comment do #uncomment to play note range
  #low notes don't play very convincingly
  define :test do
    puts note(:f2)
    puts note(:c8)
    note(:f2).upto(note(:c8)) do |i|
      pl(ntosym(i),0.4,0,0.6)
      puts ntosym(i)
      sleep 0.4
    end
  end
  test
  sleep 1000 #press stop here
end
#set up arrays of notes and durations for the 5 parts in section 1
n1 = [:g5,:f5,:f5,:e5,:r,:f5,:e5,:f5,:g5,:a5,:g5,:f5,:e5,:e5,:d5,:e5,:f5,:fs5,:g5,:f5,:f5,:e5]
d1 = [md,c,m,c,c,q,q,q,q,q,q,q,q,m,q,q,q,q,md,c,m,m]
#b7
n1.concat [:f5,:g5,:f5,:e5,:f5,:g5,:a5,:f5,:e5,:d5,:d5,:c5,:r]
d1.concat [sq,sq,sq,sq,sq,sq,sq,sq,c,c,m,c,c]
n2 = [:e5,:d5,:d5,:c5,:r,:d5,:cs5,:d5,:e5,:f5,:e5,:d5,:c5,:c5,:b4,:r,:e5,:d5,:d5,:c5,:cs5,:r,:c5,:b4,:b4,:r]
d2 = [md,c,m,c,c,q,q,q,q,q,q,q,q,m,c,c,md,c,m,c,c,m,c,c,m,m]
n3 = [:c4,:e4,:g4,:gs4,:a4,:g4,:f4,:e4,:d4,:e4,:f4,:fs4,:g4,:r,:c4,:e4,:g4,:gs4,:a4,:bb4,:a4,:g4,:g4,:r]
d3 = [c,c,c,c,md,c,cd,q,q,q,q,q,md,c,c,c,c,c,md,c,m,m,md,c]
n4 = [:r,:bb4,:f4,:r,:f4,:e4,:r]
d4 = [5*b+md,c,m,m,m,c,c]
n5 = [:r,:c4,:r]
d5 = [7*b,md,c]
#store a list of arrays and durations to be played
lar = [n1,d1,n2,d2,n3,d3,n4,d4,n5,d5]

#set up note and duration arrays for the second section
#b9
n1b = [:g5,:fs5,:g5,:fs5,:g5,:a5,:g5,:fs5,:c6,:b5,:bb5,:c6,:bb5,:a5,:g5,:fs5,:g5,:a5,:bb5,:b5,:c6,:cs6,:d6]
d1b = [m+q,sq,sq,sq,sq,sq,sq,c,m,c,cd,sq,sq,c,c,q,q,q,q,q,q,q,q]
#b13
n1b.concat [:d6,:e6,:d6,:c6,:b5,:b5,:a5,:r,:g5,:g5,:a5,:g5,:fs5,:e5,:e5,:d5,:r,:c5,:b4,:g5,:e5,:a5,:g5,:fs5]
d1b.concat [q,sq,sq,q,q,q,q,q,q,q,sq,sq,q,q,q,q,q,q,q,q,q,c,c,q]
#b16
n1b.concat [:a5,:g5,:gs5,:a5,:b5,:cs6,:d6,:g5,:a5,:b5,:c6,:fs5,:f5,:cs5,:d5,:e5,:f5,:fs5,:g5,:f5]
d1b.concat [m,c,c,m+q,q,q,q,m+q,q,q,q,b,cd,q,q,q,q,q,md,c]
#b22
n1b.concat [:f5,:e5,:f5,:g5,:f5,:e5,:r,:f5,:e5,:f5,:g5,:a5,:g5,:f5,:e5,:e5,:d5,:e5,:f5,:e5,:d5,:e5,:f5,:fs5]
d1b.concat [c,sq,sq,sq,sq,c,c,q,q,q,q,q,q,q,q,c,sq,sq,sq,sq,q,q,q,q]
#b25
n1b.concat [:g5,:b5,:a5,:c6,:b5,:d6,:c6,:g5,:f5,:f5,:e5,:f5,:g5,:f5,:e5,:f5,:g5,:a5,:f5,:e5,:f5,:fs5,:g5,:g5,:f5,:e5,:d5,:d5,:e5,:d5,:c5,:r]
d1b.concat [q,sq,sq,sq,sq,sq,sq,c,c,m,m,sq,sq,sq,sq,sq,sq,sq,sq,sq,sq,sq,sq,sq,sq,sq,sq,cd,sq,sq,c,c]
n2b = [:r,:d5,:c5,:fs5,:r,:eb5,:d5,:c5,:d5,:r,:e5,:d5,:d5,:c5,:r,:d5,:cs5,:d5,:e5,:f5,:e5,:d5,:c5,:c5,:b4,:r]
d2b = [6*b+m,c,c,m,2*b+m,m,c,c,cd,m+q,md,c,m,c,c,q,q,q,q,q,q,q,q,m,c,c]
#b25
n2b.concat [:e5,:r,:e5,:d5,:d5,:cs5,:d5,:r,:b4,:r]
d2b.concat [c,c,c,c,m,m,m,m,m,m]
n3b=[:e5,:d5,:c5,:d5,:e5,:d5,:r,:g5,:fs5,:e5,:d5,:c5,:b4,:a4,:fs4,:g4,:c4,:b4,:a4,:c5,:b4,:r,:g5,:fs5,:r,:f5,:e5,:r,:c5,:b4,:a4]
d3b=[m,m,m,m,b,c,md,cd,q,c,c,cd,q,c,c,c,c,c,c,m,c,m,c,c,m,c,c,c,m,c,c]
#b20
n3b.concat [:b4,:r,:c4,:e4,:g4,:gs4,:a4,:g4,:f4,:g4,:f4,:e4,:d4,:e4,:f4,:fs4,:g4,:r,:c4,:e4,:g4,:gs4,:a4,:bb4,:a4,:g4,:a4,:c5,:b4,:g4,:r]
d3b.concat [cd,q+m,c,c,c,c,md,c,q,q,q,q,q,q,q,q,md,c,c,c,c,c,c,m,q,q,m,c,c,md,c]
n4b=[:c5,:b4,:a4,:g4,:cs5,:r,:e5,:d5,:c5,:b4,:a4,:g4,:fs4,:ds4,:e4,:r,:d4,:g4,:r,:cs5,:d5,:r,:b4,:c5,:r]
d4b=[m,m,m,m,b,b,cd,q,c,c,cd,q,c,c,c,c,m,md,m,c,c,m,c,c,c]
#b19
n4b.concat [:g4,:r,:f4,:g4,:f4,:e4,:r]
d4b.concat [b+cd,q+m+6*b,m,m,m,c,c]
n5b=[:r,:c4,:r]
d5b=[19*b,md,c]
#add these parts to the lar list
lar.concat [n1b,d1b,n2b,d2b,n3b,d3b,n4b,d4b,n5b,d5b]

#sec is defined to play 5 pairs of note/duration arrays. p is the offset in lar list
#sh sets transposition
#parts played together using threads
define :sec do |p,sh=0,amp=0.4|
  in_thread do
    plarray(lar[8+p],lar[9+p],sh,amp,0.6)
  end
  in_thread do
    plarray(lar[6+p],lar[7+p],sh,amp,0.6)
  end
  in_thread do
    plarray(lar[4+p],lar[5+p],sh,amp,-0.6)
  end
  in_thread do
    plarray(lar[2+p],lar[3+p],sh,amp,-0.6)
  end
  plarray(lar[0+p],lar[1+p],sh,amp,-0.6)
end

#now play first and second sections
#shift set at start adjusts transpose. 0 and 10 are offsets in lar and last parameter is vol
with_fx :reverb, room: 0.6 do
  with_fx :lpf, cutoff: 85 do
    sec(0,shift,0.4)
    sec(0,shift,0.3)
    sec(10,shift,0.3)
    sec(10,shift,0.4)
  end
end

You can copy and paste the program from here

You can listen to a recording of the program output here

There is a video of the piece playing on youtube here

3 thoughts on “Creating a glass harmonica emulator with Sonic Pi

  1. I am new to Raspberry Pi having been working on the Tablet Ocarina Project for a blind friend, which is featured in the August edition of the Magpi magazine. Sonic pi looked very interesting and decided to see what it was all about, then I came across your delightful work for the glass armonica by Mozart. Have got it on my Sonic Pi. Hardly expected to find something like this around with all the other sound effects that can be made with Sonic Pi. I see there is another interesting piece by Mozart to do with minuets and a card game. It sounds great. Thanks so much. Robert

    • Glad you like the Armonica program. I have done a lot of classical music for Sonic-Pi you can access it on my two soundcloud accounts:
      soundcloud.com/rbnman and soundcloud.com/scrbn
      In most cases the sound file has a link to the source code so you can try it out yourself.
      Robin

Leave a comment