How to build a synthesizer with python: part 2

Signal

mike | July 11, 2023, 3:09 a.m.

In part 1 we built a simple stream player class and set up a temporary sine wave generator to provide an audio source.

In this part, we’ll dive right into the synthesis code. By the end of this tutorial we’ll be able to produce more complex sounds by mixing two oscillator types. I’ll be honest, this tutorial has probably the most code and the least obvious upfront payoff. At the end, we’ll still only be generating a 440 Hz wave. But you need bricks to build a (brick) house, and these are the bricks.

We’ll use common Object-Oriented Programming techniques to build a set of signal components that share common properties but implement unique behaviors. We’ll then arrange the components in a tree structure and use them to generate audio.

You told me this was Python

But what I failed to tell you is that I used to be a Java developer! But seriously, this is a pretty clear cut case for using class inheritance. As you’ll see when we write the code, we can come up with a clear hierarchy of signal components

So let’s get into the code then! We’ll start by making some base classes to represent the signal components. To do that, we need to make some new submodules. Inside the synth folder, create a new folder named synthesis. Inside that folder, create an __init__.py file. Also inside the synthesis folder, create a new folder named signal. Likewise, create an __init__.py file in the signal folder.

The directory structure looks something like

|
+-toy-synth
  |
  |-.gitignore
  |-requirements.txt
  |
  +-venv
  |
  +-synth
    |
    |-__init__.py
    |-__main__.py
    |-settings.py
    |
    +-playback
    | |
    | |-__init__.py
    | |-stream_player.py
    |
    +-synthesis
      |
      |-__init__.py
      |
      +-signal
        |
        |-__init__.py

Now, in the signal module, create a file called component.py. Add the imports we’ll need:

import logging
from typing import List
import random

Now add a class declaration and constructor

class Component():
    """
    Represents a base signal component. A signal component is an iterator.
    The iterator should return an ndarray of size <frames_per_chunk> with type numpy.float32

    A component can have a list of subcomponents, which should also be iterators.

    A component must implement
    __iter__
    __next__
    __deepcopy__
    """

    def __init__(self, sample_rate: int, frames_per_chunk: int, subcomponents: List['Component']=[], name="Component", control_tag: str = ""):
        self.log = logging.getLogger(__name__)
        self.sample_rate = sample_rate
        self.frames_per_chunk = frames_per_chunk
        self.subcomponents = subcomponents
        self.active = False
        self.name = name + "#" + str(random.randint(0, 9999))
        self.control_tag = control_tag

In the constructor we’re just taking care of assigning variables to self that we want every component to have. We’ve seen sample_rate and frames_per_chunk before, when we set up the PyAudio stream. A lot of parts of the program need to have these parameters set to the same value to work correctly, that’s why we store and access it from our settings.py file.

It’s important to note that Components can have subcomponents, which are also instances of the Component class or one of its (soon to be) many subclasses. We’ll use this property to arrange the components into a tree of iterators with oscillators as the leaf nodes.

Speaking of iterators, you may have read the docstring and noticed that we have a few more methods to implement. Let’s add them below the constructor.

    def __iter__(self):
        return self
    
    def __next__(self):
        self.log.error("Child class should override the __next__ method")
        raise NotImplementedError
    
    def __deepcopy__(self, memo):
        self.log.error("invoked deepcopy on base class")
        raise NotImplementedError

We’ll never directly instantiate the base class, so it makes sense to either do nothing or raise an error if any of these methods are called.

Built-in goodness

You may already know about Python built-in methods, but if not I’ll explain them briefly here, since we’re using them. You’ll notice some of our method names have underscores in them. For example, __next__. This is so that instances of our class can work with the next() function from the standard library. When you call next() on an object, the Python interpreter will check that object to see if it has a method called __next__. If it does, it executes that method. For example, if you got an instance of a component with my_component = Component(44100, 1024), and then did chunk = next(my_component) you should expect to see a log printout and for a NotImplementedError to be raised.

__iter__ and __next__ work together to make our class an iterator. Iterators are one of the core concepts of our synth design. An iterator is an object that has a means of traversing a data structure. In Python, one typically calls iter() on an object once to do any needed setup, and then next() repeatedly to get the next element in the data structure.

