Overview
This project is a USB sampling keyboard inspired by the Casio SK-1. You can record samples using the microphone and play them back at different pitches using the keys on the USB MIDI keyboard. No keyboard skills? No problem, use MIDI file playback to automatically play an entire song with the press of a single button. The project uses an I2S microphone, the audiofilewriter module, and granular pitch shifting. All of which are recent additions to CircuitPython, available as of development release 10.3.0-alpha.4.
Samples get saved to the microSD card inserted into the built-in slot on the Fruit Jam. Meaning, unlike the SK-1, your sample persists while the device is turned off. Once you power it back up, the sample will be loaded and ready for you to begin playing with the keyboard.
USB Midi Keyboard
I used the Akai MPK mini 4, but any standard USB midi keyboard should work.
Page last edited July 20, 2026
Text editor powered by tinymce.
Install CircuitPython
CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.
CircuitPython Quickstart
Follow this step-by-step to quickly get CircuitPython running on your board.
audioi2sin, audiofilewriter, and GranularPitchShift modules used by this project are brand new. Running this project requires using the development release of CircuitPython 10.3.0.alpha-4 or newer until there is a 10.3.0 stable release. On the downloads page scroll down to the latest development release panel.
Click the link above to download the latest CircuitPython UF2 file.
Save it wherever is convenient for you.
To enter the bootloader, hold down the BOOT/BOOTSEL button (highlighted in red above), and while continuing to hold it (don't let go!), press and release the reset button (highlighted in red or blue above). Continue to hold the BOOT/BOOTSEL button until the RP2350 drive appears!
If the drive does not appear, release all the buttons, and then repeat the process above.
You can also start with your board unplugged from USB, press and hold the BOOTSEL button (highlighted in red above), continue to hold it while plugging it into USB, and wait for the drive to appear before releasing the button.
A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.
You will see a new disk drive appear called RP2350.
Â
Drag the adafruit-circuitpython-boardname-language-version.uf2 file to RP2350.
The RP2350 drive will disappear and a new disk drive called CIRCUITPY will appear.
That's it, you're done! :)
Safe Mode
You want to edit your code.py or modify the files on your CIRCUITPY drive, but find that you can't. Perhaps your board has gotten into a state where CIRCUITPY is read-only. You may have turned off the CIRCUITPY drive altogether. Whatever the reason, safe mode can help.
Safe mode in CircuitPython does not run any user code on startup, and disables auto-reload. This means a few things. First, safe mode bypasses any code in boot.py (where you can set CIRCUITPY read-only or turn it off completely). Second, it does not run the code in code.py. And finally, it does not automatically soft-reload when data is written to the CIRCUITPY drive.
Therefore, whatever you may have done to put your board in a non-interactive state, safe mode gives you the opportunity to correct it without losing all of the data on the CIRCUITPY drive.
To enter safe mode when using CircuitPython, plug in your board or hit reset (highlighted in red above). Immediately after the board starts up or resets, it waits 1000ms. On some boards, the onboard status LED (highlighted in green above) will blink yellow during that time. If you press reset during that 1000ms, the board will start up in safe mode. It can be difficult to react to the yellow LED, so you may want to think of it simply as a slow double click of the reset button. (Remember, a fast double click of reset enters the bootloader.)
In Safe Mode
If you successfully enter safe mode on CircuitPython, the LED will intermittently blink yellow three times.
If you connect to the serial console, you'll find the following message.
Auto-reload is off. Running in safe mode! Not running saved code. CircuitPython is in safe mode because you pressed the reset button during boot. Press again to exit safe mode. Press any key to enter the REPL. Use CTRL-D to reload.
You can now edit the contents of the CIRCUITPY drive. Remember, your code will not run until you press the reset button, or unplug and plug in your board, to get out of safe mode.
Flash Resetting UF2
If your board ever gets into a really weird state and CIRCUITPY doesn't show up as a disk drive after installing CircuitPython, try loading this 'nuke' UF2 to RP2350. which will do a 'deep clean' on your Flash Memory. You will lose all the files on the board, but at least you'll be able to revive it! After loading this UF2, follow the steps above to re-install CircuitPython.
Page last edited July 20, 2026
Text editor powered by tinymce.
Wiring & Assembly
Make the following connections. Refer to the wiring diagram and photos as needed.
ICS43434 I2S Mic
- Fruit Jam 3V to mic breakout 3V
- Fruit Jam GND to mic breakout GND
- Fruit Jam D6 to mic breakout bit clock BCLK
- Fruit Jam D7 to mic breakout word select LRCL
- Fruit Jam D9 to mic breakout data out DOUT
MIDI Keyboard & Speaker
- Speaker USB power to the Fruit Jam right USB port closest to the 3.5mm jack
- Speaker 3.5mm input to Fruit Jam 3.5mm DAC output jack
- USB midi keyboard to Fruit Jam left USB port
Page last edited July 20, 2026
Text editor powered by tinymce.
Code
Getting the Program's Files
To use the application, you need to obtain code.py with the program, and the other project files to place on the Fruit Jam CIRCUITPY drive.
Thankfully, this can be done in one go. In the example below, click the Download Project Bundle button below to download the necessary libraries, the code.py file, and other project files in a zip file.
Connect your board to your computer via a known good data+power USB cable. The board should show up in your File Explorer/Finder (depending on your operating system) as a flash drive named CIRCUITPY.
Extract the contents of the zip file, copy the lib directory files to CIRCUITPY/lib. Copy the code.py file, as well as the anna-magdealena-20a.mid file to your CIRCUITPY drive. The program should self start.
# SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
import time
import audiobusio
import audiocore
import audiodelays
import audiofilewriter
import audiofilters
import audioi2sin
import audiomixer
import board
from pwmio import PWMOut
import usb.core
import adafruit_midi
from adafruit_midi.note_off import NoteOff
from adafruit_midi.note_on import NoteOn
from adafruit_midi.control_change import ControlChange
import adafruit_midi_parser
import adafruit_tlv320
import adafruit_usb_host_midi
from neopixel import NeoPixel
OUTPUT_PATH = "/sd/sample0.wav"
SONG_PATH = "/anna-magdalena-20a.mid"
CC_RECORD = 77 # midi CC control value to initiate recording
CC_PLAY_SONG = 78 # midi CC control value to initiate playing a song
SONG_PLAYBACK_BPM = 135
# Default debounce window (seconds) for control change messages.
CC_DEBOUNCE_DEFAULT = 1.0
# Per-control overrides, keyed by CC number, for buttons that need a longer
# or shorter debounce window than the default.
CC_COOLDOWNS = {
CC_PLAY_SONG: 3.0,
}
# Tracks the last accepted time for each CC number so duplicates arriving
# within the debounce window can be ignored.
last_cc_time = {}
SAMPLE_RATE = 16000
STATE = "playing"
pixels = NeoPixel(board.NEOPIXEL, 5, brightness=0.1)
def cc_debounced(control):
"""Return True if this control change should be acted on.
Looks up (or falls back to the default) cooldown window for `control`,
compares against the last time this control was accepted, and - if
enough time has passed - records the new time and returns True. This
replaces having a separate `last_*` variable and cooldown check for
every individual CC button.
"""
now = time.monotonic()
cooldown = CC_COOLDOWNS.get(control, CC_DEBOUNCE_DEFAULT)
if now >= last_cc_time.get(control, 0) + cooldown:
last_cc_time[control] = now
return True
return False
# --- Set up USB Host Midi keyboard ---
print("Looking for midi device")
raw_midi = None
while raw_midi is None:
for device in usb.core.find(find_all=True):
try:
raw_midi = adafruit_usb_host_midi.MIDI(device, timeout=0.01)
print("Found midi device: ", hex(device.idVendor), hex(device.idProduct))
except ValueError:
continue
midi = adafruit_midi.MIDI(midi_in=raw_midi, in_channel=0)
# --- Set up DAC & 3.5mm output ---
mclk_pwm = PWMOut(board.I2S_MCLK, frequency=15_000_000, duty_cycle=2**15)
i2c = board.I2C()
dac = adafruit_tlv320.TLV320DAC3100(i2c)
dac.configure_clocks(sample_rate=44100, bit_depth=16, mclk_freq=15_000_000)
dac.headphone_output = True
dac.dac_volume = 0 # dB
dac.headphone_volume = -10
audio = audiobusio.I2SOut(board.I2S_BCLK, board.I2S_WS, board.I2S_DIN)
# --- Set up polyphony ---
# audiomixer with 4 voices to play up to 4 things at once
mixer = audiomixer.Mixer(
voice_count=4,
sample_rate=16000,
channel_count=1,
bits_per_sample=16,
samples_signed=True,
)
audio.play(mixer)
# --- Set up I2S Microphone ---
mic = audioi2sin.I2SIn(
bit_clock=board.D6,
word_select=board.D7,
data=board.D9,
sample_rate=SAMPLE_RATE,
bit_depth=32,
output_bit_depth=16,
mono=True,
left_justified=False, # True for SPH0645LM4H
)
actual_rate = mic.sample_rate
# --- General variables ---
effect_chains = []
samples = []
recording_file = None
# --- Set up samples ---
class DataContext:
def __init__(self):
self.data_start = None
self.pcm = None
self.cur_voice_index = 0
# Lazily created the first time CC 78 arrives.
self.song_player = None
data_context = DataContext()
def load_samples():
with open(OUTPUT_PATH, "rb") as f:
raw = f.read()
data_context.data_start = raw.find(b"data") + 8 # skip 'data' + length field
data_context.pcm = memoryview(raw)[data_context.data_start :].cast(
"h"
) # signed 16-bit view, matches the chain
samples.clear()
for _ in range(len(mixer.voice)):
# per voice, pre-allocate a RawSample
sample = audiocore.RawSample(
data_context.pcm, sample_rate=16000, channel_count=1
)
samples.append(sample)
try:
load_samples()
except OSError:
print("No sample file")
# --- Set up effects chains ---
# One chain instance per mixer voice
for i in range(len(mixer.voice)):
new_chain = []
# amp used for pre_gain only to boost volume
amp = audiofilters.Distortion(
pre_gain=23.6,
drive=0.0, # drive 0.0 for no distortion
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,
)
# pitch shifter to tune sample up or down
gran_pitch_shift = audiodelays.GranularPitchShift(
semitones=7.0,
mix=1.0,
grain_size=1024,
spread=0.125,
density=4,
buffer_size=1024,
channel_count=1,
sample_rate=16000,
)
# add both effects to the new chain
new_chain.append(amp)
new_chain.append(gran_pitch_shift)
# add the chain to the list of chains
effect_chains.append(new_chain)
# --- note and song playback handling ---
def trigger_note(note):
"""Play the current sample pitch-shifted to `note` on the next mixer voice.
Shared by the live keyboard and the MIDI-file song player so both behave
identically (semitones relative to middle C, round-robin voice stealing).
"""
effect_chain = effect_chains[data_context.cur_voice_index]
# set the pitch shift semi-tone for note
effect_chain[1].semitones = note - 60
# walk thru the chain and call play() on each effect
for chain_index in range(len(effect_chain)):
if chain_index == 0:
# 0 index effect calls play() on the sample
effect_chain[chain_index].play(samples[data_context.cur_voice_index])
else:
# all other effects call play() on the prior effect in the chain
effect_chain[chain_index].play(effect_chain[chain_index - 1])
# play the last link in the effect chain on the current mixer voice
mixer.voice[data_context.cur_voice_index].play(effect_chain[-1])
# increment current voice index for next time
data_context.cur_voice_index = (data_context.cur_voice_index + 1) % len(mixer.voice)
class SamplerMIDIPlayer(adafruit_midi_parser.MIDIPlayer):
"""Drives the sampler voices from a parsed MIDI file.
Each note-on re-triggers the sample pitch-shifted to that note, exactly like
pressing a key. Notes are one-shot RawSamples that ring out, so note-off is
a no-op, matching the live keyboard's behavior.
"""
# pylint: disable=unused-argument, no-self-use
# `self`, `velocity`, and `channel` are required by the parent interface
# even though they are unused by this subclass implementation.
def on_note_on(self, note, velocity, channel):
trigger_note(note)
def start_song():
"""Parse (once) and (re)start playback of SONG_PATH."""
if data_context.song_player is None:
try:
parser = adafruit_midi_parser.MIDIParser()
parser.parse(SONG_PATH)
parser.bpm = SONG_PLAYBACK_BPM
print(
f"Parsed {SONG_PATH}: {len(parser.events)} events, "
+ f"{parser.note_count} notes, {parser.bpm:.1f} BPM"
)
data_context.song_player = SamplerMIDIPlayer(parser)
except adafruit_midi_parser.MIDIParseError as e:
print("Could not load song:", e)
return False
else:
# Rewind so a fresh CC_PLAY_SONG restarts from the top.
data_context.song_player.stop()
data_context.song_player.parser.reset()
return True
# --- main loop ---
while True:
# Advance MIDI-file playback (non-blocking) while a song is active.
if STATE == "song" and data_context.song_player is not None:
data_context.song_player.play()
if data_context.song_player.finished:
STATE = "playing"
pixels[0] = 0x000000
try:
msg = midi.receive()
except usb.core.USBError as usbe:
print("Ignoring USB error", usbe)
# if keyboard note was pressed
if isinstance(msg, NoteOn) and msg.velocity != 0:
# if we're in live play state
if STATE == "playing":
print(
"noteOn: ", msg.note, "vel:", msg.velocity, data_context.cur_voice_index
)
trigger_note(msg.note)
# if we're waiting to start recording
elif STATE == "recording_prompt":
# turn pixel red and begin recording
pixels[0] = 0xFF0000
STATE = "recording"
# cannot use `with` because file must remain open beyond this context
recording_file = open(OUTPUT_PATH, "wb")
writer = audiofilewriter.AudioFileWriter(recording_file)
writer.play(mic)
# if keyboard note was released
elif isinstance(msg, NoteOff) or (isinstance(msg, NoteOn) and msg.velocity == 0):
# only care about note release if we're recording
if STATE == "recording":
# stop the recording and load the new sample
print("noteOff:", msg.note, "vel:", msg.velocity)
print("ending recording")
writer.stop()
recording_file.close()
load_samples()
# turn pixel off and change state to live play
pixels[0] = 0x000000
STATE = "playing"
# if control change button was pressed
elif isinstance(msg, ControlChange):
if not cc_debounced(msg.control):
# duplicate event from the keyboard within the cooldown window;
# ignore it
pass
# prompt to start recording
elif msg.control == CC_RECORD:
print("recording prompt")
STATE = "recording_prompt"
pixels[0] = 0xFFFF00
# start playing midi song
elif msg.control == CC_PLAY_SONG:
print("play song")
if start_song():
pixels[0] = 0x00FF00
STATE = "song"
else:
print(msg)
# print unknown event messages
elif msg is not None:
print(msg)
Drive Structure
After copying the files, your drive should look like the listing below. It can contain other files as well, but must contain these at a minimum.
Page last edited July 20, 2026
Text editor powered by tinymce.
Code Explanation
This page contains an overview how the code works, and what different sections are responsible for. It's written in top down order following along with code.py. To get the most out of it look at the contents of code.py side-by-side with this page.
Configurable Variables
The first thing underneath the imports is a handful of variables that can be used to configure the behavior of the app.Â
-
OUTPUT_PATH- Where the sample wave file will get saved to. /sd/sample0.wav by default. -
SONG_PATH- The MIDI file to use for automated playback. anna-magdalena-20a.mid by default. -
CC_RECORD- The CC button value to use for the record button.77by default -
CC_PLAY_SONG- The CC button value to use for the automatic MIDI file playback.78by default. -
SONG_PLAYBACK_BPM- The approximate beats per minute to play the MIDI file at.135by default.
CC Debouncing
My keyboard always sends duplicate events when the CC buttons are pressed. The code ignores the duplicates by implementing a debouncing mechanism with cooldown timer. The default time to ignore duplicate events is set by CC_DEBOUNCE_DEFAULT. Individual CC actions can override the default by adding entries to the CC_COOLDOWNS dictionary. There is a helper function, cc_debounced(), that enforces the cooldown timers.
USB MIDI Keyboard Setup
To find the MIDI keyboard, the code loops over all connected USB host devices and attempts to initialize them using the adafruit_usb_host_midi library. Once found, the keyboard is hooked into the adafruit_midi library in order to parse the MIDI events received via USB host.
DAC Setup
The Fruit Jam's on-board TLV320 DAC is initialized. The DAC is configured to output via the 3.5mm headphone jack. The volume is set here as well, adjust the values as needed if your speakers don't have hardware volume control. The I2SOut instance is stored in the audio variable.
Polyphony Setup
The audiomixer module is used to handle polyphony. A Mixer object is instantiated with voice_count=4, meaning it can play up to four things layered at once. The mixer is played on the I2SOut audio variable, so all 4 voices will play from I2S DAC headphone output.
General Vars & Sample Setup
Next are 3 variables used by different parts of the code. effect_chains is a list that will hold four copies of the effects chain, one per voice in the mixer. samples is a list that will hold the RawSample instances of the unmodified audio sample, there will be four copies of it, one per voice in the mixer. recording_file will hold the opened file reference to the wave file that is being written during recording.
DataContext is a class that holds 4 more variables. They are contained within a class to avoid the need to use the global keyword in functions that update the value of these variables.
-
data_start- Offset to the location within the wave file of the section that contains raw audio data. -
pcm- Amemoryviewthat will hold the raw audio data extracted from the wave file. -
cur_voice_index- The index of the voice within the mixer to use for playing the next sample. It will get automatically incremented whenever a sample is played. As you play more notes it will cycle between the 4 available voices within the mixer. -
song_player- ASamplerMIDIPlayerinstance that will handle parsing and playing the song from the MIDI file.
The load_samples() function is responsible for loading the sample from the wave file on the SD card. It will populate the 4 RawSamples inside of the samples list.
Effects Chain Setup
4 copies of the effects chain are initialized and stored in the effects_chains list. Each copy includes two effects:
-
amp- An instance ofaudiofilters.Distortionwith thedriveset to0.0, meaning it won't add any actual distortion. Thepre_gainargument on it is used to boost the volume of the sample. This is helpful because the microphone recordings tend to be quiet. -
gran_pitch_shift- An instance ofaudiodelays.GranularPitchShift. This is used to apply the change in pitch for each of the notes on the keyboard. When different keys are pressed, thesemitonesproperty is updated to an offset based on the key's note value.
There are 4 copies of the effects chain so that each voice in the audiomixer can have it's own chain to work with independently.
Play Note & Song Functions
The trigger_note() function will play the note specified by the value of note argument. It is called when the keys on the keyboard are pressed. It's also used by the automatic MIDI file playback. It will automatically select the correct voice within the audiomixer, and increment data_context.cur_voice_index in preparation for the next note.
A SamplerMIDIPlayer class is defined that extends adafruit_midi_parser.MIDIPlayer. The class overrides the on_note_on() function to use the trigger_note() function. When paired with the MIDIParser on_note_on() will get called automatically as appropriate based on the song in the MIDI file and BPM.
The start_song() function initializes the MIDIParser and uses it along with the instance of SamplerMIDIPlayer to begin playing the MIDI file song. On subsequent calls, after the objects are already initialized, it will instead just stop and rewind the player back to the beginning of the song.
Main Loop
The main loop of the code boils down to polling the USB MIDI keyboard for new events and handling any that are received. The code uses a basic state machine with a few different states. The current state is kept in the STATE variable. In the normal "playing" state, when keyboard key events are recieved it will call trigger_note() for the note pressed.
When CC events are recieved it will check if they are the defined CC_RECORD, or CC_PLAY_SONG values. For the CC_PLAY_SONG button it will call the start_song() function.
For CC_RECORD it changes the state to "recording_prompt" and sets a NeoPixel to yellow. In the "recording_prompt" state the standard keyboard keys have different behavior, instead of playing the sample, pressing a key will start the live recording and releasing the key stops the recording. While recording the NeoPixels changes from yellow to red. When recording stops, the NeoPixel is turned off.
Page last edited July 20, 2026
Text editor powered by tinymce.
Use
When the project is first loaded, there is no sample saved. It will be silent when the keys are pressed. Once you record a sample, the file gets saved to the micro SD card. From then on it will automatically load when the Fruit Jam boots up.
Control Change Values
MIDI control change events are used to control the sample recording and automated MIDI file playback. Near the top of the code.py file you will find variables that configure the CC values used by the project.
CC_RECORD = 77 # midi CC control value to initiate recording CC_PLAY_SONG = 78 # midi CC control value to initiate playing a song
On the Akai MPK Mini 4 the values used: 77, and 78 correspond with the red solid circle button labeled "quantize", and red circle with plus inside button labeled "automation".
Different keyboards may use different CC values for their buttons. The project code will print the ControlChange object for any other buttons when they are pressed. Look in the serial console to find the value of your buttons and substitute them into the code for the CC_RECORD, and CC_PLAY_SONG variables. See example CC prints and updated variables below.
ControlChange(control=73, value=127, channel=0) ControlChange(control=74, value=127, channel=0)
CC_RECORD = 73 # midi CC control value to initiate recording CC_PLAY_SONG = 74 # midi CC control value to initiate playing a song
Recording
To record first press the button assigned as CC_RECORD, on my keyboard its the red solid circle labeled "quantize". The first NeoPixel in the line of 5 built-in pixels will turn yellow to indicate that it is waiting to begin recording. To start recording press and hold any key on the keyboard. The recording will stop when you release the key. The NeoPixel will turn red while recording.
MIDI File Player
The project bundle comes with an example MIDI file that contains an aria by Bach. There is a variable near the top of the code.py file that configures the MIDI file to use for the auto playback feature.
SONG_PATH = "/anna-magdalena-20a.mid"
To customize it, copy your desired MIDI file to the CIRCUITPY drive and update the filename in this variable.
To play the song press the CC button that is assigned to CC_PLAY_SONG, on my keyboard that's the red circle with plus sign in it, labeled "automation".
Enjoy this rendition of Bach's aria by Princess Catty Beans 😺.
Going Further
If you've built this project and are interested in taking it to the next level, here are some ideas for expanding the capabilities:
- Allow multiple samples. Recorded samples are saved to /sd/sample0.wav. The old file is always overwritten by newly recorded samples. Update the project code to allow sample2, 3, or more to get saved alongside the old sample(s). Set up an unused CC knob or button on your keyboard to cycle between sample files.
- Apply more effects. The only things in the effects chain currently are an amp to boost the volume, and the granular pitch shift to move the sample up or down in pitch. Add additional effects before or after the pitch shift in the chain. Different effects are found in these core modules: audiofilters, audiodelays, and audiofreeverb. Look for the
# --- Set up effects chains ---section header comment in the code. - Expand the controls using additional MIDI CC knobs and buttons on your keyboard. Make a volume knob by hooking it up to change the
pre_gainvalue used for the amp. Add more effects to the chain and hook up buttons to enable/disable them, or knobs to modify theirmixproperty. - Add an additional reserved voice track to the
audiomixerand use it to play a backing track from a different wave file. - Support multiple MIDI files. Copy more MIDI files to the CIRCUITPY drive and hook up different CC buttons to play each one. Or use one button to cycle thru songs and another button to play the currently selected one.
Page last edited July 20, 2026
Text editor powered by tinymce.