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.
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 Component
s 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.
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.
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.
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, Generator
s 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
.
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.
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.
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:
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.
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
.
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.
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.
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.
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:
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!