So how does that apply to us? Let’s consider a few component types. One of our components will be a sine wave oscillator. This component’s job is to generate a sine wave. If you remember from part 1, the PyAudio stream will invoke the audio callback any time it needs more data. You may also recall that we simply call next() on whatever object you pass in through the class constructor. We could easily just call iter() on our sine wave oscillator and then pass it to the stream player.

Now say we want to generate a more complex sound. Let’s suppose we want to mix a sine wave and a square wave together. We could build this with three components: a sine wave oscillator, a square wave oscillator, and a mixer. The two oscillators would both be subcomponents of the mixer. In this setup, when you call next() on the mixer component, it can call next() on its subcomponents, then combine their outputs. You can imagine all kinds of components that could generate or modulate the signal in some way. By creating a tree of iterators with generator components at the leaf nodes we can use recursion to calculate the next signal chunk.

The rest of the class

Okay, that was a long but hopefully worthwhile explanation of a few key concepts. We still have some code to add to our Component class. Add the following to the bottom of the file:

    @property
    def sample_rate(self):
        """The number of sample slices per second"""
        return self._sample_rate

    @sample_rate.setter
    def sample_rate(self, value):
        try:
            int_value = int(value)
            self._sample_rate = int_value
        except ValueError:
            self.log.error(f"unable to set with value {value}")

    @property
    def frames_per_chunk(self):
        """The number of data frames to generate per call to __next__. Essentially the size of the array to generate"""
        return self._frames_per_chunk
    
    @frames_per_chunk.setter
    def frames_per_chunk(self, value):
        try:
            int_value = int(value)
            self._frames_per_chunk = int_value
        except ValueError:
            self.log.error(f"unable to set with value {value}")

    @property
    def active(self):
        """
        The active status.
        When a component is active it should perform its function
        When it is inactive it should either return zeros or bypass the signal.
        If the component is a generator it should generate zeros when inactive.
        """
        return self._active
    
    @active.setter
    def active(self, value):
        try:
            bool_val = bool(value)
            self._active = bool_val
            for sub in self.subcomponents:
                sub.active = bool_val
        except ValueError:
            self.log.error(f"Unable to set with value {value}")
    
    def get_subcomponents_str(self, component, depth):
        """
        Returns an indented string representing the tree of subcomponents
        """
        ret_str = ""
        for _ in range(depth):
            ret_str += "  "
        ret_str += f"{component.name}\n"    
        if hasattr(component, "subcomponents") and len(component.subcomponents) > 0:
            for subcomponent in component.subcomponents:
                ret_str += self.get_subcomponents_str(subcomponent, depth + 1)
        return ret_str
    
    def __str__(self):
        ret_str = self.get_subcomponents_str(self, 0)
        return ret_str

Nothing too exciting here. Just some getters and setters, and a way to return a string representation of the component.

Generation Abstract

Okay, only two more abstract classes, I swear! We’ll need to create a file in the signal folder called generator.py. We’ll keep this one short:

import logging

from .component import Component

class Generator(Component):
    def __init__(self, sample_rate: int, frames_per_chunk: int, name: str="Generator"):
        """
        The base class for any signal component that can generate signal.
        Generators should be leaf nodes on the signal tree. That means they have no subcomponents.
        """
        super().__init__(sample_rate, frames_per_chunk, [], name=name)
        self.log = logging.getLogger(__name__)

The main things to note about the Generator class are that it doesn’t have any subcomponents and it inherits from the Component class. In our set of components, Generators are components that produce sound in some way. One type of generator is an oscillator. Oscillators produce sounds that have frequency. Let’s add another file called oscillator.py (we’re getting so close).

import logging
import numpy as np

from .generator import Generator

