A study to produce a CircuitPython helper library to construct a synthio.waveform wave table object from a list of fundamental and overtone frequencies, amplitudes, and wave shapes (sine, square, triangle, saw, noise).
The WaveBuilder class currently resides in its GitHub repository and can be found in the CircuitPython Community Bundle. The update utility circup can also be used to install cedargrove_wavebuilder.
The objective was simple. Create a realistic approximation of a wind chime sound. Use synthio to stack up a collection of sine wave Note objects starting with the fundamental frequency and mix in a few unique overtones, each with its appropriate volume level. Use the same ADSR envelope for all notes and voilà, we've got a pretty good chime sound. That worked nicely. Let's try to synthesize another voice.
The Analog (-ish) Approach
An iconic drum machine, the Roland TR-808, builds its percussion voices using analog circuits; no digital samples or models in the mix. Our first goal will be to recreate one of the more unique sounds, a high-hat cymbal. The first step in the process uses synthio to define six square wave notes of dissonant frequencies, simulating the six oscillators used to create the foundation of the metallic and slightly periodic percussion sound. After summing the six oscillators, we'll run the combined signal through some resonant filtering, add some distortion, trigger an envelope, and wrap it up with a resonant high-pass filter. Hmm, this will push the limits of my current skills with synthio.
Using an approach similar to the one used to create the wind chime sound, six synthio.Note objects (oscillators) were used, a high-hat envelope was linked to each, and a sine wave ring modulator was used to simulate filter resonance. The resultant sound was surprisingly close to that of the analog circuitry. However, the FeatherS2 (an ESP32-S2) bogged down and couldn't play the composite note quickly enough to be used for real-time percussion. We'll need to find a better way.
A Digital Waveform Sample Approach
Since waveforms within synthio are created with digital wave tables anyway, perhaps we could create a sample of the complex multi-oscillator waveform instead. The notion would be to create a series of wave table arrays that represent the oscillators then simply sum the sample values together to create the combined wave table array for synthioNote to use. The length of the wave table (number of samples) would match what would be needed for a single wavelength of the fundamental (lowest) oscillator frequency.
Two problems emerged with this approach. First, the resolution of the wave table (number of samples divided by the sample rate) may not be sufficient to accurately represent frequencies that are much higher than the fundamental. Second, when looping the wave table end-to-start with itself, care is needed to match the ending sample of the wave table with the starting sample to avoid an unwanted "click" (distortion) that adds unrelated high frequency harmonics. Both of these issues are detectable and solvable. Since it's possible to predict, a recommendation can be made when resolution is an issue. The loop distortion can be fixed by optionally adjusting a few sample values at the end of the wave table to gradually match the wave table's first value. We'll add these fixes into a helper class.
Success! The composite output wave table approach indeed created the noise similar to the analog circuitry. All that's left to do is to run the noise through a bandpass filter, adjust the resonance, add some distortion, fine-tune the envelope, and run the results through a high-pass filter. But let's make a helper class that will create the oscillators wave table before tackling the additional stuff.
Going to the Head of the Class
To make this into a helpful helper, we'll build a class that can combine an arbitrary number of oscillators together into a single complex waveform wave table. That will make it easy to build a digital sample model of the TR-808 high-hat square wave noise oscillators. The class will also be useful for constructing a wave table representation of the composite wind chime voice out of sine waveforms. And since the wave table will only be associated with a single instance of the synthio.Note object, slow playback from having multiple Note objects will no longer be an issue.
Instead of just generating a one-use wave table, the class will allow real-time adjustments to the oscillator parameters and the wave table characteristics. An updated wave table will be created as-needed when any parameter is changed.
The class is initialized with parameters that will be used to build the synthio-compatible waveform object, WaveBuilder.wave_table. The parameters are:
- oscillators -- a list of oscillator characteristics in tuples of wave shape, frequency, and amplitude
- table_length -- the number of samples in the resultant wave table
- sample_max -- the maximum positive value of a sample; the maximum negative value is internally derived from this value
- lambda_factor -- the number of fundamental wavelengths per wave table
- loop_smoothing -- smooth the transition between the end of a wave table and the start to reduce loop distortion
- debug -- enable debug print messages
The key to building a composite waveform is the oscillators parameter list. The list for the TR-808 high-hat oscillators looks like this:
TRS-808 High-Hat
tr_oscillators = [
(WaveShape.Square, 245, 1/7),
(WaveShape.Square, 306, 1/7),
(WaveShape.Square, 365, 1/7),
(WaveShape.Square, 415, 1/7),
(WaveShape.Square, 437, 1/7),
(WaveShape.Square, 619, 1/7),
(WaveShape.Sine, 3400, 1/14)
]
The first six oscillators provide square waves of equal amplitude at six dissonant frequencies. The seventh oscillator provides a sine wave metallic resonance at 3.4kHz.
Chimes
For the chime sound, the oscillators list is composed of:
chime_oscillators = [
(WaveShape.Sine, 1.00, 0.60),
(WaveShape.Sine, 2.76, 0.20),
(WaveShape.Sine, 5.40, 0.10),
(WaveShape.Sine, 8.93, 0.07),
(WaveShape.Sine, 11.34, 0.02),
(WaveShape.Sine, 18.64, 0.01),
]
For the chime oscillators list, the frequencies are replaced with overtone ratios. Also amplitudes decrease as the overtone ratio increases.
A Single Tone
If all that is needed is a single oscillator, the oscillators list would consist of a single entry:
saw_oscillators = [(WaveShape.Saw, 440, 0.8)]
The amplitude is set for 80% of full (0.8). The frequency value is arbitrary in this case since only one oscillator is defined.
More Sound Formulas
I'll add a few waveform lists here as the experiment progresses. Eventually, the envelope parameters will be provided as well.
Harmonica
harmonica_oscillators = [
(WaveShape.Sine, 1.00, 0.10),
(WaveShape.Sine, 2.00, 0.48),
(WaveShape.Sine, 3.00, 0.28),
(WaveShape.Sine, 4.00, 0.02),
(WaveShape.Sine, 5.00, 0.12),
(WaveShape.Sine, 6.00, 0.0),
(WaveShape.Sine, 7.00, 0.0),
]
The fundamental frequency amplitude for the harmonica is significantly lower than the second and third. The sixth and seventh are shown as zero amplitude in the scientific model but are expected to change as the empirical model is developed.
Plucked Guitar String - Center Position
plucked_string_center_oscillators = [
(WaveShape.Sine, 1.00, 0.382),
(WaveShape.Sine, 2.00, 0.076),
(WaveShape.Sine, 3.00, 0.336),
(WaveShape.Sine, 4.00, 0.084),
(WaveShape.Sine, 5.00, 0.107),
(WaveShape.Sine, 6.00, 0.011),
(WaveShape.Sine, 7.00, 0.004),
]
For this test, the plucked guitar string wave shape was a sine wave. It's likely to sound more authentic with the fundamental frequency as a triangle wave that slowly morphs into a sine wave after the note is plucked.
Plucked Guitar String -- Bridge Position
plucked_string_bridge_oscillators = [
(WaveShape.Sine, 1.00, 0.180),
(WaveShape.Sine, 2.00, 0.299),
(WaveShape.Sine, 3.00, 0.234),
(WaveShape.Sine, 4.00, 0.180),
(WaveShape.Sine, 5.00, 0.090),
(WaveShape.Sine, 6.00, 0.015),
(WaveShape.Sine, 7.00, 0.003),
]
Simple Example Code
The following example plays a single tone every second but switches between a sine wave and a saw wave during the playback while True:
loop. The code was written for a FeatherS2 (ESP32-S2) with a MAX98357A I2S amplifier.
# SPDX-FileCopyrightText: Copyright (c) 2023, 2024 JG for Cedar Grove Maker Studios # SPDX-License-Identifier: MIT import time import board import synthio import audiobusio import audiomixer from cedargrove_wavebuilder import WaveBuilder, WaveShape # Define synth parameters SAMPLE_RATE = 22050 # The sample rate in SPS WAVE_TABLE_LENGTH = 512 # The wave table length in samples SAMPLE_MAXIMUM = 32700 # The maximum value of a sample # Define the oscillator wave shape, overtone ratio, and amplitude tone = [(WaveShape.Sine, 1.0, 0.6)] # Create the wave table and show the debug messages wave = WaveBuilder( oscillators=tone, table_length=WAVE_TABLE_LENGTH, sample_max=SAMPLE_MAXIMUM, lambda_factor=1.0, loop_smoothing=True, debug=True, ) # Define the tone's ADSR envelope parameters tone_envelope = synthio.Envelope( attack_time=0.02 + 0.01, attack_level=1.0 * 1.0, decay_time=0.0, release_time=2.0, sustain_level=1.0, ) # Configure synthesizer for I2S output on a Feather S2 audio_output = audiobusio.I2SOut( bit_clock=board.D12, word_select=board.D9, data=board.D6, left_justified=False ) mixer = audiomixer.Mixer( sample_rate=SAMPLE_RATE, buffer_size=4096, voice_count=1, channel_count=1 ) audio_output.play(mixer) mixer.voice[0].level = 0.50 synth = synthio.Synthesizer(sample_rate=SAMPLE_RATE) mixer.play(synth) note_1 = synthio.Note(880, envelope=tone_envelope, waveform=wave.wave_table) while True: # Set the note waveform to sine and play the note wave.oscillators = [(WaveShape.Sine, 1.0, 0.6)] note_1.waveform = wave.wave_table synth.press(note_1) synth.release(note_1) time.sleep(1) # Set the note waveform to square and play the note wave.oscillators = [(WaveShape.Saw, 1.0, 0.6)] note_1.waveform = wave.wave_table synth.press(note_1) synth.release(note_1) time.sleep(1)
The next example shows how a wind chime sound is simulated using WaveBuilder and synthio.
# SPDX-FileCopyrightText: Copyright (c) 2023, 2024 JG for Cedar Grove Maker Studios # SPDX-License-Identifier: MIT """ =============================================================================== An example of using the WaveBuilder class to simulate wind chimes. The oscillator values are from the CedarGrove Chime class. """ import time import board import synthio import audiobusio import audiomixer from cedargrove_wavebuilder import WaveBuilder, WaveShape print("=== WaveBuilder Simpletest===") # Define synth parameters SAMPLE_RATE = 22050 # The sample rate in SPS WAVE_TABLE_LENGTH = 512 # The wave table length in samples PLOT = False # Plot the wave table array via the REPL # Define the wave type, overtone ratio, and amplitude (0.0 to 1.0) chimes = [ (WaveShape.Sine, 1.0, 0.6), (WaveShape.Sine, 2.76, 0.2), (WaveShape.Sine, 5.40, 0.1), (WaveShape.Sine, 8.93, 0.1), ] wave = WaveBuilder( oscillators=chimes, table_length=WAVE_TABLE_LENGTH, sample_max=32700, lambda_factor=1, loop_smoothing=True, ) if PLOT: # Plot the wave_table array contents for point in wave.wave_table: print(f"({point / 1000}, )") # Define Chime ADSR envelope parameters chime_envelope = synthio.Envelope( attack_time=0.02 + 0.01, attack_level=1.0 * 1.0, decay_time=0.0, release_time=2.0, sustain_level=1.0, ) # Configure synthesizer for I2S output on a Feather S2 audio_output = audiobusio.I2SOut( bit_clock=board.D12, word_select=board.D9, data=board.D6, left_justified=False ) mixer = audiomixer.Mixer( sample_rate=SAMPLE_RATE, buffer_size=4096, voice_count=1, channel_count=1 ) audio_output.play(mixer) mixer.voice[0].level = 0.50 synth = synthio.Synthesizer(sample_rate=SAMPLE_RATE, waveform=wave.wave_table) mixer.play(synth) note_1 = synthio.Note(880, envelope=chime_envelope) print("===") while True: synth.press(note_1) synth.release(note_1) time.sleep(0.5)
Adding It Up
The technique of adding the outputs of several oscillators together to create complex sounds is sometimes referred to as additive synthesis. Traditionally, only sine wave oscillators are used since almost any desired waveform can be created from a collection of sine waves. The WaveBuilder class extends that technique by allowing any or all oscillator wave shapes to be sine, saw, square, or triangle so that fewer oscillators are needed for highly complex sounds such as percussion or distorted electric guitar.
Digital Waveform Sampling Observations
I've always been hesitant to use digital sampling to recreate musical voices. It's not a stretch to imagine that an important bit of the sampled voice could be lost when only a fraction of the sound waveform is captured for looped playback, removing most of the musician's expression or instrument variations. The character of some instruments, like the guitar, is contained in the slight frequency change that happens when a string is plucked (it stretches a little) followed by a change in the waveform shape from triangle to sine as the sound energy is gradually altered by the resonance of the guitar body during decay. The change at the start of the note can easily be captured by digital sampling, but it'll be repeated continuously as the wave table loops. And the gradual change of the waveform shape and amplitude would require a much larger wave table and a method for compressing or expanding the table to accomodate notes of varying durations.
Many synthesizers, including synthio, only utilize a single wavelength of a waveform to recreate the sound because it's prohibitive to capture the entire start-to-finish of a note as it transitions from attack to release. A single wavelength table for Middle C on a piano needs only 190 bytes of storage for the 4.3msec 16-bit recording at a resolution of 22.050k samples per second. However, capturing a wave table of a played note in its entirety, which in some cases may take up to a couple of seconds to completely decay, could easily exceed 60kbytes.
In order to take full advantage of the digital sampling technique for reproducing the voice of a musical instrument, a process to compensate for the missing elements is needed if realism is a requirement (or is an obsessive preference). To pursue that elusive high-quality rendering, there's a lot more on my list to discover about how to link together synthio functions and features to create viable renditions of musical instrument and analog circuitry sounds. I'm still learning.