We'll be using CircuitPython for this project. Are you new to using CircuitPython? No worries, there is a full getting started guide here.
Adafruit suggests using the Mu editor to edit your code and have an interactive REPL in CircuitPython. You can learn about Mu and its installation in this tutorial.
In CIRCUITPY/lib
you will need to have:
- adafruit_bus_device
- adafruit_register
- adafruit_framebuf.mpy
- adafruit_ssd1306.mpy
- adafruit_debouncer.mpy
In addition to putting the project's python files on CIRCUITPY
, you'll also need to copy the font5x8.bin
from the project.
The basic approach to the user interface:
- Check the inputs
- Do something appropriate
- Update the outputs
- Go back to 1
More specifically:
- Check the switches and encoder
- If the encoder has been rotated change the frequency, possibly scaling based on OLED wing buttons
- If the encoder was pushed, change the waveform
- Update the display
- Update the generated signal
- Go back to 1
Since we are dealing with hardware buttons, it's a good idea to clean them up with a debouncer. We use the debouncer in the CircuitPython library bundle.
Using a SAMD21 board (in this case the Feather M0 Express) provides plenty of room for well structured CircuitPython code, but constrains how big of a sample buffer can be allocated. The code reflects this. Notes below indicate where the lower frequency limit and sample rate can be changed to take advantage of the SAMD51 chip on the Feather M4 Express.
In regards to well structured code, bundling the debouncer into a class is one example of this. This lets us create separate debouncer objects for the three wing buttons and the encoder's push switch in addition to putting all the code for the debouncer in one place.
To further separate code functionality, the display and signal generator can be placed into their own classes (which will be in separate files/modules). Everything needs to know what waveforms are supported, so that list can go into its own file/module as well.
The List of Shapes
Let's start with the simplest: the waveforms. This is imported by everything else (except the debouncer).
# SPDX-FileCopyrightText: 2018 Dave Astels for Adafruit Industries # # SPDX-License-Identifier: MIT """ Signal generator wave shapes. Adafruit invests time and resources providing this open source code. Please support Adafruit and open source hardware by purchasing products from Adafruit! Written by Dave Astels for Adafruit Industries Copyright (c) 2018 Adafruit Industries Licensed under the MIT license. All text above must be included in any redistribution. """ SINE = 0 SQUARE = 1 TRIANGLE = 2 SAWTOOTH = 3 NUMBER_OF_SHAPES = 4
We'll structure things nicely and separate out areas of concern. There are two functions that handle changing the frequency and shape. Both of these let us ignore the details of how the changes occur when we write the main loop. It also puts those details in a clearly defined place. Do you want to change the frequency range? change_frequency
is the logical place to do it. The change_frequency function is also responsible for limiting the frequency range. The lower limit of 150Hz prevents the sample buffer from becoming un-allocatably large on a Feather M0 board. If you are running this on a Feather M4 board you can safely decrease it to 10Hz.
def change_frequency(frequency, delta): return min(20000, max(150, frequency + delta)) def change_shape(shape): return (shape + 1) % shapes.NUMBER_OF_SHAPES
Similarly, we bundle the handling of the encoder into a function. It gets the new position from the encoder and computes the difference. That's the important part, as it determines how much to change the frequency by.
Note something else: The encoder and current position are passed in as parameters, and the new position and delta are returned. That new position gets stored by the main loop and passed back in next time. Doing this completely avoids having a global variable. Not only is this a good programming practice, but it also let's the code run significantly faster. This code is very user interface limited, so performance isn't much of a concern, but in a lot of embedded code it could be crucial. The reason is that referencing a global variable requires dictionary lookups, whereas using a local variable (or parameter) is a direct reference. A local/parameter also lives on the stack and not the heap, so there isn't allocation and garbage collection impact.
As you look through the code, you'll see that there are no global variables.
def get_encoder_change(encoder, pos): new_position = encoder.position if pos is None: return (new_position, 0) else: return (new_position, new_position - pos)
Now we have the main setup and loop code. This has been placed in a function. It starts by creating the variables it needs, initializing things, then entering the main loop.
The Display
and Generator
are created next. We'll look into these in detail later. Then the debouncers (see above) and rotary encoder are created. Next, a handful of state variables are allocated and initialized. Finally the display is given some initial content.
def make_debouncable(pin): switch_io = digitalio.DigitalInOut(pin) switch_io.direction = digitalio.Direction.INPUT switch_io.pull = digitalio.Pull.UP return switch_io def run(): display = Display() generator = Generator() button_a = Debouncer(make_debouncable(board.D9)) button_b = Debouncer(make_debouncable(board.D6)) button_c = Debouncer(make_debouncable(board.D5)) encoder_button = Debouncer(make_debouncable(board.D12)) encoder = rotaryio.IncrementalEncoder(board.D10, board.D11) current_position = None # current encoder position change = 0 # the change in encoder position delta = 0 # how much to change the frequency by shape = shapes.SINE # the active waveform frequency = 440 # the current frequency display.update_shape(shape) # initialize the display contents display.update_frequency(frequency)
Now we have the main loop. First, all the debouncers are updated. This debounces the switches, giving us a stable value to use as well as detected rising and falling edges (switching being pushed or released). For the wing buttons, we just need button values and we can get that (logically enough) from the debouncer's value
attribute. For the encoder switch, we want to know when it was pushed and don't care (in this case) when it's released or even what the value is. We can use the debouncer's fell
attribute give us that information.
After updating the debouncers, we check the encoder to see if it has been rotated. If so, we use the values of the three wing buttons to scale the change by 10, 100, or 1000 (depending on which, if any, is pressed). If none are pressed, the change isn't scaled. Finally, if there was a change, the frequency is updated.
Next, if the encoder button was pushed since last time, the wave shape is changed.
At the end of the loop, the display and generator are updated.
while True: encoder_button.update() button_a.update() button_b.update() button_c.update() current_position, change = get_encoder_change(encoder, current_position) if change != 0: if not button_a.value: delta = change * 1000 elif not button_b.value: delta = change * 100 elif not button_c.value: delta = change * 10 else: delta = change frequency = change_frequency(frequency, delta) if encoder_button.fell: shape = change_shape(shape) display.update_shape(shape) display.update_frequency(frequency) generator.update(shape, frequency)
Since the main code is in a function, all that's left is to execute it:
run()
Press 'Download Project Bundle' below, and copy the files over. After you've copied them over, it should look something like this:

# SPDX-FileCopyrightText: 2018 Dave Astels for Adafruit Industries # # SPDX-License-Identifier: MIT """ Main signal generator code. Adafruit invests time and resources providing this open source code. Please support Adafruit and open source hardware by purchasing products from Adafruit! Written by Dave Astels for Adafruit Industries Copyright (c) 2018 Adafruit Industries Licensed under the MIT license. All text above must be included in any redistribution. """ import rotaryio import board import digitalio from display import Display from adafruit_debouncer import Debouncer from generator import Generator import shapes def change_frequency(frequency, delta): return min(20000, max(150, frequency + delta)) def change_shape(shape): return (shape + 1) % shapes.NUMBER_OF_SHAPES def get_encoder_change(encoder, pos): new_position = encoder.position if pos is None: return (new_position, 0) else: return (new_position, new_position - pos) def make_debouncable(pin): switch_io = digitalio.DigitalInOut(pin) switch_io.direction = digitalio.Direction.INPUT switch_io.pull = digitalio.Pull.UP return switch_io def run(): display = Display() generator = Generator() button_a = Debouncer(make_debouncable(board.D9)) button_b = Debouncer(make_debouncable(board.D6)) button_c = Debouncer(make_debouncable(board.D5)) encoder_button = Debouncer(make_debouncable(board.D12)) encoder = rotaryio.IncrementalEncoder(board.D10, board.D11) current_position = None # current encoder position change = 0 # the change in encoder position delta = 0 # how much to change the frequency by shape = shapes.SINE # the active waveform frequency = 440 # the current frequency display.update_shape(shape) # initialize the display contents display.update_frequency(frequency) while True: encoder_button.update() button_a.update() button_b.update() button_c.update() current_position, change = get_encoder_change(encoder, current_position) if change != 0: if not button_a.value: delta = change * 1000 elif not button_b.value: delta = change * 100 elif not button_c.value: delta = change * 10 else: delta = change frequency = change_frequency(frequency, delta) if encoder_button.fell: shape = change_shape(shape) display.update_shape(shape) display.update_frequency(frequency) generator.update(shape, frequency) run()
The Display
The display functionality is encapsulated into the Display
class which is instantiated (just a single instance) in the run
function above. It's then used to show the user the current waveform and frequency.
The class has several instance variables: I2C
and SSD1306_I2C
instances for manipulating the physical display, and places to hold the current shape and frequency.
The constructor initializes the hardware and blanks the display.
def __init__(self): self.i2c = busio.I2C(board.SCL, board.SDA) self.oled = adafruit_ssd1306.SSD1306_I2C(128, 32, self.i2c) self.oled.fill(0) self.oled.show()
There are methods to update the shape and frequency that are used from the main loop. These both operate similarly: they check that there actually was a change, update the appropriate instance variable, and call update to refresh the screen.
def update_shape(self, shape): if shape != self.shape: self.shape = shape self.update() def update_frequency(self, frequency): if frequency != self.frequency: self.frequency = frequency self.update()
The update
method and the draw_*
methods do the work of putting pixels and text on the screen. This involves drawing the appropriate waveform and showing the selected frequency.
def draw_sine(self): for i in range(32): self.oled.pixel(i, int(math.sin(i/32 * math.pi * 2) * 16) + 16, 1) def draw_square(self): for i in range(16): self.oled.pixel(0, 32 - i, 1) self.oled.pixel(i, 31, 1) self.oled.pixel(31, i, 1) self.oled.pixel(15, 16 + i, 1) self.oled.pixel(15, i, 1) self.oled.pixel(16 + i, 0, 1) def draw_triangle(self): for i in range(8): self.oled.pixel(i, 16 + i * 2, 1) self.oled.pixel(8 + i, 32 - i * 2, 1) self.oled.pixel(16 + i, 16 - i * 2, 1) self.oled.pixel(24 + i, i * 2, 1) def draw_sawtooth(self): for i in range(16): self.oled.pixel(0, 16 + i, 1) self.oled.pixel(31, i, 1) for i in range(32): self.oled.pixel(i, 31 - i, 1) def update(self): self.oled.fill(0) if self.shape == shapes.SINE: self.draw_sine() elif self.shape == shapes.SQUARE: self.draw_square() elif self.shape == shapes.TRIANGLE: self.draw_triangle() elif self.shape == shapes.SAWTOOTH: self.draw_sawtooth() self.oled.text("{0}".format(self.frequency), 40, 10) self.oled.show()
# SPDX-FileCopyrightText: 2018 Dave Astels for Adafruit Industries # # SPDX-License-Identifier: MIT """ Display code for signal generator. Adafruit invests time and resources providing this open source code. Please support Adafruit and open source hardware by purchasing products from Adafruit! Written by Dave Astels for Adafruit Industries Copyright (c) 2018 Adafruit Industries Licensed under the MIT license. All text above must be included in any redistribution. """ import math import board import busio import adafruit_ssd1306 import shapes class Display: """Manage the OLED Featherwing display""" i2c = None oled = None shape = None frequency = None def __init__(self): self.i2c = busio.I2C(board.SCL, board.SDA) self.oled = adafruit_ssd1306.SSD1306_I2C(128, 32, self.i2c) self.oled.fill(0) self.oled.show() def draw_sine(self): for i in range(32): self.oled.pixel(i, int(math.sin(i/32 * math.pi * 2) * 16) + 16, 1) def draw_square(self): for i in range(16): self.oled.pixel(0, 32 - i, 1) self.oled.pixel(i, 31, 1) self.oled.pixel(31, i, 1) self.oled.pixel(15, 16 + i, 1) self.oled.pixel(15, i, 1) self.oled.pixel(16 + i, 0, 1) def draw_triangle(self): for i in range(8): self.oled.pixel(i, 16 + i * 2, 1) self.oled.pixel(8 + i, 32 - i * 2, 1) self.oled.pixel(16 + i, 16 - i * 2, 1) self.oled.pixel(24 + i, i * 2, 1) def draw_sawtooth(self): for i in range(16): self.oled.pixel(0, 16 + i, 1) self.oled.pixel(31, i, 1) for i in range(32): self.oled.pixel(i, 31 - i, 1) def update(self): self.oled.fill(0) if self.shape == shapes.SINE: self.draw_sine() elif self.shape == shapes.SQUARE: self.draw_square() elif self.shape == shapes.TRIANGLE: self.draw_triangle() elif self.shape == shapes.SAWTOOTH: self.draw_sawtooth() self.oled.text("{0}".format(self.frequency), 40, 10, 1) self.oled.show() def update_shape(self, shape): if shape != self.shape: self.shape = shape self.update() def update_frequency(self, frequency): if frequency != self.frequency: self.frequency = frequency self.update()
The generator is pretty simple. It uses the audioio
library to play an array of samples.
The generator is in generator.py
and implemented in the class: Generator
.
There are a few instance variables to store the sample array, the sample player, and the current shape and frequency.
The constructor allocates and initializes the AudioOut
driver that will be used to play the samples.
def __init__(self): self.dac = audioio.AudioOut(board.A0)
If we jump down to the update method that's called from the main loop, we see that it first checks that there is a reason to update. If not, it immediately returns. This is an example of the guard clause pattern discussed in this guide.
If the frequency has changed, the sample array is reallocated: the size is the sample rate (64,000 in this case) divided by the frequency. The logic is that since we update the signal update 64,000 times per second (i.e. the sample rate) we have that many samples to use over the course of a second. For a 1 Hz signal we can use all 64,000 samples for one full cycle, but for a 1000 Hz signal we only have 64. That knowledge is encapsulated in the length
function. The actual allocation of the array is done in the reallocate
method. The sample rate of 32000 keeps the buffer small from being to large for a Feather M0 board. If running on a Feather M4 board you can increase it to 64000 without trouble.
def length(frequency): return int(32000 / frequency) def reallocate(self, frequency): self.sample = array.array("h", [0] * self.length(frequency))
Back in the update method, the sample array is filled with a waveform determined by the shape parameter. Note that the sample array is always refilled. Either the frequency changed and is now a different size and thus the samples need to be regenerated, or the shape has changed and new samples are needed to reflect the new shape.
Finally the sample player is stopped and restarted with the new samples.
def update(self, shape, frequency): if shape == self.shape and frequency == self.frequency: return if frequency != self.frequency: self.reallocate(frequency) self.frequency = frequency self.shape = shape if shape == shapes.SINE: self.make_sine() elif shape == shapes.SQUARE: self.make_square() elif shape == shapes.TRIANGLE: self.make_triangle() elif shape == shapes.SAWTOOTH: self.make_sawtooth() self.dac.stop() self.dac.play(audiocore.RawSample(self.sample, channel_count=1, sample_rate=64000), loop=True)
All that's left are the functions that generate the samples for each waveform. Sample values range from -32767 to +32767. These are the 2**15-1
values in the code The CircuitPython bytecode compiler will optimize these constant expressions to a constant value, so they don't have any runtime performance impact.
def make_sine(self): l = len(self.sample) for i in range(l): self.sample[i] = min(2 ** 15 - 1, int(math.sin(math.pi * 2 * i / l) * (2 ** 15))) def make_square(self): l = len(self.sample) half_l = l // 2 for i in range(l): if i < half_l: self.sample[i] = -1 * ((2 ** 15) - 1) else: self.sample[i] = (2 ** 15) - 1 def make_triangle(self): l = len(self.sample) half_l = l // 2 s = 0 for i in range(l): if i <= half_l: s = int((i / half_l) * (2 ** 16)) - (2 ** 15) else: s = int((1 - ((i - half_l) / half_l)) * (2 ** 16)) - (2 ** 15) self.sample[i] = min(2 ** 15 -1, s) def make_sawtooth(self): l = len(self.sample) for i in range(l): self.sample[i] = int((i / l) * (2 ** 16)) - (2 ** 15)
# SPDX-FileCopyrightText: 2018 Dave Astels for Adafruit Industries # # SPDX-License-Identifier: MIT """ Outpout generator code for signal generator. Adafruit invests time and resources providing this open source code. Please support Adafruit and open source hardware by purchasing products from Adafruit! Written by Dave Astels for Adafruit Industries Copyright (c) 2018 Adafruit Industries Licensed under the MIT license. All text above must be included in any redistribution. """ import math import array import board import audioio import audiocore import shapes def length(frequency): return int(32000 / frequency) class Generator: sample = None dac = None shape = None frequency = None def __init__(self): self.dac = audioio.AudioOut(board.A0) def reallocate(self, frequency): self.sample = array.array("h", [0] * length(frequency)) def make_sine(self): l = len(self.sample) for i in range(l): self.sample[i] = min(2 ** 15 - 1, int(math.sin(math.pi * 2 * i / l) * (2 ** 15))) def make_square(self): l = len(self.sample) half_l = l // 2 for i in range(l): if i < half_l: self.sample[i] = -1 * ((2 ** 15) - 1) else: self.sample[i] = (2 ** 15) - 1 def make_triangle(self): l = len(self.sample) half_l = l // 2 s = 0 for i in range(l): if i <= half_l: s = int((i / half_l) * (2 ** 16)) - (2 ** 15) else: s = int((1 - ((i - half_l) / half_l)) * (2 ** 16)) - (2 ** 15) self.sample[i] = min(2 ** 15 -1, s) def make_sawtooth(self): l = len(self.sample) for i in range(l): self.sample[i] = int((i / l) * (2 ** 16)) - (2 ** 15) def update(self, shape, frequency): if shape == self.shape and frequency == self.frequency: return if frequency != self.frequency: self.reallocate(frequency) self.frequency = frequency self.shape = shape if shape == shapes.SINE: self.make_sine() elif shape == shapes.SQUARE: self.make_square() elif shape == shapes.TRIANGLE: self.make_triangle() elif shape == shapes.SAWTOOTH: self.make_sawtooth() self.dac.stop() self.dac.play(audiocore.RawSample(self.sample, channel_count=1, sample_rate=64000), loop=True)
Page last edited January 22, 2025
Text editor powered by tinymce.