class Oscillator(Generator):
    """
    The base class for any component that generates a signal with frequency.
    """
    def __init__(self, sample_rate: int, frames_per_chunk: int, name: str="Oscillator"):
        super().__init__(sample_rate, frames_per_chunk, name=name)
        self.log = logging.getLogger(__name__)
        self.frequency = 0.0
        self.phase = 0.0
        self.amplitude = 0.1

    @property
    def frequency(self):
        """The wave frequency in hertz"""
        return self._frequency

    @frequency.setter
    def frequency(self, value):
        try:
            float_value = float(value)
            self._frequency = float_value
        except:
            self.log.error(f"unable to set with value {value}")

    @property
    def phase(self):
        """The phase offset of the wave in radians"""
        return self._phase
    
    @phase.setter
    def phase(self, value):
        try:
            float_value = float(value)
            self._phase = float_value
        except:
            self.log.error(f"unable to set with value {value}")

    def set_phase_degrees(self, degrees):
        """
        Convenience method to set the phase offset in degrees instead of radians
        """
        try:
            radians = (degrees / 360) * 2 * np.pi
            self.phase = radians
        except:
            self.log.error(f"unable to set with value {degrees}")

    @property
    def amplitude(self):
        """The wave amplitude from 0.0 to 1.0"""
        return self._amplitude

    @amplitude.setter
    def amplitude(self, value):
        try:
            float_value = float(value)
            if float_value >= 0.0 and float_value <= 1.0:
                self._amplitude = float_value
            else:
                raise ValueError
        except:
            self.log.error(f"unable to set with value {value}")

    @property
    def active(self):
        """
        Whether or not the oscillator is active
        Overrides the active property of the Component class
        """
        return self._active
    
    @active.setter
    def active(self, value):
        try:
            bool_val = bool(value)
            self._active = bool_val
            self.frequency = 0.0 if not bool_val else self.frequency
        except ValueError:
            self.log.error(f"Unable to set with value {value}")

You’ll notice, similar to the Component class, the Oscillator class is mostly full of getters and setters for properties all of its subclasses will have. Specifically, we have frequency, phase, and amplitude.

Sine wave oscillator

We made it. We got a sneak peak in the last tutorial, but this time we get to talk about how it works. Create a file in the signal module called sine_wave_oscillator.py.

import logging

import numpy as np

from .oscillator import Oscillator

class SineWaveOscillator(Oscillator):
    def __init__(self, sample_rate: int, frames_per_chunk: int, name: str="SineWaveOscillator"):
        super().__init__(sample_rate, frames_per_chunk, name=name)
        self.log = logging.getLogger(__name__)

    def __iter__(self):
        self._chunk_duration = self.frames_per_chunk / self.sample_rate
        self._chunk_start_time = 0.0
        self._chunk_end_time = self._chunk_duration
        return self
    
    def __next__(self):
        # Generate the sample
        if self.frequency <= 0.0:
            if self.frequency < 0.0:
                self.log.error("Overriding negative frequency to 0")
            sample = np.zeros(self.frames_per_chunk)
        
        else:
            ts = np.linspace(self._chunk_start_time, self._chunk_end_time, self.frames_per_chunk, endpoint=False)
            sample = self.amplitude * np.sin(self.phase + (2 * np.pi * self.frequency) * ts)

        # Update the state variables for next time
        self._chunk_start_time = self._chunk_end_time
        self._chunk_end_time += self._chunk_duration

        return sample.astype(np.float32)
    
    def __deepcopy__(self, memo):
        return SineWaveOscillator(self.sample_rate, self.frames_per_chunk, name="SineWaveOscillator")

Because we've already implemented some common properties in the Oscillator base class, we just have to implement the methods listed in the "interface" we specified in the Component class docstring. Remember, the __iter__ and __next__ methods make our class an iterator. We'll see why all the signal components implement __deepcopy__ later, when we work on the poly synth functionality. For now, just know that it will come in handy later to be able to duplicate the signal chain.

How does it work though?

The most interesting lines are in the else branch of __next__.

ts = np.linspace(self._chunk_start_time, self._chunk_end_time, self.frames_per_chunk, endpoint=False)
sample = self.amplitude * np.sin(self.phase + (2 * np.pi * self.frequency) * ts)

This is the code that generates our sound wave. Let's break it down. First we generate a numpy array called ts (as in times). This is an array of evenly spaced time values that will represent the times for which we generate samples of our sine wave approximation.

What about the parameters we pass in to np.linspace? The self._chunk_start_time variable tells the function what value to start the array with. The self._chunk_end_time variable along with endpoint=False tells the linspace function to generate values from self._chunk_start_time up to but not including the end point. The self.frames_per_chunk variable tells linspace how big the array should be.

Both the self._chunk_start_time and self._chunk_end_time variables are initialized by the __iter__ method and updated each time __next__ is called. By "rolling" this array along the time axis we can generate a series of continuous seeming sine values.

