Audio From Scratch With Go: Waveform synthesis

In the previous posts we first looked at how we can generate a sine wave as ‘raw’ floats and interpret them using ffplay. Later we explored how to read / write .wave files and how to extract and create ‘automation tracks’ using breakpoints.

As you might have noticed, we’ve never actually created .wave files from scratch with our own sound data. So, it’s about time to change that. In this blogpost we’ll look at how we can create a variety of basic soundwaves.

The code we’ll be diving into for this post can be found in the Github Examples and as part of the GoAudio library.

Constructing an oscillator

An oscillator is a device (in our case a piece of code) that generates a periodic (oscillating) signal. The sine wave is one example of such a waveform, but we’ll also look at square waves, triangle waves, and sawtooth waves.

At the end of this post, you’ll be able to generate signals that look like this:

On the images, these look like connected lines, but in our digital audio signal that we will generate, they are separate datapoints. How many ‘points’ do have in each cycle? That depends on the sample rate we are using.

We can figure out how to place our dots given the sample rate. Remember that we are using radians in our trig functions, and a period of the wave is thus defined as: 2 * PI. To know how to place our points, we can figure out part of the puzzle (the ‘increment’) as follows:

increment = (2 * PI) / SampleRate

Unfortunately, this is not the entire picture. We also have the keep in mind that our wave has a certain frequency - which we’ll have to account for in our increments. The actual function then becomes:

increment = ((2 * PI) / SampleRate) * freq

In our Oscillator we’ll have to track these things. We’ll want to know what the current frequency is, what the current phase is, and how to increment this phase to get the next value of our wave.

This solves only part of the puzzle. It’s also clear now that we’ll need a way to differentiate which type of waveform the user wants to generate. For this, we can start with an “enum” of a Shape type. Each shape will also need to be calculated in a different way, so we can associate a Shape with a calculation function with a map shapeCalcFunc = map[Shape]func(float64)float64

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
type Shape int

const (
	SINE Shape = iota
	SQUARE
	DOWNWARD_SAWTOOTH
	UPWARD_SAWTOOTH
	TRIANGLE
)

var (
	shapeCalcFunc = map[Shape]func(float64) float64{
		SINE:              sineCalc,
		SQUARE:            squareCalc,
		TRIANGLE:          triangleCalc,
		DOWNWARD_SAWTOOTH: downSawtoothCalc,
		UPWARD_SAWTOOTH:   upwSawtoothCalc,
	}
)

These are our ‘elementary’ shapes that we’ll use for the next few posts. Although we’ll extend on them, they’ll give us a solid base to start.

Putting this together, we can define an Oscillator struct:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20

type Oscillator struct {
	curfreq  float64
	curphase float64
	incr     float64
	twopiosr float64 // (2*PI) / samplerate
	tickfunc func(float64) float64
}

// NewOscillator set to a given sample rate
func NewOscillator(sr int, shape Shape) (*Oscillator, error) {
	cf, ok := shapeCalcFunc[shape]
	if !ok {
		return nil, fmt.Errorf("Shape type %v not supported", shape)
	}
	return &Oscillator{
		twopiosr: tau / float64(sr), // (2 * PI) / SampleRate
		tickfunc: cf,
	}, nil
}

Notice that we are storing twopiosr = tau / SampleRate = (2 * PI) / SampleRate as a convenience variable. We’ll be using this in a few functions.

Generating waveforms

With this constructor, we have the basis for a working oscillator but it’s not yet generating anything. For this purpose we’ll need a function that asks the oscillator to produce the next value of the wave (It can do this indefinitely). This function will need to do a few things:

  • Accept a frequency for the wave to generate
  • Adjust the increment between frames
  • Find the value at this phase
  • Adjust the current phase
  • Do some bounds checks on the phase (optionally)

Our function in Go becomes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func (o *Oscillator) Tick(freq float64) float64 {
	if o.curfreq != freq {
		o.curfreq = freq
		o.incr = o.twopiosr * freq
	}
	val := o.tickfunc(o.curphase)
	o.curphase += o.incr
        
        // adjust bounds
	if o.curphase >= tau {
		o.curphase -= tau
	}
	if o.curphase < 0 {
		o.curphase = tau
	}
	return val

}

The adjustments to our curphase is to keep it in the bounds (Although depending on the implementation of the sin function this might not be necessary, I kept it here as a guard but I’m pretty sure it’s not necessary in Go).

Waveforms functions

The only part left to implement is the actual generation of the different shapes of waves. This is what happens in the call val := o.tickfunc(o.curphase). By using a generic function call, we can inject the correct calculation function in the call to NewOscillator().

The easiest one to implement is the Sine wave.

1
2
3
func sineCalc(phase float64) float64 {
	return math.Sin(phase)
}

