Monday 7 July 2014

Karplus-Strong in Silverlight

Following on from my previous post making audio signals with Silverlight, I thought it would be good to do something a bit more interesting to explore the possibilities. I had a bit of a play about with this last summer, so have dug out the code again to break it into some steps and hopefully can then show some more interesting examples.

One of the easiest algorithms to get an interesting sound with very little coding is the Karplus-Strong delay-line which makes a very basic simulation of a plucked string through a simplification of the more generalised concept of digital waveguide synthesis.

Basically a signal, usually white noise, is used to simulate the string pluck and then the signal is delayed and put into a low-pass filter to simulate the decay of the string oscillations.

The basic filter circuit is shown below:


There are already some good examples of this online. Probably the most accessible and closest to what we're doing is by Andre-Michelle who has posted his AS3 implementation. He also has taken this a bit further to simulate all six strings along with resonance, which we'll get onto in a future post hopefully.

The other notable, accessible way to see this algorithm and more is the well-know Synthesis Toolkit in C++ (STK). The source is described and there is a lot of reference material explaining this and the other concepts that are built on it. Andy Li has also done an excellent job of porting much of this over into AS3 (stk-in-as3).

And back to some code here... we're going to build on the previous posting by making a new Generator class called Karp:


using System;
using System.Net;

namespace AgSynth
{
    public class Karp: Generator
    {
        int excite = 70; // excitation duration in samples
        int noise = 0; // gate if noise is being generated or not
        int length = 0; // length of the delay line
        Random r = new Random();

        double Fo = 0; // fundamental frequency (pitch requested)
        double vlpfdelay = 0; // low pass filter internal delay value

        Delay delay = new Delay();

        public Karp()
        {
        }

        public void pluck()
        {
            noise += excite;
        }

        // helper function
        static int Truncate(double d)
        {
            return (int)(d > 0 ? Math.Floor(d) : -Math.Floor(-d));
        }

        public void pitch(double p)
        {
            if (p == 0)
            {
                delay.Length = 0;
            }
            else
            {
                Fo = p; // frequency
                double Fs = PcmStreamSource.SampleRate;

                double No = Fs / Fo;
                length = Truncate(No - 0.5);
                excite = Truncate(length * 0.95);

                double fraction = No - 0.5 - length;

                delay.Length = length;
            }
        }



        public override short tick()
        {
            short sample = 0;

            if (length > 0)
            {
                double amp = 0.99; // amplification

                double vin = 0;

                // push noise into circuit if still generating noise
                if (noise > 0) 
                {
                    vin = (r.NextDouble() * 2.0) - 1.0; // generate noise sample
                    noise--;
                }

                double decay = 0.999;

                double vinlpf = delay.pop();  // get the delayed value

                // filter the output with simple LPF and decay
                double voutlpf = ((vlpfdelay)  + (vinlpf)) * 0.5 * decay;
                vlpfdelay = voutlpf;

                // output = mix input with filter
                double vout = voutlpf + vin;

                // push the output to the start of the delay-line
                delay.push(vout);
                delay.increment();

                // amplify
                sample = (short)(vout * short.MaxValue * amp);
            }

            return sample;
        }
    }
}

There are three main methods here:

  • pitch - which sets the pitch (or frequency of the note)
  • pluck - which sets the counter for the gate for noise to be pumped into the algorithm
  • tick - which is our main algorithm
In the Karplus-Strong algorithm the pitch is set by the length of the delay-line. I'll get onto this a bit more in a follow-on post. The Karp class therefore uses a generic ring-buffer class called delay (emulating the approach used by STK).

In this implementation push puts the sample on the end of the delay-line, pop takes the last sample off the delay line and increment cycles the delay-line by a sample.



using System;
using System.Net;


namespace AgSynth
{
    public class Delay
    {
        private const int MAXLENGTH = 1000;
        private double[] buffer = new double[MAXLENGTH + 1];

        private int idx = 0;
        private int length = 0;

        public Delay()
        {
            for (int i = 0; i < MAXLENGTH + 1; i++)
            {
                buffer[i] = 0;
            } 
        }

        public void increment()
        {
            lock (buffer)
            {
                idx = (idx + 1 > length) ? 0 : idx + 1;
            }
        }


        public double pop()
        {
            double value = get(length);
            //increment();

            return value;
        }

        public void push(double value)
        {
            set(0, value);
        }

        public double get(int index)
        {
            double result = 0.0;

            if (buffer != null)
            {
                lock (buffer)
                {
                    int pos = (idx + index >= length) ? idx + index - length : index + idx;
                    result = buffer[pos];
                }
            }

            return result;
        }

        public void set(int index, double value)
        {
            if (buffer != null)
            {
                lock (buffer)
                {
                    int pos = (idx + index >= length) ? idx + index - length : index + idx;
                    buffer[pos] = value;
                }
            }
        }


        public int Length
        {
            set
            {
                length = value;

                if (length < MAXLENGTH)
                {
                    lock (buffer)
                    {
                        double[] newbuffer = new double[MAXLENGTH];

                        for (int i = 0; i < length; i++)
                        {
                            newbuffer[i] = get(i);
                        }

                        buffer = newbuffer;
                        idx = 0;
                    }
                }
            }
        }

    }
}


The tick() function then implements the algorithm. Firstly noise is generated for a certain number of samples (which is scaled by the frequency). A basic two-point moving averager low-pass filter is then implemented to progressively smooth out the high-frequencies and decay the signal.

The MainPage algorithm to make this do something interesting then just needs a few tweaks to get it to play some notes:


        Karp karp = new Karp();

        private void PlayTune()
        {
            double bpm = 120;

            double barduration = 60 * 1000 * 4 / bpm; // in ms - assuming 4/4

            beat = barduration / 4;

            List<double> tune = new List<double>() { Theory.C4, Theory.D4, Theory.E4, Theory.F4, Theory.E4, Theory.D4 };

            while (true)
            {
                foreach (double p in tune)
                {
                    karp.pitch(p);
                    karp.pluck();
                    Thread.Sleep((int)(beat));
                }
            }
        }


        private void ButtonPlay_Click(object sender, RoutedEventArgs e)
        {
            source.Input = karp;
            media.Play();

            if (player == null)
            {
                player = new Thread(new ThreadStart(PlayTune));

                player.Start();
            }
        }

The Theory class puts together the note pitches:


      
    public class Theory
    {  

        public const double C4 = 261.63;
        public const double Cs4 = 277.2;
        public const double D4 = 293.67;
        public const double Ds4 = 311.1;
        public const double E4 = 329.63;
        public const double F4 = 349.23;
        public const double Fs4 = 370.0;
        public const double G4 = 392.00;
        public const double Gs4 = 415.3;
        public const double A4 = 440.00;
        public const double As4 = 466.2;
        public const double B4 = 493.88;
    }


And here is is






No comments:

Post a Comment