Code the Voicebox
Once you've finished setting up your RP2350 Feather with CircuitPython, you can access the code and necessary libraries by downloading the Project Bundle.
To do this, click on the Download Project Bundle button in the window below. It will download to your computer as a zipped folder.
# SPDX-FileCopyrightText: 2026 Liz Clark for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
Voice Box FX Changer
I2S Mic in -> .WAV file -> I2S DAC out
Effects controlled with analog inputs during looped playback
"""
import io
import time
import audiobusio
import board
import audioi2sin
import simpleio
import audiodelays
import audiofreeverb
import audiofilewriter
import audiofilters
import audiocore
import audiomixer
import keypad
from analogio import AnalogIn
import neopixel
pitch_slide = AnalogIn(board.A0)
reverb_slide = AnalogIn(board.A1)
dist_slide = AnalogIn(board.A2)
# record button on D24, play button on A3
keys = keypad.Keys((board.D24, board.A3), value_when_pressed=False, pull=True)
pixels = neopixel.NeoPixel(board.D12, 8, brightness=0.6, auto_write=True)
RED = (255, 0, 0)
YELLOW = (255, 255, 0)
GREEN = (0, 255, 0)
BLUE = (0, 0, 255)
PURPLE = (50, 0, 255)
OFF = (0, 0, 0)
# recording config
SAMPLE_RATE = 16000
OUTPUT_PATH = "/recording.wav"
MAX_RECORD_SECONDS = 20
# mono 16-bit = 2 bytes/sample + room for the 44-byte WAV header
CAPTURE_ALLOC = SAMPLE_RATE * 2 * MAX_RECORD_SECONDS + 64
capture = None
# Mic
mic = audioi2sin.I2SIn(
bit_clock=board.D5,
word_select=board.D6,
data=board.D9,
sample_rate=SAMPLE_RATE,
bit_depth=32,
output_bit_depth=16,
mono=True,
left_justified=False, # using ICS43434
)
i2s = audiobusio.I2SOut(board.D10, board.D11, board.SCL)
mixer = audiomixer.Mixer(
voice_count=1,
sample_rate=SAMPLE_RATE,
channel_count=1,
bits_per_sample=16, # matches output_bit_depth
samples_signed=True,
)
i2s.play(mixer)
mixer.voice[0].level = 0.5
pitch_shift = audiodelays.PitchShift(
semitones=0.0,
mix=1.0,
window=2048,
overlap=256,
buffer_size=1024,
channel_count=1,
sample_rate=SAMPLE_RATE,
)
reverb = audiofreeverb.Freeverb(
roomsize=0.35,
damp=0.25,
buffer_size=1024,
channel_count=1,
sample_rate=SAMPLE_RATE,
mix=0.0,
)
echo = audiodelays.Echo(
max_delay_ms=1000,
delay_ms=850,
decay=0.0,
buffer_size=1024,
channel_count=1,
sample_rate=SAMPLE_RATE,
mix=1.0,
freq_shift=False
)
amp = audiofilters.Distortion(
pre_gain=15,
drive=0.00,
mode=audiofilters.DistortionMode.LOFI,
soft_clip=True,
mix=1.0,
buffer_size=1024,
sample_rate=SAMPLE_RATE,
bits_per_sample=16,
samples_signed=True,
channel_count=1,
)
loop = False
recording = False
file = open("/bootbeeps.wav", "rb")
wav = audiocore.WaveFile(file)
mixer.voice[0].play(wav, loop=False)
# start-up purple marquee
for i in range(8):
pixels[i] = PURPLE
time.sleep(0.2)
time.sleep(0.5)
mixer.voice[0].level = 1.0
pixels.fill(OFF)
while True:
event = keys.events.get()
if event:
key_number = event.key_number
if event.pressed and key_number == 0: # record button
if not recording: # press to record
i2s.stop() # stopping i2s out mutes DAC
pixels.fill(RED) # red means recording
capture = io.BytesIO(CAPTURE_ALLOC) # write into memory
writer = audiofilewriter.AudioFileWriter(capture)
writer.play(mic)
recording = True
else: # press to stop
writer.stop()
pixels.fill(YELLOW) # yellow while writing
print("captured", capture.tell(), "bytes")
try:
capture.seek(0) # write from memory to wav on file system
with open(OUTPUT_PATH, "wb") as f:
while True:
chunk = capture.read(4096)
if not chunk:
break
f.write(chunk)
capture.seek(0)
recording = False
pixels.fill(OFF)
except OSError as e:
pixels.fill(BLUE) # error = blue, but continues running
print(f"Read-only mode, can't save the file, flip the switch and reboot!: {e}")
time.sleep(2)
pixels.fill(OFF)
continue
if event.pressed and key_number == 1: # play button
if not loop: # press to play, looping
i2s.play(mixer)
try:
# opens wav file that was just written from memory
file = open("recording.wav", "rb")
wav = audiocore.WaveFile(file)
print("got file")
pixels.fill(GREEN) # playback is green
except AttributeError as e:
pixels.fill(BLUE)
print(f"Missing recording.wav: {e}")
time.sleep(2)
pixels.fill(OFF)
continue
loop = True
pitch_shift.play(wav, loop=True) # effect chain, all have to loop
reverb.play(pitch_shift, loop=True)
echo.play(reverb, loop=True)
amp.play(echo, loop=True) # gain boost
mixer.voice[0].play(amp, loop=True)
else: # press to stop
mixer.voice[0].stop()
loop = False
pixels.fill(OFF)
# controlling % of reverb in the mix
verb = simpleio.map_range(reverb_slide.value, 100, 65536, 0.0, 1.0)
reverb.mix = verb
# controlling amount of echo decay
echo_range = simpleio.map_range(dist_slide.value, 100, 65536, 0.0, 1.0)
echo.decay = echo_range
# an octave has 12 semitones, -13 and +13 gives full octave up and down
pitch = simpleio.map_range(pitch_slide.value, 100, 65536, -13.0, 13.0)
pitch_shift.semitones = int(pitch)
Upload the Code and Libraries to the RP2350 Feather
After downloading the Project Bundle, plug your RP2350 Feather into the computer's USB port with a known good USB data+power cable. You should see a new flash drive appear in the computer's File Explorer or Finder (depending on your operating system) called CIRCUITPY. Unzip the folder and copy the following items to the RP2350 Feather's CIRCUITPY drive.
- lib folder
- bootbeep.wav
- boot.py
- code.py
Your RP2350 Feather CIRCUITPY drive should look like this after copying the lib folder, bootbeep.wav file, boot.py file andĀ code.py file:
boot.py
This project makes use of a boot.py file. This file runs before code.py and sets the state for readonly mode for the CIRCUITPY drive. If pin D25 is connected to ground with the toggle switch, then CIRCUITPY is set to read/write mode. This allows for the recordings to be saved to the CIRCUITPY drive.Ā
If you need to edit code.py, or any other files on the CIRCUITPY drive, then you'll want D25 to not be connected to ground to have CIRCUITPY be in read only mode.
import board
import digitalio
import storage
switch = digitalio.DigitalInOut(board.D25)
switch.direction = digitalio.Direction.INPUT
switch.pull = digitalio.Pull.UP
# If the switch pin is connected to ground CircuitPython can write to the drive
storage.remount("/", readonly=switch.value)
For more information on using boot.py and the storage module, check out this page in the CircuitPython Essentials guide.
How the CircuitPython Code Works
The code begins by initializing the pins for the three analog inputs and two button inputs. The two buttons are passed to a keypad object. Then the NeoPixel stick and the colors used are defined.
pitch_slide = AnalogIn(board.A0) reverb_slide = AnalogIn(board.A1) dist_slide = AnalogIn(board.A2) # record button on D24, play button on A3 keys = keypad.Keys((board.D24, board.A3), value_when_pressed=False, pull=True) pixels = neopixel.NeoPixel(board.D12, 8, brightness=0.6, auto_write=True) RED = (255, 0, 0) YELLOW = (255, 255, 0) GREEN = (0, 255, 0) BLUE = (0, 0, 255) PURPLE = (50, 0, 255) OFF = (0, 0, 0)
I2S Settings
There are a few recording configuration variables that are set. TheĀ SAMPLE_RATE has to match across all of the audio settings. In this case, it is 16000. The OUTPUT_PATH is where the recording from the I2S microphone will be saved.Ā Then, the I2S input and I2S output are instantiated. The I2S output is passed to a Mixer object.
# recording config
SAMPLE_RATE = 16000
OUTPUT_PATH = "/recording.wav"
MAX_RECORD_SECONDS = 20
# mono 16-bit = 2 bytes/sample + room for the 44-byte WAV header
CAPTURE_ALLOC = SAMPLE_RATE * 2 * MAX_RECORD_SECONDS + 64
capture = None
# Mic
mic = audioi2sin.I2SIn(
bit_clock=board.D5,
word_select=board.D6,
data=board.D9,
sample_rate=SAMPLE_RATE,
bit_depth=32,
output_bit_depth=16,
mono=True,
left_justified=False, # using ICS43434
)
i2s = audiobusio.I2SOut(board.D10, board.D11, board.SCL)
mixer = audiomixer.Mixer(
voice_count=1,
sample_rate=SAMPLE_RATE,
channel_count=1,
bits_per_sample=16, # matches output_bit_depth
samples_signed=True,
)
i2s.play(mixer)
mixer.voice[0].level = 0.5
Effects Chain
Four effects are used for the voice changer:Ā PitchShift, Freeverb (reverb), Echo and Distortion. The three analog inputs control the semitones for PitchShift, mix amount for Freeverb and decay amount for Echo. The Distortion effect is used as an amplifier to give a gain boost to the I2S output.
pitch_shift = audiodelays.PitchShift(
semitones=0.0,
mix=1.0,
window=2048,
overlap=256,
buffer_size=1024,
channel_count=1,
sample_rate=SAMPLE_RATE,
)
reverb = audiofreeverb.Freeverb(
roomsize=0.35,
damp=0.25,
buffer_size=1024,
channel_count=1,
sample_rate=SAMPLE_RATE,
mix=0.0,
)
echo = audiodelays.Echo(
max_delay_ms=1000,
delay_ms=850,
decay=0.0,
buffer_size=1024,
channel_count=1,
sample_rate=SAMPLE_RATE,
mix=1.0,
freq_shift=False
)
amp = audiofilters.Distortion(
pre_gain=15,
drive=0.00,
mode=audiofilters.DistortionMode.LOFI,
soft_clip=True,
mix=1.0,
buffer_size=1024,
sample_rate=SAMPLE_RATE,
bits_per_sample=16,
samples_signed=True,
channel_count=1,
)
Boot Up Beeps
Before the loop, there are two variables that are defined.Ā loop will determine if the audio file is playing back and recording will determine if a recording is in progress. Then, the boot up sound effect is loaded and passed to the mixer to play once. While it plays, the NeoPixels light up purple one by one.
loop = False
recording = False
file = open("/bootbeeps.wav", "rb")
wav = audiocore.WaveFile(file)
mixer.voice[0].play(wav, loop=False)
# start-up purple marquee
for i in range(8):
pixels[i] = PURPLE
time.sleep(0.2)
time.sleep(0.5)
mixer.voice[0].level = 1.0
pixels.fill(OFF)
The Loop
keypad events are used to monitor the two button inputs. If key_number 0, the record button, is pressed, it will either start a recording or stop a recording through the I2S microphone. The recordings are saved in memory using the audiofilewriter module and you'll see the NeoPixels turn red. When a recording is stopped, the recording stored in memory is converted to a .WAV file that is written to the CIRCUITPY drive and the NeoPixels will turn yellow. If the voice changer is in read-only mode, then the NeoPixels will light up blue to let you know that you need to flip the switch and reboot.
while True:
event = keys.events.get()
if event:
key_number = event.key_number
if event.pressed and key_number == 0: # record button
if not recording: # press to record
i2s.stop() # stopping i2s out mutes DAC
pixels.fill(RED) # red means recording
capture = io.BytesIO(CAPTURE_ALLOC) # write into memory
writer = audiofilewriter.AudioFileWriter(capture)
writer.play(mic)
recording = True
else: # press to stop
writer.stop()
pixels.fill(YELLOW) # yellow while writing
print("captured", capture.tell(), "bytes")
try:
capture.seek(0) # write from memory to wav on file system
with open(OUTPUT_PATH, "wb") as f:
while True:
chunk = capture.read(4096)
if not chunk:
break
f.write(chunk)
capture.seek(0)
recording = False
pixels.fill(OFF)
except OSError as e:
pixels.fill(BLUE) # error = blue, but continues running
print(f"Read-only mode, can't save the file, flip the switch and reboot!: {e}")
time.sleep(2)
pixels.fill(OFF)
continue
If key_number 1, the playback button, is pressed, it will either start playing the recording.wav file on a loop or stop playback through the I2S DAC. If the recording.wav file is missing, then the NeoPixels will light up blue to let you know. Otherwise, the NeoPixels will be green while the file is playing. The file is passed through the effects chain (pitch shift to reverb to echo to amp) to the mixer that outputs over I2S. When playback is stopped, the NeoPixels turn off.
if event.pressed and key_number == 1: # play button
if not loop: # press to play, looping
i2s.play(mixer)
try:
# opens wav file that was just written from memory
file = open("recording.wav", "rb")
wav = audiocore.WaveFile(file)
print("got file")
pixels.fill(GREEN) # playback is green
except AttributeError as e:
pixels.fill(BLUE)
print(f"Missing recording.wav: {e}")
time.sleep(2)
pixels.fill(OFF)
continue
loop = True
pitch_shift.play(wav, loop=True) # effect chain, all have to loop
reverb.play(pitch_shift, loop=True)
echo.play(reverb, loop=True)
amp.play(echo, loop=True) # gain boost
mixer.voice[0].play(amp, loop=True)
else: # press to stop
mixer.voice[0].stop()
loop = False
pixels.fill(OFF)
Effect Controls
Outside of theĀ keypad event listener, the three analog values are mapped to effect parameters. The reverb mix amount and echo decay amount are mapped to the two potentiometers. The pitch shift semitone value is mapped to the slide potentiometer. The value is scaled to an integer so that it corresponds with 12 semitones above or below the beginning pitch of the recording. This gives a full octave up and down for the range.
# controlling % of reverb in the mix
verb = simpleio.map_range(reverb_slide.value, 100, 65536, 0.0, 1.0)
reverb.mix = verb
# controlling amount of echo decay
echo_range = simpleio.map_range(dist_slide.value, 100, 65536, 0.0, 1.0)
echo.decay = echo_range
# an octave has 12 semitones, -13 and +13 gives full octave up and down
pitch = simpleio.map_range(pitch_slide.value, 100, 65536, -13.0, 13.0)
pitch_shift.semitones = int(pitch)
Page last edited July 28, 2026
Text editor powered by tinymce.