Chapter 4: Karplus Strong Algorithm#

Adapted from: “Python Playground: Geeky Projects for the Curious Programmer” by Mahesh Venkitachalam (No Starch Press)

This program implements the Karplus–Strong string synthesis. According to ChatGPT 4.0:

ChatGPT 4.0 Output#

The Karplus-Strong algorithm is a method used in digital signal processing and physical modeling synthesis to simulate the sound of a plucked string or percussion instruments. Developed by Kevin Karplus and Alex Strong in 1983, this algorithm is a simple yet powerful technique for synthesizing realistic string sounds and has been influential in the field of computer music.

Basic Concept#

The Karplus-Strong algorithm uses a concept known as a “wavetable synthesis”. Here’s how it works:

  1. Initial Noise Burst: The algorithm starts with a buffer (wavetable) filled with random values. This buffer represents a noise burst, similar to the initial complex vibrations when a string is plucked.

  2. Circular Buffer Feedback Loop: The buffer is treated as a circular buffer, meaning that once the playback reaches the end of the buffer, it wraps around to the start again. The contents of this buffer are continuously fed back into itself, creating a feedback loop.

  3. Low-Pass Filter: Each time the buffer wraps around, the algorithm applies a simple low-pass filtering process. This usually involves averaging each sample with the next one (or with a small number of subsequent samples). This filtering simulates the energy loss in a real string due to factors like air resistance and internal friction.

  4. Resulting Sound: The output sound is created by continuously reading the values from this buffer. The low-pass filtering gradually turns the initial noise into a harmonic wave, mimicking the behavior of a vibrating string.

Characteristics and Usage#

  • Realism and Simplicity: The Karplus-Strong algorithm is renowned for its ability to produce realistic string-like sounds with a very simple implementation, making it computationally efficient.

  • Modifications for Different Sounds: The algorithm can be modified to simulate different types of plucked string instruments. Adjusting the initial conditions (like the noise burst) and parameters (like the low-pass filter and the buffer length, which determines the pitch) allows for a variety of sounds.

  • Extended Techniques: Over the years, various extensions and modifications have been made to the basic algorithm to improve realism or to simulate different characteristics of string instruments.

Applications#

  • Music Synthesis: The Karplus-Strong algorithm is used in synthesizers and music software to generate string sounds.

  • Sound Effects: It’s also used in video games and other applications for generating realistic sound effects on-the-fly.

The simplicity and effectiveness of the Karplus-Strong algorithm make it a staple in the field of digital sound synthesis, demonstrating how complex and natural phenomena can be simulated with relatively simple computational models.

In file ks.py#

import sys, os
import time, random
import wave, argparse, pygame
import numpy as np

# I added the next two lines.  TkAgg uses the Tkinter rederer, which seems to work on my system.
# You may need to try other renderers to get the plot drawing to work
import matplotlib
matplotlib.use('TkAgg')


from collections import deque
from matplotlib import pyplot as plt

# show plot of algorithm in action?
gShowPlot = False

# notes of a Pentatonic Minor Scale
# piano C4-E(b)-F-G-B(b)-C5
pmNotes = {'C4': 262, 'Eb': 311, 'F': 349, 'G': 391, 'Bb': 466}

def writeWAVE(fname, data):
    # open file
    file = wave.open(fname, 'wb')
    # WAV file parameters
    nChannels = 1
    sampleWidth = 2
    frameRate = 44100
    nFrames = 44100
    # set parameters
    file.setparams((nChannels, sampleWidth, frameRate, nFrames, 'NONE', 'noncompressed'))
    file.writeframes(data)
    file.close
    
# generate note of given frequency
def generateNote(freq):
    nSamples = 44100
    sampleRate = 44100
    N = int(sampleRate / freq)
    
    # initialize ring buffer
    buf = deque([random.random() - 0.5 for i in range(N)])
    
    # plot if flag set
    if gShowPlot:
        axline, = plt.plot(buf)
    
    # initialize sample buffer
    samples = np.array([0]*nSamples, 'float32')
    for i in range(nSamples):
        samples[i] = buf[0]
        avg = 0.995 * 0.5 * (buf[0] + buf[1])
        buf.append(avg)
        buf.popleft()
        # plot if flag set
        if gShowPlot:
            if i % 1000 == 0:
                axline.set_ydata(buf)
                plt.draw()
        
    # convert samples to 16-bit values and then to a string
    # the maximum value is 32767 for 16-bit
    samples = np.array(samples*32767, 'int16')
    return samples.tostring()
    
# play a WAV file
class NotePlayer:
    # constructor
    def __init__(self):
        pygame.mixer.pre_init(44100, -16, 1, 2048)
        pygame.init()
        # dictionary of notes
        self.notes = {}
    
    # add a note
    def add(self, fileName):
        self.notes[fileName] = pygame.mixer.Sound(fileName)
    
    # play a note
    def play(self, fileName):
        try:
            self.notes[fileName].play()
        except:
            print(fileName + ' not found!')
            
    def playRandom(self):
        """ play a random note """
        index = random.randint(0, len(self.notes)-1)
        note = list(self.notes.values())[index]
        note.play()
        