A close contender for being the most simple to implement is probably the square wave function. In this case, half our period is 1 and the other half is -1.

1
2
3
4
5
6
7
func squareCalc(phase float64) float64 {
	val := -1.0
	if phase <= math.Pi {
		val = 1.0
	}
	return val
}

The triangle wave is the first one that looks more complex, with the sawtooth waves being related to it (you can visually see a sawtooth is being part of the triangle with a sharp cut-off.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func triangleCalc(phase float64) float64 {
	val := 2.0*(phase*(1.0/tau)) - 1.0
	if val < 0.0 {
		val = -val
	}
	val = 2.0 * (val - 0.5)
	return val
}

func upwSawtoothCalc(phase float64) float64 {
	val := 2.0*(phase*(1.0/tau)) - 1.0
	return val
}

func downSawtoothCalc(phase float64) float64 {
	val := 1.0 - 2.0*(phase*(1.0/tau))
	return val
}

Making waves

With our oscillator set up, we can finally start using it. All the code that follows is contained in this GoAudio example. The setup of this example is similar to what we’ve seen in the previous posts where we deal with parsing breakpoints. To keep things simple, this entire setup happens in our main routine.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func main() {
	flag.Parse()
	fmt.Println("usage: go run main -d {dur} -s {shape} -a {amps} -f {freqs} -o {output}")
	if output == nil {
		panic("please provide an output file")
	}

	wfmt := wave.NewWaveFmt(1, 1, 44100, 16, nil)
	amps, err := ioutil.ReadFile(*amppoints)
	if err != nil {
		panic(err)
	}
	ampPoints, err := breakpoint.ParseBreakpoints(bytes.NewReader(amps))
	if err != nil {
		panic(err)
	}
	ampStream, err := breakpoint.NewBreakpointStream(ampPoints, wfmt.SampleRate)

	freqs, err := ioutil.ReadFile(*freqpoints)
	if err != nil {
		panic(err)
	}
	freqPoints, err := breakpoint.ParseBreakpoints(bytes.NewReader(freqs))
	if err != nil {
		panic(err)
	}
	freqStream, err := breakpoint.NewBreakpointStream(freqPoints, wfmt.SampleRate)
	if err != nil {
		panic(err)
	}
	// create wave file sampled at 44.1Khz w/ 16-bit frames

	frames := generate(*duration, stringToShape[*shape], ampStream, freqStream, wfmt)
	wave.WriteFrames(frames, wfmt, *output)
	fmt.Println("done")
}

Notice that we also print the usage, this tells us that we expect a duration, a shape, amplitude breakpoints, frequency breakpoints and finally an output file. The ‘heavy lifting’ happens in the call to the generate function. Here we pass along the duration, a Shape instance derived from the string entered on the CLI, our breakpoints and finally also a WaveFmt. Remember that the WaveFmt struct contains the metadata for the .wave files we are generating. In this case, the statement wave.NewWaveFmt(1, 1, 44100, 16, nil) means it’s a standard PCM wave-file, with 1 channel (mono) playing at 44.1Khz, where the data consists of 16-bit floats. You can play with these values to see how the result changes. (Well, the channels are a bit trickier to change as we learned in this post).

Finally in the generate function, we need to calculate the amount of samples (= frames for mono) we need to generate. Then we’ll call the Tick function of our oscillator as well of our breakpoint stream to continously retrieve the next value.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func generate(dur int, shape synth.Shape, ampStream, freqStream *breakpoint.BreakpointStream, wfmt wave.WaveFmt) []wave.Frame {
	reqFrames := dur * wfmt.SampleRate
	frames := make([]wave.Frame, reqFrames)
	osc, err := synth.NewOscillator(wfmt.SampleRate, shape)
	if err != nil {
		panic(err)
	}

	for i := range frames {
		amp := ampStream.Tick()
		freq := freqStream.Tick()
		frames[i] = wave.Frame(amp * osc.Tick(freq))
	}

	return frames
}

And there we go, we now have all code in place for generating basic waveforms. When examining them in audacity we get the result shown at the beginning of this post.

Improvements

With this we can generate basic “clean” audio signals, this could be handy for testing purposes but as far as I know it pretty much ends there. (Most software synths will also let you play around with these type of waves, but you’ll tweak them to something more usable).

You might have some concerns with this code though, the first being the performance issue. An oscillator is by definition something repetitive, yet we are constantly calculating the ‘next phase’. Is this strictly necessary? No, we can actually store the values we expect to see in a ‘lookup table’. (As a complete side-note here, this always reminds me of how in WW2 they used ‘firing tables’ to lookup ballistic trajectories. More on that here)

In the next post we’ll take a look at using lookup tables, and we’ll also start thinking about harmonics to represent sound in a more realistic way.

Resources


If you liked this and want to know when I write new posts, the best way to keep up to date is by following me on twitter.