Let's take a look at the next line,  sample = self.amplitude * np.sin(self.phase + (2 * np.pi * self.frequency) * ts). According to Wikipedia, the formula for a sine wave as a function of time is y(t) = amplitude * sin((2 * pi * frequency * time) + phase). Looks familiar, right? It's so easy to write like this because NumPy's vectorized functions allow us to perform operations on entire arrays at once instead of needing to loop through each element. In fact, it's extremely important that we stick to vectorized functions as much as we can when working on the PyAudio thread. This is because the synthesis code needs to be able to generate audio faster than it can be played back, and vectorization is fast.

Testing it out

Let's test what we have so far. Open your __main__.py file. First let's clean up the temporary generator function we used in the last tutorial. Delete the sine_generator method. You can also go ahead and remove the numpy import.

Now add to the imports:

from synth.synthesis.signal.sine_wave_oscillator import SineWaveOscillator

Go ahead and replace the line

    # Create a sine wave generator
    sine_wave_generator = sine_generator(frequency=440.0, amplitude=0.5, sample_rate=settings.sample_rate, frames_per_chunk=settings.frames_per_chunk)

with

    # create a sine wave oscillator
    osc_a = SineWaveOscillator(settings.sample_rate, settings.frames_per_chunk)
    osc_a.frequency = 440.0

In the StreamPlayer constructor, change input_delegate=sine_wave_generator to input_delegate=osc_a. We can also take this time to make our program a little more robust. Let's wrap the main loop in a try-except block.

    try:
        stream_player.play()
        while True:
            sleep(1)
    except KeyboardInterrupt:
        log.info("Caught keyboard interrupt. Exiting the program.")

If you didn't follow, that's alright. Your __main__.py file should look like this:

import logging
from time import sleep

from . import settings
from .playback.stream_player import StreamPlayer
from .synthesis.signal.sine_wave_oscillator import SineWaveOscillator

if __name__ == "__main__":
    logging.basicConfig(level=logging.DEBUG, 
                        format='%(asctime)s [%(levelname)s] %(module)s [%(funcName)s]: %(message)s',
                        datefmt='%Y-%m-%d %H:%M:%S')
    
    log = logging.getLogger(__name__)
    log.info(
        """
    __
   |  |
 __|  |___             ______         __        __
/__    __/          __|______|__     |  |      |  |
   |  |            |  |      |  |    |__|______|  |
   |  |     __     |  |      |  |       |______   |
   |__|____|__|    |__|______|__|        ______|__|
      |____|          |______|          |______|

                                                            __              __
                                                           |  |            |  |
    _______         __        __      __    ____         __|  |___         |  |   ____
 __|_______|       |  |      |  |    |  |__|____|__     /__    __/         |  |__|____|__
|__|_______        |__|______|  |    |   __|    |  |       |  |            |   __|    |  |
   |_______|__        |______   |    |  |       |  |       |  |     __     |  |       |  |
 __________|__|        ______|__|    |  |       |  |       |__|____|__|    |  |       |  |
|__________|          |______|       |__|       |__|          |____|       |__|       |__|
        """
    )

    # create a sine wave oscillator
    osc_a = SineWaveOscillator(settings.sample_rate, settings.frames_per_chunk)
    osc_a.frequency = 440.0

    # Create a stream player
    stream_player = StreamPlayer(sample_rate=settings.sample_rate, frames_per_chunk=settings.frames_per_chunk, input_delegate=osc_a)
    
    try:
        stream_player.play()
        while True:
            sleep(1)
    except KeyboardInterrupt:
        log.info("Caught keyboard interrupt. Exiting the program.")

Just like in part 1, I'll warn you to start with the volume low/off and turn it up slowly until it's a comfortable volume. Make sure your virtual environment is still active and run the program with python -m synth. If all went well, you should hear A440 coming out of your speaker just like before.

Here are the first 1000 samples of the sine wave we generated:

Sine waves are boring

Not satisfied with the lowly sine wave, you say? Not enough right angles and OOP? Have I got the class for you! Let's add a file in the signal module called square_wave_oscillator.py.

import logging

import numpy as np

from .sine_wave_oscillator import SineWaveOscillator

class SquareWaveOscillator(SineWaveOscillator):
    def __init__(self, sample_rate: int, frames_per_chunk: int, name: str="SquareWaveOscillator"):
        super().__init__(sample_rate, frames_per_chunk, name=name)
        self.log = logging.getLogger(__name__)

    def __next__(self):
        """
        This oscillator works by first generating a sine wave, then setting every frame
        to either -1 or 1, depending on the sign of the wave y value at that point.
        This has the effect of filtering it into a square wave
        """
        sine_wave = super().__next__()
        square_wave = self.amplitude * np.sign(sine_wave)
        return square_wave
    
    def __deepcopy__(self, memo):
        return SquareWaveOscillator(self.sample_rate, self.frames_per_chunk, name="SquareWaveOscillator")

