This is just a fun post, where I look at the Karplus-Strong Algorithm. This algorithm is used to produce synthetic guitar sounds via looping a waveform repeatedly thru a filter. That might sound strange, but what it is doing is taking a ‘pluck’ of random noise; you continuously feed it back into a filter that stands in as the ‘string’ decaying over time. This is what would make sense after you pluck a guitar string. It eventually becomes quiet. There are a few moving parts to this post, but you will get a treat at the end.
The first thing we need to do is to build a data structure that loops back on itself and can be indexed basically forever. This is a standard data structure called a Ring Buffer. The Wikipedia article has much more information and explanation than I can address here. This is a simple implementation of a Ring Buffer; we have overloaded the getters and setters for indexing the buffer. This allows us to index off of the current ‘0’ position of the Ring Buffer. Here the populate buffer function fills the buffer with random data between $-.5$ and $.5$. This simulates a random pluck.
class RingBuffer:
def __init__(self, n: int, initial_vals = None):
#set up buffer state
self.size = n
self.cursor = 0
self.buffer = numpy.zeros(self.size)
#set buffer values
if initial_vals is not None:
self.buffer[:] = inital_vals[0:self.size]
else:
self.populate_buffer()
def next(self):
# pushes the cursor forward one position
self.cursor += 1
if self.cursor >= self.size:
self.cursor = 0
def __getitem__(self, index:int):
return self.buffer[(index + self.cursor + self.size)%self.size]
def __setitem__(self, index:int, value):
self.buffer[(index + self.cursor + self.size)%self.size] = value
def populate_buffer(self):
#fills buffer with random numbers in [-.5, .5]
self.buffer[:] = numpy.random.rand(self.size)-.5
Now that we have that out of the way, we can implement the actual Karplus-Strong algorithm. This is actually quite simple to implement with the ring buffer already implemented. This is assuming a known sampling rate of your computer; in my case, it is 48000 Hz. The actual KS algorithm takes 1 line to implement. We take the next 2 values in the ring buffer, take their average, and then dampen it. Yep, That is it.
class KS_String:
def __init__(self, freq:int, dampen:float = .999):
self.freq = int(freq)
self.ring_buffer = RingBuffer(sampling_rate // self.freq)
self.dampen = dampen
def get_value(self):
# this is the heart of the KS algorithm
self.ring_buffer[0] = .5*self.dampen*(self.ring_buffer[1] + self.ring_buffer[2])
self.ring_buffer.next()
return self.ring_buffer[-1]
def pluck(self):
self.ring_buffer.populate_buffer()
Now that we can pluck a string to a frequency, that means we can play a note. You already guessed it. We are making a note object. This note object takes a KS_String object and has a duration to it.
class Note:
def __init__(self, freq, duration:float = .25):
# this is assuming 4/4 time
# default note length is quarter note
# defualt tempo is 120 bpm
self.freq = int(freq)
self.string = KS_String(self.freq)
self.duration = duration
self.num_steps = 60*4*duration*sampling_rate/bpm
self.step_count = 0
def get_value(self):
self.step_count += 1
if not self.isComplete():
return self.string.get_value()
else:
return 0
def isComplete(self):
return self.step_count > self.num_steps
def restart(self):
# reset the note back to the start
self.string = KS_String(self.freq)
step_count = 0
Now we have noted; we can make an instrument! This is a rather simple object that takes lists of notes and when the notes start. This assumes that the notes are so that they are played.
class Instrament:
def __init__(self, notes:list, starts:list):
self.notes = notes
self.starts = numpy.array([int(start*60*sampling_rate/bpm) for start in starts])
def compose(self):
# initalize the variables
counter = 0
sound = list()
active_note = -1
while True:
# condition to switch to the next note
if active_note != len(self.notes)-1:
if counter == self.starts[active_note +1]:
active_note = active_note+1
print('new note')
# play nothing if no note is active
if active_note < 0:
sound.append(0)
else:
sound.append(self.notes[active_note].get_value())
# termination conditions all notes are off
if self.notes[-1].isComplete():
break
# go to the next time step
counter += 1
# reset all of out instraments notes
for i in range(len(self.notes)):
self.notes[i].restart()
# return the instrament music as a numpy array
return numpy.array(sound)
Now that all of that work is out of the way, We can FINALLY PLAY A SONG. I will pick ‘Good King Wenceslas’ as it is relatively simple and one of the first ‘real’ songs I learned when I was in band. This song wasn’t really made for the guitar, but that never stopped a guitar player. I transcribed the notes from broadly available sheet music. While the song is out of copyright, specific compositions of it are copyrighted.
Here ‘no’ is a helper function that maps the note names and beat duration to a Note object with the associate frequency. There seems to be a problem with the frequency in this table. As you can hear in the last segment of the song, the C->B->A transition doesn’t sound quite right.
sampling_rate = 48000
bpm = 240
def no(val:str, dur):
freq_dict = {'C':261, 'D':293, 'E':329, 'F':349, 'G':391, 'A':440, 'B':493, 'high_C':526}
return Note(freq_dict[val], duration=dur)
notes = [no('F', 1), no('F', 1), no('F', 1), no('G', 1),no('F', 1),no('F', 1),no('C', 2), no('D', 1),no('C', 1),no('D', 1),no('E', 1),no('F', 2),no('F', 2)]
#add refrain
notes.extend([copy.deepcopy(i) for i in notes])
#add the end to the song
finish = [no('high_C', 1), no('B', 1), no('A', 1), no('G', 1), no('A', 1), no('G', 1), no('F', 2), no('D', 1), no('C', 1), no('D', 1), no('E', 1), no('F', 2), no('F', 2)]
notes.extend(finish)
# calcuate to note start times
starts = (numpy.cumsum([i.duration for i in notes]) - 1).tolist()
guitar = Instrament(notes, starts)
music = guitar.compose()
This composed piece of music can then be turned into a sound file, and this is the result. WARNING LOUD!!!