mike | July 17, 2023, 2:58 a.m.
In this tutorial, we'll use the modules we built in the preceding parts to build a Synthesizer class. In part 1 we built a stream player to play audio out loud. In part 2 we wrote the core synthesis code, giving our stream player something to play. In part 3 we gave ourselves a way to control the signal components with MIDI. By the end of this part, you will have a 4 voice, 2 oscillator-per-voice poly synth. We'll be able to adjust the oscillator mix via MIDI CC messages as well.
Remember what our minimal synthesizer consists of?
Now we've built all 3 pieces, let's put them together. The goal here is to build a 4 voice synthesizer, so let's think about what we'll need to do that. Recall how we constructed a single voice, or "mono", synth by arranging our signal components in a tree structure and manipulating the components. You can think of that tree as comprising a single voice. Since we want our synth to have 4 voices, it makes sense to encapsulate this data structure and functionality into a class, which we'll call Voice
.
In addition to the Voice
class, we'll make a supporting class called Chain
which will act as an interface to our tree of signal components. Each instance of Voice
will have a signal.Chain
instance. The Chain
class's job is to provide access to the functionality of the signal components without exposing their structure.
And lastly, we'll need the Synthesizer
class itself. If everything we've built so far are the bread, fillings, and condiments, this class is the sandwich. Calling run
on an instance of this class is analogous to flipping the power switch of a physical synth to the On position.
Let's build from the bottom up. Create a file in the signal submodule called chain.py
. Add the following imports:
import logging
from copy import deepcopy
from .component import Component
from .oscillator import Oscillator
And below that, add the class definition:
class Chain():
def __init__(self, root_component: Component):
self.log = logging.getLogger(__name__)
self._root_component = root_component
def __iter__(self):
self.root_iter = iter(self._root_component)
return self
def __next__(self):
chunk = next(self.root_iter)
return chunk
def __deepcopy__(self, memo):
return Chain(deepcopy(self._root_component, memo))
def __str__(self):
string = "--- Signal Chain ---\n"
string += str(self._root_component)
return string
@property
def active(self):
"""
The active status.
The chain is considered active when the root component is active
"""
return self._root_component.active
def get_components_by_class(self, cls):
components = []
def search_subcomponents(component):
if isinstance(component, cls):
components.append(component)
if hasattr(component, "subcomponents") and len(component.subcomponents) > 0:
for subcomponent in component.subcomponents:
search_subcomponents(subcomponent)
search_subcomponents(self._root_component)
return components
def get_components_by_control_tag(self, control_tag):
components = []
def search_subcomponents(component):
if hasattr(component, "control_tag") and component.control_tag == control_tag:
components.append(component)
if hasattr(component, "subcomponents") and len(component.subcomponents) > 0:
for subcomponent in component.subcomponents:
search_subcomponents(subcomponent)
search_subcomponents(self._root_component)
return components
def note_on(self, frequency):
for osc in self.get_components_by_class(Oscillator):
osc.frequency = frequency
self._root_component.active = True
def note_off(self):
# Setting the root component active status should propagate down the tree
self._root_component.active = False
You'll notice that we're essentially acting as a wrapper for Component
types and giving the note_on
and note_off
methods to control them. I'll be the first to admit this section of code could be cleaned up or consolidated, but hey, sometimes you go with what works. The original idea here was to gate access to the signal component tree structure, but as you'll see later we end up accessing signal components through the Voice
class anyway.
Now that we've got that out of the way, let's add a nice short class. Create a file in the synthesis
submodule and call it voice.py
.
from .signal.chain import Chain
class Voice:
def __init__(self, signal_chain: Chain):
self.signal_chain = iter(signal_chain)
self.note_id = None
@property
def active(self):
return self.signal_chain.active
def note_on(self, frequency, id):
self._active = True
self.note_id = id
self.signal_chain.note_on(frequency)
def note_off(self):
self.signal_chain.note_off()
As you can see, we only need a few members in our Voice
class. The class encapsulates a small set of actions: turning a musical note on and off. By having multiple instances of this class, our synth can play multiple notes at the same time.
The next class we want to write is the Synthesizer
class. Before we dive into the code, let's review what we've built so far and how the Synthesizer
class will fit in.
First, we built a stream player, which gives us a way to play audio out loud. The stream player fetches audio data chunk by chunk via a callback function. This callback function is the hook where we can attach our synthesizer functionality to the audio output stream. Our Synthesizer will attach a generator function to the stream player. The generator function mixes the output from our 4 voices and yields a bufferful of audio data. You can think of this as a continuous process. Even when the synth is silent, the generator will yield chunks full of zeros.
After the stream player, we built a set of signal components that can generate and modulate audio. Our Synthesizer
class uses these components through the Voice
class. When the synth starts up it creates a prototype signal chain and then makes a deep copy of it for each voice.
And finally, we built a way to control our synth. The MIDI listener interacts with the synth only indirectly, by placing messages into a mailbox. This is the main way we control our Synthesizer
instance. The synth thread's main loop is to wait for mailbox messages and act on them.
Alright, let's get to the code! Create a file in the synth
folder called synthesizer.py
. Add the following imports:
import threading
import logging
from queue import Queue
from copy import deepcopy
import numpy as np
from . import midi
from .synthesis.voice import Voice
from .synthesis.signal.chain import Chain
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
from .playback.stream_player import StreamPlayer
Next, add the class constructor.
class Synthesizer(threading.Thread):
def __init__(self, sample_rate: int, frames_per_chunk: int, mailbox: Queue, num_voices: int=4) -> None:
super().__init__(name="Synthesizer Thread")
self.log = logging.getLogger(__name__)
self.sample_rate = sample_rate
self.frames_per_chunk = frames_per_chunk
self.mailbox = mailbox
self.num_voices = num_voices
self.should_run = True
# Set up the voices
signal_chain_prototype = self.setup_signal_chain()
self.log.info(f"Signal Chain Prototype:\n{str(signal_chain_prototype)}")
self.voices = [Voice(deepcopy(signal_chain_prototype)) for _ in range(self.num_voices)]
# Set up the stream player
self.stream_player = StreamPlayer(self.sample_rate, self.frames_per_chunk, self.generator())
The two most important sections are setting up the voices and setting up the stream player. As mentioned above, we create a signal chain prototype, then create deep copies of it for each voice. After that we instantiate a stream player and pass in the generator function which we'll define below.
For now, let's define the message handling behavior. Add the following code below the constructor:
def run(self):
self.stream_player.play()
while self.should_run and self.stream_player.is_active():
# get() is a blocking call
if message := self.mailbox.get():
self.message_handler(message)
return
def message_handler(self, message: str):
"""Handles messages from the mailbox."""
match message.split():
case ["exit"]:
self.log.info("Got exit command.")
self.stream_player.stop()
self.should_run = False
case ["note_on", "-n", note, "-c", channel]:
int_note = int(note)
chan = int(channel)
note_name = midi.note_names[int_note]
self.note_on(int_note, chan)
self.log.info(f"Note on {note_name} ({int_note}), chan {chan}")
case ["note_off", "-n", note, "-c", channel]:
int_note = int(note)
chan = int(channel)
note_name = midi.note_names[int_note]
self.note_off(int_note, chan)
self.log.info(f"Note off {note_name} ({int_note}), chan {chan}")
case ["control_change", "-c", channel, "-n", cc_num, "-v", control_val]:
chan = int(channel)
int_cc_num = int(cc_num)
int_cc_val = int(control_val)
self.control_change_handler(chan, int_cc_num, int_cc_val)
case _:
self.log.info(f"Matched unknown command: {message}")
def control_change_handler(self, channel: int, cc_number: int, val: int):
self.log.info(f"Control Change: channel {channel}, number {cc_number}, value {val}")
As you can see, the run
method is quite simple for this thread. We're simply checking for mailbox messages and then sending them to a handler. So far, we're handling note_on
and note_off
messages, but simply printing out a log for MIDI CC messages.
Let's define the methods that will take care of the sound synthesis:
def setup_signal_chain(self) -> Chain:
"""Build the signal chain prototype."""
osc_a = SineWaveOscillator(self.sample_rate, self.frames_per_chunk)
osc_b = SquareWaveOscillator(self.sample_rate, self.frames_per_chunk)
gain_a = Gain(self.sample_rate, self.frames_per_chunk, [osc_a], control_tag="gain_a")
gain_b = Gain(self.sample_rate, self.frames_per_chunk, [osc_b], control_tag="gain_b")
mixer = Mixer(self.sample_rate, self.frames_per_chunk, [gain_a, gain_b])
signal_chain = Chain(mixer)
return signal_chain
def generator(self):
"""
Generate the signal by mixing the voice outputs
"""
mix = np.zeros(self.frames_per_chunk, np.float32)
num_active_voices = 0
while True:
for i in range(self.num_voices):
voice = self.voices[i]
mix += next(voice.signal_chain)
if voice.active:
num_active_voices += 1
# Prevent the mix from going outside the range (-1, 1)
mix = np.clip(mix, -1.0, 1.0)
yield mix
mix = np.zeros(self.frames_per_chunk, np.float32)
num_active_voices = 0
We used setup_signal_chain
and generator
in the class constructor. The setup_signal_chain
method is where we define the structure of our signal chain. This is where we decide what oscillator types to use and what order the components are arranged in. If you built the extra sawtooth and triangle wave oscillator types in part 2, this is where you can substitute them for the sine or square types.
generator
is where our synthesizer produces sound. You may notice it simply gets the next chunk from each voice and sums them together. It is essentially a simple mixer.
And finally (for now), let's add the code our message handler will use to turn notes on and off:
def note_on(self, note: int, chan: int):
"""
Set a voice on with the given note.
If there are no unused voices, drop the voice that has been on for the longest and use that voice
"""
note_id = self.get_note_id(note, chan)
freq = midi.frequencies[note]
for i in range(len(self.voices)):
voice = self.voices[i]
if not voice.active:
voice.note_on(freq, note_id)
self.voices.append(self.voices.pop(i)) # Move this voice to the back of the list. It should be popped last
break
if i == len(self.voices) - 1:
self.log.debug(f"Had no unused voices!")
self.voices[0].note_off()
self.voices[0].note_on(freq, note_id)
self.voices.append(self.voices.pop(0))
def note_off(self, note: int, chan: int):
"""
Find the voice playing the given note and turn it off.
"""
note_id = self.get_note_id(note, chan)
for i in range(len(self.voices)):
voice = self.voices[i]
if voice.active and voice.note_id == note_id:
voice.note_off()
def get_note_id(self, note: int, chan: int):
"""
Generate an id for a given note and channel
By hashing the note and channel we can ensure that we are turning off the exact note
that was turned on
"""
note_id = hash(f"{note}{chan}")
return note_id
The note_on
and note_off
methods have to do a bit more than the ones inside the Voice
class. Since we are able to play multiple notes at the same time, we are faced with the problem of knowing which voice to use when turning on a new note. We could simply iterate through the list of voices and turn on the first unused one, but then what happens when there are no unused voices? We probably want to drop the note that has been on the longest and turn on the new note. But we need to know which note has been on for the longest in that case. By maintaining the list of voices as a FIFO stack, we can solve the problem.
We should be at the point where we can try out the note_on
/note_off
functionality. But first we need to delete some code we no longer need. Open up __main__.py
and delete all of the signal components and the stream player. That's roughly the code section that looks like:
# 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])
# Create a stream player
stream_player = StreamPlayer(sample_rate=settings.sample_rate, frames_per_chunk=settings.frames_per_chunk, input_delegate=mixer)
Also delete everything inside the try
block. We'll just rewrite it.
Delete all of the unused imports (everything from the synthesis
and playback
modules), and add the following import:
from .synthesizer import Synthesizer
And right above the try
block add:
synthesizer = Synthesizer(settings.sample_rate, settings.frames_per_chunk, synth_mailbox)
Now add the following inside the try
block:
midi_listener.start()
synthesizer.start()
while True:
sleep(1)
A little bit cleaner, right? Right below the except
clause, delete the line
stream_player.stop()
And below listener_mailbox.put("exit")
add:
synth_mailbox.put("exit")
synthesizer.join()
In case you didn't follow that, your __main__.py
file should look like:
import logging
import queue
import sys
from optparse import OptionParser
from time import sleep
from . import settings
from .midi.midi_listener import MidiListener
from .synthesizer import Synthesizer
if __name__ == "__main__":
parser = OptionParser()
parser.add_option("-p", "--port", dest="midi_port", default=None, help="MIDI port to listen on", metavar="MIDI_PORT")
(options, args) = parser.parse_args()
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(
"""
__
| |
__| |___ ______ __ __
/__ __/ __|______|__ | | | |
| | | | | | |__|______| |
| | __ | | | | |______ |
|__|____|__| |__|______|__| ______|__|
|____| |______| |______|
__ __
| | | |
_______ __ __ __ ____ __| |___ | | ____
__|_______| | | | | | |__|____|__ /__ __/ | |__|____|__
|__|_______ |__|______| | | __| | | | | | __| | |
|_______|__ |______ | | | | | | | __ | | | |
__________|__| ______|__| | | | | |__|____|__| | | | |
|__________| |______| |__| |__| |____| |__| |__|
"""
)
listener_mailbox = queue.Queue()
synth_mailbox = queue.Queue()
midi_listen_port = options.midi_port if options.midi_port else settings.auto_attach
log.info(f"Using MIDI port {midi_listen_port}")
midi_listener = MidiListener(listener_mailbox, synth_mailbox, midi_listen_port)
synthesizer = Synthesizer(settings.sample_rate, settings.frames_per_chunk, synth_mailbox)
try:
midi_listener.start()
synthesizer.start()
while True:
sleep(1)
except KeyboardInterrupt:
log.info("Caught keyboard interrupt. Exiting the program.")
listener_mailbox.put("exit")
synth_mailbox.put("exit")
synthesizer.join()
midi_listener.join()
sys.exit(0)
You can now run the program with python -m synth
. You should be able to play up to 4 notes at once this time.
But how does that affect our output? Remember our plain old sine wave from before? Let's see what it looks like to play 2 notes at once. Here's an example of a minor third:
And just for good measure, here's the same minor third, but with triangle waves instead of sine.
Notice in both the same pattern of big peaks and small peaks? If you look closely, you can see that the small peak pattern is slightly different every time. This suggests some dissonance. Here's what a perfect fifth, a more consonant diad looks like:
See how the pattern is even more consistent? The interference pattern of the waves falls into a stable cycle.
Okay! Right now each voice has a dual oscillator set up, with one sine and one square wave oscillator. Currently the mix is 50/50, but let's make it adjustable. This will involve parsing MIDI control change messages, which will be similar to note on/off.
Before we go any further, let's figure out which MIDI CC number we'll be using to adjust the mix. Run the program.
$ python -m synth
Now, assuming your MIDI controller has a few knobs or faders, pick the one you want to use and try moving it. You should get a log message like:
2023-06-28 21:13:20 [INFO] midi_listener [run]: Matched unknown MIDI message: control_change channel=0 control=70 value=42 time=0
See where it says control=
? Take the number that follows. That's the CC number we'll use to adjust the oscillator mix.
The next thing we need to do is give the synth a way to handle MIDI control change messages. We'll make a new file to handle MIDI implementation values. Create a file in the midi
submodule called implementation.py
. Add an enum:
from enum import Enum
class Implementation(Enum):
OSCILLATOR_MIX = 70
But replace 70
with the value you got when you moved your knob or fader. The values you place in this enum will roughly correspond to the MIDI implementation chart that comes with most synthesizers.
Let's go back to the synthesizer.py
file. Import the enum we just created:
from .midi.implementation import Implementation
And add a new section in the constructor:
# Set up the lookup values
self.osc_mix_vals = np.linspace(0, 1, 128, endpoint=True, dtype=np.float32)
This gives us a 128 element array of values evenly spaced between 0 and 1 (the range of our Gain
component). Since MIDI control change values are between 0-127, we can directly map them to the array. Each time we get a MIDI control change message, we can look up the correspeonding gain value.
In our case, we want the sum of the two oscillator gains to equal 1. By varying the ratio, we can control the oscillator mix. Add code to the control_change_handler
method so it looks like this:
def control_change_handler(self, channel: int, cc_number: int, val: int):
self.log.info(f"Control Change: channel {channel}, number {cc_number}, value {val}")
if cc_number == Implementation.OSCILLATOR_MIX:
gain_a_mix_val = self.osc_mix_vals[val]
gain_b_mix_val = 1 - gain_a_mix_val
self.set_gain_a(gain_a_mix_val)
self.set_gain_b(gain_b_mix_val)
self.log.info(f"Gain A: {gain_a_mix_val}")
self.log.info(f"Gain B: {gain_b_mix_val}")
We've referenced set_gain_a and set_gain_b but we haven't implemented them yet. Go ahead and add two methods to the bottom of the file:
def set_gain_a(self, gain):
for voice in self.voices:
gain_a_components = voice.signal_chain.get_components_by_control_tag("gain_a")
for gain_a in gain_a_components:
gain_a.amp = gain
def set_gain_b(self, gain):
for voice in self.voices:
gain_b_components = voice.signal_chain.get_components_by_control_tag("gain_b")
for gain_b in gain_b_components:
gain_b.amp = gain
And that's all we need! We've now created a basic poly synth with adjustable oscillator mix. As always, run the program with python -m synth
. If you move the control you set up while playing some notes, you should be able to hear the texture of the sound change as the wave shifts from sine to square shape.
The synthesizer.py file should look like:
import threading
import logging
from queue import Queue
from copy import deepcopy
import numpy as np
from . import midi
from .midi.implementation import Implementation
from .synthesis.voice import Voice
from .synthesis.signal.chain import Chain
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
from .playback.stream_player import StreamPlayer
class Synthesizer(threading.Thread):
def __init__(self, sample_rate: int, frames_per_chunk: int, mailbox: Queue, num_voices: int=4) -> None:
super().__init__(name="Synthesizer Thread")
self.log = logging.getLogger(__name__)
self.sample_rate = sample_rate
self.frames_per_chunk = frames_per_chunk
self.mailbox = mailbox
self.num_voices = num_voices
self.should_run = True
# Set up the voices
signal_chain_prototype = self.setup_signal_chain()
self.log.info(f"Signal Chain Prototype:\n{str(signal_chain_prototype)}")
self.voices = [Voice(deepcopy(signal_chain_prototype)) for _ in range(self.num_voices)]
# Set up the stream player
self.stream_player = StreamPlayer(self.sample_rate, self.frames_per_chunk, self.generator())
# Set up the lookup values
self.osc_mix_vals = np.linspace(0, 1, 128, endpoint=True, dtype=np.float32)
def run(self):
self.stream_player.play()
while self.should_run and self.stream_player.is_active():
# get() is a blocking call
if message := self.mailbox.get():
self.message_handler(message)
return
def message_handler(self, message: str):
"""Handles messages from the mailbox."""
match message.split():
case ["exit"]:
self.log.info("Got exit command.")
self.stream_player.stop()
self.should_run = False
case ["note_on", "-n", note, "-c", channel]:
int_note = int(note)
chan = int(channel)
note_name = midi.note_names[int_note]
self.note_on(int_note, chan)
self.log.info(f"Note on {note_name} ({int_note}), chan {chan}")
case ["note_off", "-n", note, "-c", channel]:
int_note = int(note)
chan = int(channel)
note_name = midi.note_names[int_note]
self.note_off(int_note, chan)
self.log.info(f"Note off {note_name} ({int_note}), chan {chan}")
case ["control_change", "-c", channel, "-n", cc_num, "-v", control_val]:
chan = int(channel)
int_cc_num = int(cc_num)
int_cc_val = int(control_val)
self.control_change_handler(chan, int_cc_num, int_cc_val)
case _:
self.log.info(f"Matched unknown command: {message}")
def control_change_handler(self, channel: int, cc_number: int, val: int):
self.log.info(f"Control Change: channel {channel}, number {cc_number}, value {val}")
if cc_number == Implementation.OSCILLATOR_MIX.value:
gain_b_mix_val = self.osc_mix_vals[val]
gain_a_mix_val = 1 - gain_b_mix_val
self.set_gain_a(gain_a_mix_val)
self.set_gain_b(gain_b_mix_val)
self.log.info(f"Gain A: {gain_a_mix_val}")
self.log.info(f"Gain B: {gain_b_mix_val}")
def setup_signal_chain(self) -> Chain:
"""Build the signal chain prototype."""
osc_a = SineWaveOscillator(self.sample_rate, self.frames_per_chunk)
osc_b = SquareWaveOscillator(self.sample_rate, self.frames_per_chunk)
gain_a = Gain(self.sample_rate, self.frames_per_chunk, [osc_a], control_tag="gain_a")
gain_b = Gain(self.sample_rate, self.frames_per_chunk, [osc_b], control_tag="gain_b")
mixer = Mixer(self.sample_rate, self.frames_per_chunk, [gain_a, gain_b])
signal_chain = Chain(mixer)
return signal_chain
def generator(self):
"""
Generate the signal by mixing the voice outputs
"""
mix = np.zeros(self.frames_per_chunk, np.float32)
num_active_voices = 0
while True:
for i in range(self.num_voices):
voice = self.voices[i]
mix += next(voice.signal_chain)
if voice.active:
num_active_voices += 1
# Prevent the mix from going outside the range (-1, 1)
mix = np.clip(mix, -1.0, 1.0)
yield mix
mix = np.zeros(self.frames_per_chunk, np.float32)
num_active_voices = 0
def note_on(self, note: int, chan: int):
"""
Set a voice on with the given note.
If there are no unused voices, drop the voice that has been on for the longest and use that voice
"""
note_id = self.get_note_id(note, chan)
freq = midi.frequencies[note]
for i in range(len(self.voices)):
voice = self.voices[i]
if not voice.active:
voice.note_on(freq, note_id)
self.voices.append(self.voices.pop(i)) # Move this voice to the back of the list. It should be popped last
break
if i == len(self.voices) - 1:
self.log.debug(f"Had no unused voices!")
self.voices[0].note_off()
self.voices[0].note_on(freq, note_id)
self.voices.append(self.voices.pop(0))
def note_off(self, note: int, chan: int):
"""
Find the voice playing the given note and turn it off.
"""
note_id = self.get_note_id(note, chan)
for i in range(len(self.voices)):
voice = self.voices[i]
if voice.active and voice.note_id == note_id:
voice.note_off()
def get_note_id(self, note: int, chan: int):
"""
Generate an id for a given note and channel
By hashing the note and channel we can ensure that we are turning off the exact note
that was turned on
"""
note_id = hash(f"{note}{chan}")
return note_id
def set_gain_a(self, gain):
for voice in self.voices:
gain_a_components = voice.signal_chain.get_components_by_control_tag("gain_a")
for gain_a in gain_a_components:
gain_a.amp = gain
def set_gain_b(self, gain):
for voice in self.voices:
gain_b_components = voice.signal_chain.get_components_by_control_tag("gain_b")
for gain_b in gain_b_components:
gain_b.amp = gain
In this part we put the pieces together. We connected our stream player, signal components, and MIDI listener to create a Synthesizer
class. By now, we've achieved the main goal we set out to accomplish: we built a proof-of-concept poly synth with Python. Hopefully you've enjoyed building it! In the next part, we'll build a few additional FX components and go over some suggestions for taking the project further. As always, thanks for reading and happy coding!