The idea here is pretty straightforward. First we call the super class's __next__ method to get a sine wave chunk. Then we use NumPy's sign function to transform the sine wave into a square wave. sign returns 1 or -1 depending on whether a given element is positive or negative, respectively.

As you can see, we can extend the functionality of our sine wave oscillator pretty easily. I encourage you to play around with the NumPy library to create fun oscillator types! We'll create a couple more optional oscillators at the end of this tutorial.

Again and a gain

We'll want a few more components to build our signal chain. Let's make a gain component so we can easily control the volume of any branch of the tree of signal components. Create a file in the signal submodule called gain.py.

import logging
from copy import deepcopy
from typing import List

import numpy as np

from .component import Component

class Gain(Component):
    """
    The gain component multiplies the amplitude of the signal by a constant factor.
    """
    def __init__(self, sample_rate: int, frames_per_chunk: int, subcomponents: List['Component'] = [], name: str="Gain", control_tag: str="gain"):
        super().__init__(sample_rate, frames_per_chunk, subcomponents, name, control_tag)
        self.log = logging.getLogger(__name__)
        self.amp = 1.0
        self.control_tag = control_tag

    def __iter__(self):
        self.subcomponent_iter = iter(self.subcomponents[0]) # Gain should only have 1 subcomponent
        return self
    
    def __next__(self):
        chunk = next(self.subcomponent_iter)
        return chunk * self.amp
    
    def __deepcopy__(self, memo):
        return Gain(self.sample_rate, self.frames_per_chunk, subcomponents=[deepcopy(self.subcomponents[0], memo)], name=self.name, control_tag=self.control_tag)
    
    @property
    def amp(self):
        return self._amp
    
    @amp.setter
    def amp(self, value):
        try:
            float_val = float(value)
            if float_val > 1.0 or float_val < 0.0:
                raise ValueError
            self._amp = float_val
        except ValueError:
            self.log.error(f"Gain must be between 0.0 and 1.0, got {value}")

Notice we're just getting the next chunk from our subcomponent and multiplying it by the amp value. This effectively scales the amplitude of the waves we generate. Amplitude correlates to perceived volume, but there are other factors, like frequency, that affect perceived volume or loudness. You may notice later on that a sine wave of the same frequency and amplitude seems quieter than a square wave. All of that said, you can expect our oscillators to sound about twice as loud when the amplitude is multiplied by 10. So 1.0 will sound about twice as loud as 0.1.

Adding to the mix

Alright, how about a mixer component? Mixing is a can of worms, so we'll keep it simple. We'll simply return the mean of all the signals we want to mix:

from copy import deepcopy
from typing import List

import numpy as np

from .component import Component

class Mixer(Component):
    def __init__(self, sample_rate: int, frames_per_chunk: int, subcomponents: List[Component] = [], name: str="Mixer"):
        super().__init__(sample_rate, frames_per_chunk, subcomponents, name)

    def __iter__(self):
        self.source_iters = [iter(component) for component in self.subcomponents]
        return self

    def __next__(self):
        input_signals = [next(source_iter) for source_iter in self.source_iters]
        mixed_signal = np.mean(input_signals, axis=0)
        mixed_signal = np.clip(mixed_signal, -1.0, 1.0)
        return mixed_signal.astype(np.float32)

    def __deepcopy__(self, memo):
        return Mixer(self.sample_rate, self.frames_per_chunk, [deepcopy(component, memo) for component in self.subcomponents], self.name)

This is the first component we've seen that can have multiple subcomponents. We're using a list comprehension to build a list of chunks generated (or modulated) by each subcomponent. Once our subcomponents have reported back, we can mix the data sources by averaging them element for element. Given, for example, two waves of equal amplitude, this method of mixing should combine them without changing the overall volume too much.

Another test

Alright, we've got all the signal components we need to build a simple synthesizer. We know the sine wave oscillator works, but let's test out our gain and mixer components.