# main() function
def main():
    # declare global var
    global gShowPlot
    
    parser = argparse.ArgumentParser(description="Generating sounds with Karplus String Algorithm")
    # add arguments
    parser.add_argument('--display', action='store_true', required=False)
    parser.add_argument('--play', action='store_true', required=False)
    parser.add_argument('--piano', action='store_true', required=False)
    args = parser.parse_args()
    
    # I added the next line to start the Python debugger, so that I can step through the script
    # and prevent the plot from flashing on and off.
    # By stepping through the script until just before the end, the plot stays up.
    import pdb; pdb.set_trace()
    
    # show plot if flag set
    if args.display:
        gShowPlot = True
        plt.ion()
        #plt.show()
        
    # create note player
    nplayer = NotePlayer()
    
    print('creating notes...')
    for name, freq in list(pmNotes.items()):
        fileName = name +'.wav'
        if not os.path.exists(fileName) or args.display:
            data = generateNote(freq)
            print('creating ' + fileName + '...')
            writeWAVE(fileName, data)
        else:
            print('filename aready created. skipping...')
        
        # add note to player
        nplayer.add(name + '.wav')
        
        # play note if display flag set
        if args.display:
            nplayer.play(name + '.wav')
            time.sleep(0.5)
                
    
    
    # play a random tune
    if args.play:
        while True:
            try:
                nplayer.playRandom()
                # rest - 1 to 8 beats
                rest = np.random.choice([1, 2, 4, 8], 1, p=[0.15, 0.7, 0.1, 0.05])
                time.sleep(0.25*rest[0])
            except KeyboardInterrupt:
                exit()
                
    # random piano mode
    if args.piano:
        while True:
            for event in pygame.event.get():
                if (event.type == pygame.KEYUP):
                    print("key pressed")
                    nplayer.playRandom()
                    time.sleep(0.5)
                    
# call main
if __name__ == '__main__':
    main()

The program above was rewritten and into a Jupyter Notebook format and upgraded with in browser functionality.

Jupyter Notebook Implementation#

%matplotlib inline
import os
import time, random
import wave, argparse, pygame
import numpy as np
from collections import deque
from matplotlib import pyplot as plt
pygame 2.5.2 (SDL 2.30.2, Python 3.10.14)
Hello from the pygame community. https://www.pygame.org/contribute.html
# notes of a Pentatonic Minor Scale
# piano C4-E(b)-F-G-B(b)-C5
pmNotes = {'C4': 262, 'Eb': 311, 'F': 349, 'G': 391, 'Bb': 466}
def writeWAVE(fname, data):
    # open file
    file = wave.open(fname, 'wb')
    # WAV file parameters
    nChannels = 1
    sampleWidth = 2
    frameRate = 44100
    nFrames = 44100
    # set parameters
    file.setparams((nChannels, sampleWidth, frameRate, nFrames, 'NONE', 'noncompressed'))
    file.writeframes(data)
    file.close
# generate note of given frequency
def generateNote(freq):
    nSamples = 44100
    sampleRate = 44100
    N = int(sampleRate / freq)
    
    # initialize ring buffer
    buf = deque([random.random() - 0.5 for i in range(N)])
    
    # initialize sample buffer
    samples = np.array([0]*nSamples, 'float32')
    for i in range(nSamples):
        samples[i] = buf[0]
        avg = 0.995 * 0.5 * (buf[0] + buf[1])
        buf.append(avg)
        buf.popleft()
        
    # convert samples to 16-bit values and then to a string
    # the maximum value is 32767 for 16-bit
    samples = np.array(samples*32767, 'int16')
    return samples.tobytes(), buf
# play a WAV file
class NotePlayer:
    # constructor
    def __init__(self):
        pygame.mixer.pre_init(44100, -16, 1, 2048)
        pygame.init()
        # dictionary of notes
        self.notes = {}
    
    # add a note
    def add(self, fileName):
        self.notes[fileName] = pygame.mixer.Sound(fileName)
    
    # play a note
    def play(self, fileName):
        try:
            self.notes[fileName].play()
        except:
            print(fileName + ' not found!')
            
    def playRandom(self):
        """ play a random note """
        index = random.randint(0, len(self.notes)-1)
        note = list(self.notes.values())[index]
        note.play()
# create note player
nplayer = NotePlayer()

Create and Plot Note Samples#

print('creating notes...')
for name, freq in list(pmNotes.items()):
    fileName = name +'.wav'
    data, buf = generateNote(freq)
    print('creating ' + fileName + '...')
    writeWAVE(fileName, data)
    # Display the Notes
    ax = plt.plot(buf, label=name)

    # add note to player
    nplayer.add(name + '.wav')
    plt.legend()
    plt.xlabel("Sample Number")
    plt.ylabel("Amplitude")
    plt.title("Generated Notes")
creating notes...
creating C4.wav...
creating Eb.wav...
creating F.wav...
creating G.wav...
creating Bb.wav...
../../../../_images/344f30932358cc12689fdd7036437b52389fdef3b4eb5ef2d3e073322b1ad94a.png
# List all files in the directory and filter for .wav files
directory_path = os.getcwd()
wav_files = [f for f in os.listdir(directory_path) if f.endswith('.wav')]
wav_files
['Bb.wav', 'C4.wav', 'Eb.wav', 'F.wav', 'G.wav']

Plot and Play Note Wavfile Data#

%matplotlib ipympl
from IPython.display import Audio, display
from scipy.io import wavfile

for wav_file in wav_files:
    file_path = os.path.join(directory_path, wav_file)
    sampling_rate, data = wavfile.read(file_path)
    # Check if the audio is mono or stereo and extract accordingly
    if len(data.shape) > 1:  # Stereo
        data = data.mean(axis=1)  # Convert to mono by averaging left and right channels

    # Create a time axis in seconds
    time = np.linspace(0, len(data) / sampling_rate, num=len(data))

    # Plotting
    plt.figure(figsize=(8, 4))
    plt.plot(time, data)
    plt.title('Waveform of .wav file')
    plt.xlabel('Time [s]')
    plt.ylabel('Amplitude')
    plt.xlim(0, 0.05)
    plt.show()
    
    display(Audio(filename=file_path))
    print(f"Playing file: {wav_file}")
    print()
Playing file: Bb.wav
Playing file: C4.wav
Playing file: Eb.wav
Playing file: F.wav
Playing file: G.wav