Our goal here will be to set up a tree of signal components which can generate two audio streams and mix them together.

        mixer
       /      \
      /        \
     /          \
   gain       gain
     |          |
     |          |
sine_osc   square_osc

Open up your __main__.py file. We'll have to add a few more imports. Under the SineWaveOscillator import, add:

from .synthesis.signal.square_wave_oscillator import SquareWaveOscillator
from .synthesis.signal.gain import Gain
from .synthesis.signal.mixer import Mixer

Now find the line osc_a.frequency = 440.0 and replace it with

    osc_b = SquareWaveOscillator(settings.sample_rate, settings.frames_per_chunk)

    gain_a = Gain(settings.sample_rate, settings.frames_per_chunk, subcomponents=[osc_a])
    gain_b = Gain(settings.sample_rate, settings.frames_per_chunk, subcomponents=[osc_b])

    mixer = Mixer(settings.sample_rate, settings.frames_per_chunk, subcomponents=[gain_a, gain_b])

    osc_a.frequency = 440.0
    osc_b.frequency = 440.0

Now, on the line

stream_player = StreamPlayer(sample_rate=settings.sample_rate, frames_per_chunk=settings.frames_per_chunk, input_delegate=osc_a)

change input_delegate=osc_a to input_delegate=mixer. Your __main__.py file looks like

import logging
from time import sleep

from . import settings
from .playback.stream_player import StreamPlayer
from .synthesis.signal.sine_wave_oscillator import SineWaveOscillator
from .synthesis.signal.square_wave_oscillator import SquareWaveOscillator
from .synthesis.signal.gain import Gain
from .synthesis.signal.mixer import Mixer

if __name__ == "__main__":
    logging.basicConfig(level=logging.DEBUG, 
                        format='%(asctime)s [%(levelname)s] %(module)s [%(funcName)s]: %(message)s',
                        datefmt='%Y-%m-%d %H:%M:%S')
    
    log = logging.getLogger(__name__)
    log.info(
        """
    __
   |  |
 __|  |___             ______         __        __
/__    __/          __|______|__     |  |      |  |
   |  |            |  |      |  |    |__|______|  |
   |  |     __     |  |      |  |       |______   |
   |__|____|__|    |__|______|__|        ______|__|
      |____|          |______|          |______|

                                                            __              __
                                                           |  |            |  |
    _______         __        __      __    ____         __|  |___         |  |   ____
 __|_______|       |  |      |  |    |  |__|____|__     /__    __/         |  |__|____|__
|__|_______        |__|______|  |    |   __|    |  |       |  |            |   __|    |  |
   |_______|__        |______   |    |  |       |  |       |  |     __     |  |       |  |
 __________|__|        ______|__|    |  |       |  |       |__|____|__|    |  |       |  |
|__________|          |______|       |__|       |__|          |____|       |__|       |__|
        """
    )

    # create a sine wave oscillator
    osc_a = SineWaveOscillator(settings.sample_rate, settings.frames_per_chunk)
    osc_b = SquareWaveOscillator(settings.sample_rate, settings.frames_per_chunk)

    gain_a = Gain(settings.sample_rate, settings.frames_per_chunk, subcomponents=[osc_a])
    gain_b = Gain(settings.sample_rate, settings.frames_per_chunk, subcomponents=[osc_b])

    mixer = Mixer(settings.sample_rate, settings.frames_per_chunk, subcomponents=[gain_a, gain_b])

    osc_a.frequency = 440.0
    osc_b.frequency = 440.0

    # Create a stream player
    stream_player = StreamPlayer(sample_rate=settings.sample_rate, frames_per_chunk=settings.frames_per_chunk, input_delegate=mixer)
    
    try:
        stream_player.play()
        while True:
            sleep(1)
    except KeyboardInterrupt:
        log.info("Caught keyboard interrupt. Exiting the program.")

Now you can run the program again with python -m synth. The resulting wave form is a mix between a sine and square wave.

Optional but fun

If you're not interested in adding different sound textures you can skip this part and get straight to the MIDI implementation. All of the instructions use the sine and square wave oscillators, so the extra ones we add below are totally optional.

We'll be adding a sawtooth wave oscillator, a triangle wave oscillator, and a white noise generator.

Sawtooth & sons

Sine and square are fun, but let's not leave out our triangular friends! Add a file called sawtooth_wave_oscillator.py.

import logging

import numpy as np

from .oscillator import Oscillator

class SawtoothWaveOscillator(Oscillator):
    def __init__(self, sample_rate: int, frames_per_chunk: int, name: str="SawtoothWaveOscillator"):
        super().__init__(sample_rate, frames_per_chunk, name=name)
        self.log = logging.getLogger(__name__)

    def __iter__(self):
        self._chunk_duration = self.frames_per_chunk / self.sample_rate
        self._chunk_start_time = 0.0
        self._chunk_end_time = self._chunk_duration
        return self
    
    def __next__(self):
        # Generate the sample
        if self.frequency <= 0.0:
            if self.frequency < 0.0:
                self.log.error("Overriding negative frequency to 0")
            sample = np.zeros(self.frames_per_chunk)
        
        else:
            # calculate the period
            period = 1.0 / self.frequency
            ts = np.linspace(self._chunk_start_time, self._chunk_end_time, self.frames_per_chunk, endpoint=False)
            sample = self.amplitude * (2 * (ts / period - np.floor(0.5 + ts / period)))

        # Update the state variables for next time
        self._chunk_start_time = self._chunk_end_time
        self._chunk_end_time += self._chunk_duration

        return sample.astype(np.float32)
    
    def __deepcopy__(self, memo):
        return SawtoothWaveOscillator(self.sample_rate, self.frames_per_chunk, name="SawtoothWaveOscillator")

Again, we'll use Wikipedia's formula to define our algorithm. That is, 2 * (t/p - floor(0.5 + t/p)), where t is time and p is the wave period. We can calculate the period as the inverse of frequency. The output steadily increases over the period, then resets back to the minimum value.

We can take things a step further by extending the sawtooth class, just like we did with the square wave. Add a file called triangle_wave_oscillator.py to the signal submodule.

import logging

import numpy as np

from .sawtooth_wave_oscillator import SawtoothWaveOscillator

class TriangleWaveOscillator(SawtoothWaveOscillator):
    def __init__(self, sample_rate: int, frames_per_chunk: int, name: str="TriWaveOscillator"):
        super().__init__(sample_rate, frames_per_chunk, name=name)
        self.log = logging.getLogger(__name__)
    
    def __iter__(self):
        return super().__iter__()

    def __next__(self):
        sawtooth = super().__next__()
        triangle = (abs(sawtooth) - 0.5) * 2
        return triangle.astype(np.float32)

    def __deepcopy__(self, memo):
        return TriangleWaveOscillator(self.sample_rate, self.frames_per_chunk, name="TriWaveOscillator")

We can filter a sawtooth wave into a triangle wave by taking its absolute value.

You may notice that after taking the absolute value, we're left with values between 0 and 1, but our audio stream expects values between -1 and 1. We can shift the wave down by subtracting 0.5, and "stretch" it by multiplying by 2. This leaves us with a fully formed triangle wave.

The last component we'll add for now is a white noise generator. White noise is random noise that's spread evenly across the frequency spectrum. And, yes, NumPy has a function for that. Add a file called noise_generator.py.

import logging
from copy import deepcopy

import numpy as np

from .generator import Generator

class NoiseGenerator(Generator):
    def __init__(self, sample_rate, frames_per_chunk, name="Noise Generator"):
        super().__init__(sample_rate, frames_per_chunk, name=name)
        self.log = logging.getLogger(__name__)
        self.amp = 0.1

    def __iter__(self):
        self.rng = np.random.default_rng()
        return super().__iter__()
    
    def __next__(self):
        if self.active:
            noise = self.amp * self.rng.uniform(-1.0, 1.0, self.frames_per_chunk)
            return noise.astype(np.float32)
        else:
            return np.zeros(self.frames_per_chunk, dtype=np.float32)
    
    def __deepcopy__(self, memo):
        return NoiseGenerator(self.sample_rate, self.frames_per_chunk)

In the __iter__ method, we get a random number generator from NumPy. In the __next__ method we generate uniformly random audio data by calling the uniform function. The effect of mixing a wave oscillator and the noise generator is to... make the wave noisier.

And here's what it looks like to add even more noise to the mix:

Conclusion

In this tutorial, we implemented the core synthesis code. We built a set of components that can be used as the basis for a multi-voice synthesizer. We tested the functionality and, optionally, built an extra set of oscillators and a white noise generator.

In the next part, we'll implement MIDI functionality. See you there!

Code up to this point.