Overview
The Teenage Engineering EP-2350 ting is a handheld microphone device, effects box, and sample player used as an input to samplers, sequencers, and other audio gizmos. The brain of the gadget is the Raspberry Pi RP2350 microcontroller. The stock firmware is a custom build of MicroPython, which means it is possible for it to run CircuitPython instead.
This guide covers the hardware teardown and reassembly, reverse engineering the pinout, loading CircuitPython on the device, and running a demo project that provides similar functionality to the original TE firmware.
Teenage Engineering EP-3250 ting
Standalone microphone device.
Page last edited August 05, 2026
Text editor powered by tinymce.
Teardown & Reassembly
Teenage Engineering deserve credit for making this device easy to take apart and put back together. There are a few small gotchas to know about, but it is much nicer than getting into many modern electronics. There are no glued enclosures or globbed over mystery chips here.
Start by removing the 5 small Phillips screws from the back side of the device. There is one in each corner, and one in the middle of the left side, opposite the handle.
Removing the panel reveals what this guide refers to as the front side of the PCB.
Near the bottom right corner where the audio cable comes out of the device, there is an orange plastic strain relief sleeve. It interfaces with two prongs on the back plate. Before putting the plate back on, make sure this is oriented properly to allow the prongs to slide down either side of it, and that the tiny wires going to the press-fit connector are not twisted up.
Taking out and returning the PCB non-destructively is a little tricky, but can be done if you're careful.
To flip the PCB and see the back side of it, unscrew 4 additional Phillips head screws that are near the short ends of the battery compartment.
Gently pry up the plastic press-fit connector that connects the 3.5mm cable to the board with a flat head screw driver. The connector and wires are delicate, be careful!
Be mindful of the spring that tensions the handle. Its arm is retained by a small beige tab that sits in the convenient cutout on the PCB. When you lift the PCB, the spring arm will come free from that tab and spring downward. It will catch on the potentiometer knob. Try to hold onto it and release it gently so it doesn't slam at full force into the shaft.
To put the PCB back in, you must tension the spring and get its arm wedged into place against the beige tab. I used a small screwdriver to hold the spring arm while carefully seating the PCB. Following the groove cutout of the PCB with the screw driver holding the arm will slide the spring arm into place against the tab.
Page last edited August 05, 2026
Text editor powered by tinymce.
Reverse Engineering Techniques
This page details the process used to reverse engineer the device to figure out which pins the peripherals were connected to. This was an LLM-assisted process that used Claude Code running in a Pi sandbox connected to the EP-2350 device. The sections on this page will include links to download session history logs, and primary markdown outputs, along with a human written summary.
Breaking up the work, Plans & Findings docs
When I first started, there were many unknowns about the device. I first attempted to follow traces on the board to the myriad available pogo pin test points, but it was not going very well. When I turned to the LLM for assistance, I wanted to do so with an intentionally controlled approach rather than just letting it loose and seeing what came out after a while.
I broke the unknowns down and focused on one thing at a time. I tried to keep the LLM sessions on the short side. If a session started approaching 200k tokens and had made progress but not gotten a final result yet, I would interject and have the agent write the findings that were made during the session into a markdown file and include action items to pick up on in a future session.
For some tasks I first asked the agent to write a plan markdown file for how to go about it. I manually reviewed and edited the plan and findings documents before handing them off to a new session to actually carry out the work.
Environment
Claude Code with Opus model was used for the agent harness. In addition to the connected EP-2350 device, the agent was given access to the following:
- ep-2350_firmware_1_0_8.uf2 file containing the latest standard device firmware
- Readme files found on the TING DISK drive, internal /rom storage, and firmware download zip
- A link to the official ep-2350 guide page.
- main.py code file that runs under the standard firmware
- RP2350 datasheet PDF
- NAU88L21 datasheet PDF
- NAU88L21 Linux driver repository
- Stock MicroPython repository
These components were connected for relevant portions work, but weren't available the whole time:
- USB camera used during LED mapping.
- USB audio adapter and speakers. This gave the agent a full feedback loop to play audio from the speakers -> record it with the EP-2350 mic -> pass-through Python code -> play out of the EP-2350 3.5mm -> capture with USB adapter.
Audio CODEC
I knew that the audio CODEC was the NAU88L21 from markings found on the chip during the teardown. I decided to start with it because I figured that it would be more difficult to map the pins for it than the buttons, LEDs, and potentiometer. Also because audio is the main point of the device.
Planning Session (log) (plan file)
I started with a plan file for finding the I2C and I2S pins first. The plan revealed a key detail about the RP2350 that I was unaware of: The pin muxing registers in the device can be read back from code. That makes it possible to figure out how each pin is configured, which is a big start towards figuring out what it is connected to. In addition to reading the IO registers the plan mentions static analysis of the UF2 firmware as a backup plan.
I2C Pins (log) (findings file)
In the first working session, I asked the agent to look for I2C pins. The first thing it did is make some tools to run code via the REPL and get the results back. It read the IO registers and formulated some theories on pins that turned out to be incorrect, confirmed wrong by bit-banging I2C scans on them. Interestingly, it actually found most of the I2S pins during this session.
- **GPIO8** = input (DIN, codec→MCU), **GPIO9** = output (DOUT), **GPIO10/11** = driven clocks (BCLK/LRCLK) — matches the enabled SM's pinctrl (in8, out9, side-set 10–11).
It did some poking at the TE firmware binary, and some more register reading. Eventually finding that that pins GP14 and GP15 showed the I2C signature of internally set pull downs, yet reading high due to external pull ups. While trying to confirm these pins, it crashed the device a few times. After the 2nd crash that I recovered it from manually, I interjected and asked it to run any last minute tests and write the findings file.
Evaluate Stock MicroPython Requirements & Risk (log) (instructions file)
At first, I was worried about flashing stock MicroPython on the device and having it get locked in some way that prevented me from getting back to the bootloader to recover. I also don't have a lot of experience using MicroPython outside of CircuitPython, so I wasn't 100% sure of the right firmware to flash. I used one session to look further into this and write a rundown of how to get stock MicroPython flashed for further testing. After this session, I looked into the crystal frequency and tried to identify the flash chip for the worst-case recovery option in the instructions.
MicroPython Tests Confirm I2C and I2S Clocks (log) (findings file)
I flashed stock MicroPython onto it and it worked without trouble. I asked the agent to run tests on the live device to validate the pins theorized so far. It ran an I2C scan and found the NAU88L21 and accelerometer on the bus. It confirmed the NAU88L21 further by checking the expected silicon-revision register. In an earlier session, it had made high confidence guesses at the I2S pins DIN, DOUT, BCLK, and WS. It had a guess for the MCLK pin on GP16 with only medium confidence, so it tried to confirm MCLK next. After running some tests and reading the NAU88L21 datasheet, GP16 was ruled out as a possibility. A sweeping test of many pins was run and pointed towards GP12 instead. The test also uncovered that BLCK and WS were swapped around. I cut it off before it could go on to confirm DIN and DOUT.
Confirming I2S Data Pins (log) (findings file)
Next up was validating GP8 and GP9 as the DIN and DOUT pins. To do this, the agent found the I2S tri-state bit in a control register and measured GP8 while enabling and disabling the bit. To confirm GP9 as DOUT, it observed that the CODEC never drove GP9 through various states. That is expected, because the MCU would always be the one sending data on this pin into the CODEC. It was further confirmed later by sending I2S audio on the pin.
Power Management (log) (findings file)
Up until this point, to keep it powered on I had wrapped a rubber band around it, squeezing the handle in all the way. Any time the handle was released the device immediately powered off. The next task was figuring out what the stock firmware does to keep the device running. The official instructions mention the device falling asleep after 5 minutes and powering off after 20, so I knew there must be a way control the power.
The agent narrowed it down to a set of likely pins and then drove them low one at a time checking for the device to disappear from the serial connection after each. During the process it asked me to remove and re-apply the band a few times. GP2 was positively identified as the power control pin. The agent designed and ran a few faulty experiments that powered off the device without recovering usable info so I intervened to have it output what was found so far.
Buttons & Volume Potentiometer (log) (findings files)
The buttons and volume knob were easy to map with MicroPython. The agent wrote a script that prompted me to press each of the various buttons in a specified order and then turn the volume knob all the way one direction and then the other. It watched for the pins that changed as I pressed buttons and twisted the knob, then recorded which button mapped to which pin, and how the potentiometer was connected.
Handle Potentiometer (log)
There is a potentiometer hidden inside the pivot point of the handle. I didn't realize this at first, but once I discovered it I used an agent session with the device running CircuitPython to identify the pin. The agent eliminated several options based on other known pins and identified a small set to test. It ran a test on the device watching the candidate pins while I pressed and released the handle. It determined that pin GP28 had been previously incorrectly identified and was actually the pin that this handle potentiometer is connected to.
LEDs (log) (findings file)
The LEDs were also pretty easy to map using MicroPython and adding a standard USB camera to the agent sandbox Pi. Using the camera, the agent could iterate over the pins and capture photos of the state of the LEDs as needed to work out which pins are connected to which LEDs. After nailing down the mapping, it also worked on PWM. It discovered that the top white and red LEDs share a PWM slice, and thus are not independently PWM controllable. All other LEDs can be PWM controlled individually.
This photo contains a sampling from the 90+ images captured while mapping the LEDs. The agent used PIL to crop the full shot down to just the rectangle containing the LEDs, and to assemble grids like these in order to evaluate many photos at once.
While working, the agent noticed that the bright LEDs were bleeding light out of the neighboring holes making it appear as if 3 of them were lit. To work around this, it used a low PWM frequency, and applied a darkened filter when capturing the photos with ffmpeg.
Interactive Pin Validation Test
After creating the CircuitPython board def I used an interactive test to validate that all pins were correct. The video below shows the test sequence, and the code follows it.
# SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks
#
# SPDX-License-Identifier: MIT
"""
Hardware validation test for the TE ting fx EP-2350 CircuitPython board
definition.
Connect to the serial console and follow the prompts. Exercises every LED,
every button, both handle switches, the handle position potentiometer, the
volume knob, and the I2C bus.
Nothing here touches board.POWER_HOLD -- driving it low powers the unit off.
"""
import time
import analogio
import board
import digitalio
import pwmio
# Seconds to wait for the user at each prompt before marking a step failed.
TIMEOUT = 15
WHITE = ("LED_WHITE1", "LED_WHITE2", "LED_WHITE3", "LED_WHITE4")
RED = ("LED_RED1", "LED_RED2", "LED_RED3", "LED_RED4")
# name, board pin attribute, value that means "actuated"
BUTTONS = (
("top side button (nearest the handle)", "BUTTON_TOP", False),
("middle side button", "BUTTON_MIDDLE", False),
("bottom side button", "BUTTON_BOTTOM", False),
)
# The handle pot only swings across a narrow slice of the ADC range: roughly
# 32370 counts at rest down to 29850 counts fully seated. Raw noise is about
# +/-80 counts, so every reading is averaged.
HANDLE_POT_SAMPLES = 32
HANDLE_POT_MIN_SWING = 1200
results = []
def record(name, passed, detail=""):
results.append((name, passed, detail))
print(" {}: {}{}".format("PASS" if passed else "FAIL", name,
" ({})".format(detail) if detail else ""))
def make_leds():
leds = {}
for name in WHITE + RED:
led = digitalio.DigitalInOut(getattr(board, name))
led.switch_to_output(value=False)
leds[name] = led
return leds
def make_input(attr):
pin = digitalio.DigitalInOut(getattr(board, attr))
pin.switch_to_input(pull=digitalio.Pull.UP)
return pin
def wait_for(pin, wanted, timeout=TIMEOUT):
"""Wait for pin.value == wanted. Returns True, or False on timeout."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if pin.value == wanted:
return True
time.sleep(0.01)
return False
def test_leds(leds):
print("\n== LEDs ==")
print("Watch the front panel. Each LED lights on its own, top to bottom.")
time.sleep(2)
for column, names in (("white", WHITE), ("red", RED)):
print(" {} column...".format(column))
for index, name in enumerate(names):
leds[name].value = True
time.sleep(0.35)
leds[name].value = False
print(" all on...")
for led in leds.values():
led.value = True
time.sleep(1.0)
for led in leds.values():
led.value = False
print(" brightness sweep on the bottom white LED...")
# GP0 and GP16 share a PWM slice, so dim a pin that is not one of those.
leds["LED_WHITE4"].deinit()
dim = pwmio.PWMOut(board.LED_WHITE4, frequency=1000, duty_cycle=0)
for step in range(0, 65536, 1024):
dim.duty_cycle = step
time.sleep(0.005)
dim.deinit()
leds["LED_WHITE4"] = digitalio.DigitalInOut(board.LED_WHITE4)
leds["LED_WHITE4"].switch_to_output(value=False)
answer = input(" Did all 8 LEDs light, and did the last one fade? [Y/n] ")
record("LEDs", answer.strip().lower() in ("", "y", "yes"))
def test_buttons(leds):
print("\n== Buttons ==")
print("Each prompt lights a white LED while it waits; press and release.")
for index, (label, attr, pressed_value) in enumerate(BUTTONS):
pin = make_input(attr)
marker = leds[WHITE[index]]
marker.value = True
print(" Press the {}...".format(label))
if not wait_for(pin, pressed_value):
record(label, False, "no press within {}s".format(TIMEOUT))
elif not wait_for(pin, not pressed_value, timeout=5):
record(label, False, "stuck pressed")
else:
record(label, True)
marker.value = False
pin.deinit()
def read_pot(pot, samples=HANDLE_POT_SAMPLES):
"""Averaged read of a noisy pot."""
total = 0
for _ in range(samples):
total += pot.value
return total // samples
def test_handle(leds):
print("\n== Handle ==")
print("The handle is a two-stage switch: HANDLE_OUT moves first,")
print("HANDLE_IN confirms the handle is fully seated. HANDLE_POSITION")
print("is a pot that reads how far through the travel it is.")
held = make_input("HANDLE_OUT")
seated = make_input("HANDLE_IN")
pot = analogio.AnalogIn(board.HANDLE_POSITION)
try:
if held.value or not seated.value:
print(" Release the handle first...")
if not wait_for(held, False):
record("handle", False, "handle never read as released")
return
at_rest = read_pot(pot)
print(" handle position at rest = {}".format(at_rest))
for led in leds.values():
led.value = True
print(" Now squeeze the handle all the way in...")
stage1 = wait_for(held, True)
stage2 = wait_for(seated, False, timeout=5) if stage1 else False
# Sample while the handle is still held in, before anyone lets go.
pressed = read_pot(pot)
for led in leds.values():
led.value = False
record("handle stage 1 (HANDLE_OUT)", stage1,
"" if stage1 else "no movement within {}s".format(TIMEOUT))
record("handle stage 2 (HANDLE_IN)", stage2,
"" if stage2 else "never reached the end stop")
# The pot falls as the handle goes in, so rest should read higher.
swing = at_rest - pressed
print(" handle position seated = {} (swing {})".format(pressed, swing))
record("handle position pot moves", abs(swing) >= HANDLE_POT_MIN_SWING,
"rest {} -> seated {}".format(at_rest, pressed))
record("handle position pot direction", swing > 0,
"" if swing > 0 else "expected the value to fall as it seats")
print(" Release the handle slowly; watch the red LEDs track it...")
track_handle(pot, leds, at_rest, pressed)
wait_for(held, False)
released = read_pot(pot)
print(" handle position after release = {}".format(released))
record("handle position pot returns",
abs(released - at_rest) < HANDLE_POT_MIN_SWING // 2,
"rest {} -> released {}".format(at_rest, released))
finally:
held.deinit()
seated.deinit()
pot.deinit()
for led in leds.values():
led.value = False
def track_handle(pot, leds, at_rest, pressed, timeout=TIMEOUT):
"""Show handle travel on the red LEDs until it sits back near rest."""
deadline = time.monotonic() + timeout
span = at_rest - pressed
if span <= 0:
return
while time.monotonic() < deadline:
value = read_pot(pot, samples=8)
travel = (at_rest - value) / span
if travel < 0.05:
break
lit = int(travel * 4)
for index, name in enumerate(RED):
leds[name].value = index < lit
time.sleep(0.02)
for name in RED:
leds[name].value = False
def test_volume(leds):
print("\n== Volume knob ==")
knob = analogio.AnalogIn(board.VOLUME)
try:
print(" Turn the knob all the way DOWN...")
low = track_knob(knob, leds, target="low")
print(" Turn the knob all the way UP...")
high = track_knob(knob, leds, target="high")
print(" low = {}, high = {}".format(low, high))
# Documented range is 68 .. 65340 counts; allow generous margin.
record("volume knob low end", low < 2000, "read {}".format(low))
record("volume knob high end", high > 63000, "read {}".format(high))
finally:
knob.deinit()
for led in leds.values():
led.value = False
def track_knob(knob, leds, target):
"""Show the knob position on the red LEDs until it holds at an end."""
deadline = time.monotonic() + TIMEOUT
best = knob.value if target == "high" else 65535
while time.monotonic() < deadline:
value = knob.value
if target == "high":
best = max(best, value)
if value > 63000:
break
else:
best = min(best, value)
if value < 2000:
break
# Red column as a 4-segment bar graph.
lit = value * 4 // 65536
for index, name in enumerate(RED):
leds[name].value = index < lit
time.sleep(0.02)
for name in RED:
leds[name].value = False
return best
def test_i2c():
print("\n== I2C ==")
i2c = board.I2C()
try:
while not i2c.try_lock():
pass
found = i2c.scan()
finally:
i2c.unlock()
i2c.deinit()
print(" found: {}".format([hex(address) for address in found]))
record("accelerometer at 0x18", 0x18 in found)
record("NAU88L21 codec at 0x1b", 0x1B in found)
def main():
print("\nEP-2350 hardware validation")
print("===========================")
leds = make_leds()
try:
test_i2c()
test_leds(leds)
test_buttons(leds)
test_handle(leds)
test_volume(leds)
finally:
for led in leds.values():
led.deinit()
failures = [name for name, passed, _ in results if not passed]
print("\n===========================")
print("{} of {} checks passed".format(len(results) - len(failures),
len(results)))
for name in failures:
print(" FAILED: {}".format(name))
if not failures:
print("All good.")
main()
Page last edited August 05, 2026
Text editor powered by tinymce.
Pinout
This page makes reference to both the front and back of the EP-2350's PCB. For these purposes "front" is the side of the PCB that is visible after removing just the back cover. The "back" side is the one that becomes visible after removing the PCB retention screws, unplugging the 3.5mm audio connector, and flipping the PCB over.
| GPIO | Board names | Function |
|---|---|---|
| GPIO0 |
GP0, LED_WHITE1, LED
|
White LED 1 (top) |
| GPIO6 |
GP6, LED_WHITE2
|
White LED 2 |
| GPIO4 |
GP4, LED_WHITE3
|
White LED 3 |
| GPIO5 |
GP5, LED_WHITE4
|
White LED 4 (bottom) |
| GPIO16 |
GP16, LED_RED1
|
Red LED 1 (top) |
| GPIO24 |
GP24, LED_RED2
|
Red LED 2 |
| GPIO18 |
GP18, LED_RED3
|
Red LED 3 |
| GPIO17 |
GP17, LED_RED4
|
Red LED 4 (bottom) |
| GPIO21 |
GP21, BUTTON_TOP
|
Top side button |
| GPIO3 |
GP3, BUTTON_MIDDLE
|
Middle side button |
| GPIO1 |
GP1, BUTTON_BOTTOM
|
Bottom side button |
| GPIO19 |
GP19, HANDLE_IN
|
Handle seated switch |
| GPIO20 |
GP20, HANDLE_OUT
|
Handle moved switch |
| GPIO28 |
GP28, A2, HANDLE_POSITION
|
Handle position potentiometer |
| GPIO2 |
GP2, POWER_HOLD
|
Power hold latch |
| GPIO14 |
GP14, SDA
|
I2C1 SDA |
| GPIO15 |
GP15, SCL
|
I2C1 SCL |
| — | board.I2C() |
Shared I2C1 bus object |
| GPIO8 |
GP8, I2S_DIN
|
Data in (from codec) |
| GPIO9 |
GP9, I2S_DOUT
|
Data out (to codec) |
| GPIO10 |
GP10, I2S_WS
|
Word select / LRCLK |
| GPIO11 |
GP11, I2S_BIT_CLOCK
|
Bit clock |
| GPIO12 |
GP12, I2S_MCLK
|
Mclock |
| GPIO29 |
GP29, A3, VOLUME
|
Volume potentiometer wiper |
The main brains of the operation is a Raspberry Pi RP2350A microcontroller.
It is situated in the top right corner of the front side of the board, near the top orange button.
In the standard TE firmware, the RP2350 runs a customized build of MicroPython.
The USB C connector is mounted on the back of the PCB in the lower right corner. The device can be powered via USB from this port, or by two AA batteries. There is no charging circuit in the device, so USB power will not recharge any type of AA batteries.
The reset button is just above the USB C, right next to the bottom white side button. Pressing reset once will reboot the device. Double pressing reset while holding the handle all the way in will access the bootloader. The device will connect to the host computer as the TING BOOT removable storage drive.
The EP-2350 has two quirks about power management as compared to most CircuitPython devices.
- It stays powered off by default when you plug in the USB C cable. In order to turn it on you must press the handle all the way in.
- There is a power watchdog monitoring GPIO2 pin. The pin must be held high for the device to remain powered. In CircuitPython code, the pin can be accessed with
board.GP2orboard.POWER_HOLD. Your code can drive it low to power down the device. When the handle is pressed again it will boot up. The CircuitPython firmware automatically drives it high on boot up and reset.
There is a small 24 pin chip on the front of the PCB that has the markings "PG", "3101", "B7880", and a logo. The informal.cc teardown post lists this as the Analog Devices LTC3101 power converter
The green handle on the side of the device has an internal spring that pushes it outward, returning it to its resting position when you release the handle.
There are two buttons actuated by the handle. One that is pressed when the handle is in the resting position, and another that is pressed when the handle is held all the way in.
- The "handle out" button is pressed when the handle is at rest with spring tension. When you start to squeeze the handle, the button gets released. The button is connected to GPIO20. It can be accessed in code with
board.GP20, orboard.HANDLE_OUT. Use aPull.UPin code to read it. When the handle is at rest the pin is driven low to GND, when you start to squeeze the handle the pin goes high. This button is small, non-tactile, and mounted on the front of the PCB. - The "handle in" button is a tactile button mounted on the back of the PCB. It gets pressed when the handle is fully squeezed. Use a
Pull.UPin code to read it. It can be accessed in code withboard.GP19orboard.HANDLE_IN. When the handle is fully squeezed the pin is driven low to GND. When the handle isn't squeezed its pulled high. This is also the button that powers the device on.
Inside the pivot point of the handle there is a potentiometer connected to GPIO28. It can be accessed in code with board.GP28, board.A2, or board.HANDLE_POSITION.
It is an analog pin which would ordinarilly read as a value between 0-65535. But, the physical movement of the potentiometer constrained to a subset of the full range. When the handle is fully squeezed it reads around 29850, and when the handle is fully released it reads around 32370.
The 3 buttons on the side of the EP-2350 ting have tactile momentary switches mounted on the back side of the board.
To read the buttons use Pull.UP in the code. When a button is pressed its pin will be driven low to GND.
-
Top orange button is connected to GPIO21, it can be accessed in code with
board.GP21orboard.BUTTON_TOP. -
Middle green button is connected to GPIO3, it can be accessed in code with
board.GP3orboard.BUTTON_MIDDLE. -
Bottom white button is connected to GPIO1, it can be accessed in code with
board.GP1orboard.BUTTON_BOTTOM.
On the back of the PCB there are 8 LEDs broken up into one column of 4 red LEDs, and one column of 4 white LEDs. The red LEDs are at the top, and the white LEDs are beneath them. The pins below are listed from the top of the device down
-
1st red LED is on GPIO16. It's available in code as
board.GP16, orboard.LED_RED1. -
2nd red LED is on GPIO24. It's available in code as
board.GP24orboard.LED_RED2. -
3rd red LED is on GPIO18. It's available in code as
board.GP18, orboard.LED_RED3. -
4th red LED is on GPIO17. It's available in code as
board.GP17, orboard.LED_RED4. -
1st white LED is on
GPIO0. It's available in code asboard.GP0,board.LED_WHITE1, orboard.LED. -
2nd white LED is on GPIO6. It's available in code as
board.GP6, orboard.LED_WHITE2 -
3rd white LED is on GPIO4. It's available in code as
board.GP4, orboard.LED_WHITE3 -
4th white LED is on GPIO5. It's available in code as
board.GP5, orboard.LED_WHITE4
A potentiometer with a green phillips screw head cap is mounted on the back of the PCB in the lower left corner. In the stock Teenage Engineering firmware, this acts as a volume knob.
The potentiometer is connected to GPIO29. It is available in code as board.GP29, board.A3, and board.VOLUME. It sweeps the full range of 0 to 3.3V. Turning the knob clockwise raises the voltage. The pin will read 3.3V when the knob is fully turned to the right clockwise.
I2C Bus
The I2C bus used on the device is available as board.I2C(). It consists of the following pins.
-
SDA is GPIO14. It's available in code as
board.GP14, orboard.SDA. -
SCL is GPIO15. It's available in code as
board.GP15, orboard.SCL.
There is an accelerometer on the back of the PCB near the top right corner. It is connected on the I2C bus with address 0x18. It's compatible with the LIS331 library. However, it reports a non-standard chip ID of 0x11 instead of the expected 0x32.
The original MicroPython firmware uses this to enable shake motions to trigger changes to the audio effects being applied or played.
The star of the show on this device is the NAU88L21 audio CODEC chip. It is configured and controlled over I2C. The audio input and output between it and the RP2350 occurs over I2S both ways.
Sending audio to it with I2SOut will cause the audio to be played from the 3.5mm jack.
Using an I2SIn object will allow you to capture audio from the microphone on the device.
-
I2S MCLK is GPIO12. It's available in code as
board.GP12, orboard.I2S_MCLK -
I2S BCLK is GPIO11. It's available in code as
board.GP11, orboard.I2S_BCLK. -
I2S WS is GPIO10. It's available in code as
board.GP10, orboard.I2S_WS. -
I2S DIN is GPIO8. It's available in code as
board.GP8, orboard.I2S_DIN. -
I2S DOUT is GPIO9. It's available in code as
board.GP9, orboard.I2S_DOUT
The microphone is connected to the CODEC with two very thin wires soldered near the NAU88L21 chip. The 3.5mm line connects to the PCB with a small plastic press fit connector.
Page last edited August 05, 2026
Text editor powered by tinymce.
Install CircuitPython
Installing CircuitPython on the EP-2350 is done using the standard UF2 file process. The name of the boot drive, TING BOOT, is different from the typical stock RP2350 boot drive name, but copying a UF2 file to flash a new firmware works the same.
CircuitPython Download
The EP-2350 board definition was added to CircuitPython after the development release 10.3.0-alpha.4. That means it is not in any official release yet, it will be included in the next one. Until then the 'absolute newest' link on the CircuitPython.org downloads page can be used to get the CircuitPython firmware UF2 file.
The button below will download the firmware from a 8/4/26 GitHub actions build.
Accessing the bootloader requires keeping the handle squeezed all the way in the entire time while the UF2 is copying and the device is rebooting afterward. It's tricky to hold it with one hand and operate the computer to paste files with the other. To make it easier, you can put a rubber band or similar around the device to hold the handle all the way in.
Once the new firmware is loaded and the device has booted back up the band can be removed to let go of the handle.
To begin, plug your board into your computer via USB, using a known-good data-sync cable.
While the handle remains held in all the way, double press the reset button. It is a small dark circle button just above the USB plug (circled in orange in this image). The timing is a little tricky, so you may have to retry a few times to get it right.
In bootloader mode, all LEDs are off and the device will appear to the host as a removable storage drive with the name TING BOOT.
Copy CircuitPython UF2
Copy the CircuitPython firmware UF2 file from the link above and paste it onto the TING BOOT storage drive. The file transfer will take a few seconds and then the device will reboot and should mount to the host as the CIRCUITPY drive.
To verify the CircuitPython installation is working you can connect to the serial console and print the pins on the board module.
Once the CircuitPython installation is confirmed you can remove the rubber band to release the handle.
Page last edited August 05, 2026
Text editor powered by tinymce.
Code
This page contains a CircuitPython implementation of the main functionality from the stock Teenage Engineering firmware and app. It features a JSON configuration file that allows you to set up to 4 presets and 4 samples. Each preset and sample can include an effects chain to manipulate your voice or the sample audio.
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 EP2350 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 project wave files, config_loader.py, and config.json file to your CIRCUITPY drive. The program should self start.
# SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
EP-2350 CircuitPython Demo: mic -> headphone passthrough with JSON-configured effect chains.
Up to four presets are used (one per red LED). Only one chain exists at a time:
switching tears the old chain down and builds the new one.
Controls:
* TOP side button -- steps to the next spot in the preset cycle. The cycle is the
configured presets plus a "clean" spot with no effects at
all, where the mic feeds the output directly.
* Handle -- gates the mic output. It starts muted; the DAC un-mutes
while the paddle is squeezed and mutes again on release.
* Volume knob -- sets the DAC digital volume, continuously. Fully anti-
clockwise is silence.
* Handle travel -- offered to the presets, and to a sample's own effects
chain, as the block ``"$handle"``, 0.0 with the handle out
to 1.0 squeezed in. A preset or sample that does not
mention it ignores the handle entirely.
* MIDDLE side button -- steps to the next spot in the list of wave samples. the
current position in the list is indicated by the white
LEDs.
* BOTTOM side button -- plays the current sample wave file with any effects
configured for it.
* TOP + MIDDLE, held together for 500ms -- shuts the unit down by driving
POWER_HOLD low.
Indication:
* Top four red LEDs, one per preset: the lit one is the active preset. On the
clean spot they are all dark.
* Bottom four white LEDs, one per sample: the lit one is the currently selected
sample.
"""
import gc
import json
import time
import analogio
import audiobusio
import audioi2sin
import audiocore
import audiomixer
import board
import digitalio
import keypad
import synthio
import adafruit_nau88l21
from config_loader import create_effects, load_samples
RATE = 16000 # frame rate on the wire; also what the codec's FLL expects
# Where to look for the preset config
CONFIG_PATHS = ("/config.json", "/ep2350_circuitpython_config.json")
MAX_PRESETS = 4
MAX_SAMPLES = 4
# How long TOP + MIDDLE must be held together, in seconds, to shut down.
SHUTDOWN_HOLD_SECONDS = 0.8
# --- Hardware effects config ---
# Analog mic gain, dB, -1 .. 36.
MIC_GAIN = 14
# ADC digital gain, dB.
ADC_VOLUME = 0
# Headphone analog volume, dB. Only 0/-3/-6/-9 exist.
HEADPHONE_VOLUME = 0
# --- Volume knob ---
# What the two ends of the knob's travel mean, as a DAC digital volume in dB.
# The codec's own limits are -66 to +24; the top is kept well short of that.
# Travel maps to dB linearly.
VOLUME_MIN_DB = -50
VOLUME_MAX_DB = 6
# Fraction of the travel at the anticlockwise end that means silence, so the
# knob has a definite "off" rather than bottoming out at merely very quiet.
VOLUME_OFF_FRACTION = 0.02
# Volume knob smoothing for noisy signal
VOLUME_DEADBAND = 700
# --- Handle position ---
# The handle travel pot on GP28, in ADC counts at each end of its swing. The
# whole range is only ~2520 counts of the 16-bit scale, so it has to be mapped
# explicitly;
HANDLE_OUT_COUNTS = 32370 # at rest, handle all the way out
HANDLE_IN_COUNTS = 29850 # squeezed fully in
# Handle position potentiometer smoothing for noisy signal
HANDLE_SAMPLES = 16
HANDLE_SMOOTHING = 0.25
# DC-blocking high-pass corner, Hz. Applied in the codec's ADC path (see the
# codec.configure_adc_highpass() call below)
HPF_HZ = 120
# Audio format of the chain. Passed to every effect config_loader builds; the
# JSON only describes the sound-affecting parameters, never the format ones.
FORMAT = {
"buffer_size": 1024,
"sample_rate": RATE,
"bits_per_sample": 16,
"samples_signed": True,
"channel_count": 1,
}
# --- Mutable state container ---
class DataContext:
"""Holds all state that is reassigned inside functions.
Keeping mutable state in one object removes the need for ``global``
declarations and makes the data dependencies of each function explicit.
"""
def __init__(self):
# The effects making up the currently active preset, in signal-flow
# order. Empty on the clean spot.
self.chain = []
# Index into the cycle: 0 == clean, 1..len(PRESETS) == that preset.
self.active = 0
# Active white LED, 1..4 (top to bottom).
self.white_active = 1
# WaveFile currently loaded, ``None`` if there is no matching sample
# configured or its file could not be loaded.
self.current_wave = None
# Playmode of the active sample, one of config_loader.PLAYMODES.
# Stays "oneshot" (a no-op default) when there is no sample.
self.current_playmode = "oneshot"
# The sample's effect chain, built from its "effects" config (empty
# list if it has none), in signal-flow order.
self.current_effects = []
# What `play_current_wave_sample()` actually hands to
# `mixer.voice[1].play()`: `current_wave` itself if the sample has no
# effects, otherwise the last element of `current_effects`. ``None``
# when there is no sample loaded.
self.current_source = None
# True while `current_source` is looping on mixer voice 1 because of a
# "hold" or "startstop" press, tracked so a "startstop" press knows
# whether to start or stop, and so a LED switch mid-loop can clean up
# correctly.
self.wave_playing = False
# Whether TOP / MIDDLE are currently held down, for the shutdown combo.
self.top_held = False
self.middle_held = False
# the `time.monotonic()` the combo started, or None while it is not both-down.
self.combo_since = None
# Wiper position, in ADC counts, that `volume` was last computed from.
# Starts far enough outside the 16-bit range that the first poll always
# applies.
self.knob_applied = -1 << 20
# The dB the DAC volume was last set to, or None while the knob is at
# its off end.
self.volume = None
# True == paddle squeezed.
self.paddle_held = False
# --- Preset config ---
def load_config():
"""Read and parse the JSON config file.
Tries `CONFIG_PATHS` in order; an unreadable or malformed file at a given
path is not fatal, it just moves on to the next one.
:return: The parsed config dict, or ``{}`` if none of the paths worked --
which leaves the demo with nothing but the clean spot in its preset
cycle and no samples to play.
"""
for path in CONFIG_PATHS:
try:
with open(path, "r") as file:
return json.load(file)
except (OSError, ValueError) as error:
print("config {}: {}".format(path, error))
return {}
CONFIG = load_config()
PACK_NAME = CONFIG.get("name", "none")
# Presets are passed to config_loader whole, since a preset is more than its
# effect list: it may carry a "blocks" mapping of LFOs and Math blocks that
# its effects refer to by name.
PRESETS = list(CONFIG.get("presets", ())[:MAX_PRESETS])
# One wave file per white LED slot, each with a playmode telling the BOTTOM
# button how to play it back. See config_loader.load_samples().
SAMPLES = load_samples(CONFIG, max_samples=MAX_SAMPLES)
# --- Codec + audio bring-up ---
# The internal clock mode I2S object has to exist first: it generates BCLK/WS,
# and the external clock mode I2S object syncs to the WS edges it sees.
# Constructing the internal clock one starts BCLK, which the codec's
# FLL then has something to lock to.
i2s = audiobusio.I2SOut(board.I2S_BIT_CLOCK, board.I2S_WS, board.I2S_DOUT)
codec = adafruit_nau88l21.NAU88L21(board.I2C())
codec.configure_clocks()
# enable headphone output and set hardware volume. The DAC digital volume is
# deliberately not set here, the knob owns it.
codec.headphone_output = True
codec.headphone_volume = HEADPHONE_VOLUME
codec.configure_microphone_input(gain_db=MIC_GAIN)
codec.adc_volume = ADC_VOLUME
# Strip the microphone's DC offset and slow subsonic bias drift in the codec's
# own ADC biquad, before it ever reaches the I2S bus.
codec.configure_adc_highpass(frequency=HPF_HZ, sample_rate=RATE)
mic = audioi2sin.I2SIn(
board.I2S_BIT_CLOCK,
board.I2S_WS,
board.I2S_DIN,
sample_rate=RATE,
bit_depth=16,
mono=True,
external_clock=True,
)
# Two-voice audio mixer sits between the sources and the I2S output.
# Voice 0 carries the current mic/effects chain. Voice 1 carries the
# wave file samples
mixer = audiomixer.Mixer(
voice_count=2,
buffer_size=FORMAT["buffer_size"],
channel_count=FORMAT["channel_count"],
bits_per_sample=FORMAT["bits_per_sample"],
samples_signed=FORMAT["samples_signed"],
sample_rate=FORMAT["sample_rate"],
)
i2s.play(mixer, loop=True)
# --- Chain switching ---
def teardown(ctx):
"""Stop the current voice, free the current chain's effects"""
mixer.voice[0].stop()
for _effect in ctx.chain:
_effect.deinit()
ctx.chain = []
gc.collect()
def build(ctx, preset):
"""Wire up ``preset`` as the live chain on mixer voice 0.
WIRING ORDER MATTERS. Work from the voice input backwards, so the call
that hands the mic to something is the LAST one:
mixer.voice[0].play(head) -> ... -> tail.play(mic)
The output side is the mixer itself, wired to I2SOut once at startup. That
means preset switching does not restart the clock source: it only replaces
the source feeding mixer voice 0. The clock follower I2SIn re-syncs when
something calls play() *on the mic* (or on an effect that eventually feeds
the mic); effects do not propagate reset_buffer(), so the mic must be wired
last.
:param ctx: The mutable state container.
:param preset: A preset from the config, or an empty one for the clean
spot, where the mic is played directly.
"""
if not preset:
ctx.chain = []
mixer.voice[0].play(mic, loop=True)
return
# Build first: a config error or an out-of-memory here must not leave a
# half-wired graph running. create_effects() builds without wiring, which
# is what lets the wiring below run output-first.
ctx.chain = create_effects(preset, blocks={"handle": handle_control}, **FORMAT)
if not ctx.chain:
mixer.voice[0].play(mic, loop=True)
return
mixer.voice[0].play(ctx.chain[-1], loop=True)
for index in range(len(ctx.chain) - 1, 0, -1):
ctx.chain[index].play(ctx.chain[index - 1])
ctx.chain[0].play(mic)
def select_preset(ctx, index):
"""Make preset cycle position ``index`` the active chain.
The switch is done with the DAC soft-muted, since tearing the graph down
and back up puts a step in the output. A preset that fails to build (an
effect this firmware lacks, or one delay line too many for RAM) falls back
to the clean spot rather than taking the demo down.
:param ctx: The mutable state container.
:param index: The preset position to activate.
"""
muted = codec.dac_soft_mute
codec.dac_soft_mute = True
teardown(ctx)
ctx.active = index
try:
build(ctx, PRESETS[index - 1] if index else [])
except (ValueError, MemoryError) as error:
print("preset {} failed: {}".format(index, error))
teardown(ctx)
ctx.active = 0
build(ctx, [])
update_red_leds(ctx)
print(
"preset {} of {} ({} effects), {} bytes free".format(
ctx.active, len(PRESETS), len(ctx.chain), gc.mem_free()
)
)
codec.dac_soft_mute = muted
# --- Controls ---
# The three side buttons are switches to ground with an internal pull-up,
# pressed reads low, so they share one Keys group. key_number is the index
# into this tuple.
BUTTON_PINS = (board.BUTTON_TOP, board.BUTTON_MIDDLE, board.BUTTON_BOTTOM)
# key_number in the keypad.Keys group:
# 0 = TOP -- already used to step presets.
# 1 = MIDDLE -- steps the white LEDs / selected sample.
# 2 = BOTTOM -- plays the selected sample, per its playmode.
TOP = 0
MIDDLE = 1
BOTTOM = 2
keys = keypad.Keys(BUTTON_PINS, value_when_pressed=False, pull=True)
# The paddle is read from its HANDLE_OUT stage (GP20) rather than HANDLE_IN:
# HANDLE_IN is the travel end-stop, and its tactile click leaks a loud thump
# into the mic. HANDLE_OUT is the earlier "user is squeezing" switch with no
# hard end-stop. It is normally closed to ground, so with a pull-up it reads low
# at rest and high once held.
paddle = digitalio.DigitalInOut(board.HANDLE_OUT)
paddle.switch_to_input(pull=digitalio.Pull.UP)
# Top four red LEDs: one per preset. The lit one is the active preset.
# None are lit is clean passthrough.
RED_PINS = (board.LED_RED1, board.LED_RED2, board.LED_RED3, board.LED_RED4)
red_leds = []
for _pin in RED_PINS:
_led = digitalio.DigitalInOut(_pin)
_led.direction = digitalio.Direction.OUTPUT
red_leds.append(_led)
# Four white LEDs: one per sample. The lit one is the active sample.
WHITE_PINS = (board.LED_WHITE1, board.LED_WHITE2, board.LED_WHITE3, board.LED_WHITE4)
white_leds = []
for _pin in WHITE_PINS:
_led = digitalio.DigitalInOut(_pin)
_led.direction = digitalio.Direction.OUTPUT
white_leds.append(_led)
def update_red_leds(ctx):
"""Light the red LED belonging to the active preset, if any."""
for index, _led in enumerate(red_leds):
_led.value = ctx.active == index + 1
def update_white_leds(ctx):
"""Light exactly the currently selected white LED."""
for index, _led in enumerate(white_leds):
_led.value = ctx.white_active == index + 1
def build_sample_chain(wave, sample, loop):
"""Build and wire the effect chain for a sample's wave file, if it has one.
WIRING ORDER MATTERS, same as `build()`: work from the end backwards, so
the call that hands the wave to something is the last one.
:param wave: The `audiocore.WaveFile` to feed into the chain, or straight
to the mixer if there are no effects.
:param dict sample: One entry from `SAMPLES`, i.e. a
``load_samples()`` dict with "effects" and "blocks" keys.
:param bool loop: Whether wave playback should loop. A sample's playmode
does not change while it stays loaded, so this is fixed for the whole
chain and threaded through every stage.
:return: ``(chain, source)`` -- ``chain`` is the list of effect objects to
`deinit()` later (empty if there are none), ``source`` is what to
hand to ``mixer.voice[1].play()``.
"""
effects_specs = sample["effects"]
if not effects_specs:
return [], wave
preset = {"list": effects_specs, "blocks": sample["blocks"]}
chain = create_effects(preset, blocks={"handle": handle_control}, **FORMAT)
if not chain:
# All effects in effects_specs were "enabled": false.
return [], wave
for index in range(len(chain) - 1, 0, -1):
chain[index].play(chain[index - 1], loop=loop)
chain[0].play(wave, loop=loop)
return chain, chain[-1]
def load_selected_wave_sample(ctx):
"""Load the selected sample wave file.
Points ``ctx.current_wave``, ``ctx.current_playmode``,
``ctx.current_effects`` and ``ctx.current_source`` at it, stopping and
freeing whatever was loaded before. ``ctx.white_active`` is 1-based;
``SAMPLES`` is 0-based, so it is short by one. A white LED with no matching
entry in ``SAMPLES`` (fewer samples configured than white LEDs) leaves
``ctx.current_wave`` / ``ctx.current_source`` ``None``.
A sample whose effects fail to build (an effect this firmware lacks, or
one too many for RAM) falls back to the plain wave.
:param ctx: The mutable state container.
"""
stop_current_wave(ctx)
for _effect in ctx.current_effects:
_effect.deinit()
ctx.current_effects = []
if ctx.current_wave is not None:
ctx.current_wave.deinit()
ctx.current_wave = None
ctx.current_playmode = "oneshot"
ctx.current_source = None
gc.collect()
if ctx.white_active > len(SAMPLES):
print("no sample configured for LED {}".format(ctx.white_active))
return
sample = SAMPLES[ctx.white_active - 1]
ctx.current_playmode = sample["playmode"]
path = "/{}".format(sample["file"])
try:
ctx.current_wave = audiocore.WaveFile(path)
except OSError as error:
print("{} not loaded: {}".format(path, error))
return
try:
ctx.current_effects, ctx.current_source = build_sample_chain(
ctx.current_wave, sample, loop=ctx.current_playmode != "oneshot"
)
except (ValueError, MemoryError) as error:
print("{} effects failed: {}".format(path, error))
ctx.current_effects, ctx.current_source = [], ctx.current_wave
print(
"loaded {} ({} Hz, {} ch, {} bit, {} mode, {} effect(s))".format(
path,
ctx.current_wave.sample_rate,
ctx.current_wave.channel_count,
ctx.current_wave.bits_per_sample,
ctx.current_playmode,
len(ctx.current_effects),
)
)
def stop_current_wave(ctx):
"""Stop whatever mixer voice 1 is playing, if anything.
.
"""
mixer.voice[1].stop()
ctx.wave_playing = False
def play_current_wave_sample(ctx):
"""Handle a BOTTOM button press, per the selected sample's playmode.
* "oneshot" -- plays through once; a press while it is still playing
restarts it, since `audiomixer.MixerVoice.play` always
replaces whatever a voice is doing.
* "hold" -- starts looping; `release_current_wave()` stops it.
* "startstop" -- toggles between looping and stopped.
:param ctx: The mutable state container.
"""
if ctx.current_source is None:
print("no wave loaded for LED {}".format(ctx.white_active))
return
if ctx.current_effects:
# Re-hand the wave to the head of the chain so the press restarts it.
ctx.current_effects[0].play(
ctx.current_wave, loop=ctx.current_playmode != "oneshot"
)
if ctx.current_playmode == "oneshot":
mixer.voice[1].play(ctx.current_source)
print("playing {} (oneshot)".format(ctx.white_active))
elif ctx.current_playmode == "hold":
mixer.voice[1].play(ctx.current_source, loop=True)
ctx.wave_playing = True
print("playing {} (hold)".format(ctx.white_active))
elif ctx.current_playmode == "startstop":
if ctx.wave_playing:
stop_current_wave(ctx)
print("stopped {} (startstop)".format(ctx.white_active))
else:
mixer.voice[1].play(ctx.current_source, loop=True)
ctx.wave_playing = True
print("playing {} (startstop)".format(ctx.white_active))
def release_current_wave(ctx):
"""Handle a BOTTOM button release -- only "hold" cares about this.
:param ctx: The mutable state container.
"""
if ctx.current_playmode == "hold":
stop_current_wave(ctx)
print("stopped {} (hold released)".format(ctx.white_active))
# The handle travel pot, offered to the presets as the block named
# "$handle".
handle_pot = analogio.AnalogIn(board.HANDLE_POSITION)
handle_control = synthio.Math(synthio.MathOperation.SUM, 0.0, 0.0, 0.0)
# The volume knob is a plain potentiometer across 3V3 with its wiper on GP29.
knob = analogio.AnalogIn(board.VOLUME)
def apply_volume(ctx, force=False):
"""Set the DAC digital volume from the knob, if the knob has moved.
The knob's travel maps linearly onto `VOLUME_MIN_DB` .. `VOLUME_MAX_DB`,
except for `VOLUME_OFF_FRACTION` at the anticlockwise end, which mutes.
Small movements are ignored: the wiper is noisy enough to jitter by a few
hundred counts while nobody is touching it, and each change costs an I2C
write in the middle of the audio loop.
:param ctx: The mutable state container.
:param bool force: Apply the current position even if it has not moved --
used for the first read, and after anything else has written the DAC
volume.
"""
raw = knob.value
if not force and abs(raw - ctx.knob_applied) < VOLUME_DEADBAND:
return
ctx.knob_applied = raw
fraction = raw / 65535
if fraction <= VOLUME_OFF_FRACTION:
# -66 dB is the bottom of the codec's digital volume scale; below that
# the codes are reserved rather than usable, so this is as close to off
# as this control goes. It is inaudible.
ctx.volume = None
codec.dac_volume = -66
print("volume off")
return
# Rescale so the usable part of the travel still covers the whole range.
fraction = (fraction - VOLUME_OFF_FRACTION) / (1 - VOLUME_OFF_FRACTION)
ctx.volume = VOLUME_MIN_DB + fraction * (VOLUME_MAX_DB - VOLUME_MIN_DB)
codec.dac_volume = ctx.volume
print("volume {:+.1f} dB".format(codec.dac_volume))
def read_handle():
"""Update `handle_control` from the handle position, 0.0 out to 1.0 in."""
total = 0
for _ in range(HANDLE_SAMPLES):
total += handle_pot.value
raw = total / HANDLE_SAMPLES
fraction = (HANDLE_OUT_COUNTS - raw) / (HANDLE_OUT_COUNTS - HANDLE_IN_COUNTS)
fraction = min(1.0, max(0.0, fraction))
handle_control.a += (fraction - handle_control.a) * HANDLE_SMOOTHING
# --- Main demo setup and loop ---
data_context = DataContext()
data_context.paddle_held = paddle.value
# Initialize the first LED and load its wave file.
update_white_leds(data_context)
load_selected_wave_sample(data_context)
print(
"demo v2: pack {!r}, {} preset(s), mic gain {:.0f} dB, codec ADC HPF {:d} Hz".format(
PACK_NAME, len(PRESETS), codec.mic_gain, HPF_HZ
)
)
print("TOP button = next preset ({} spots, 0 = clean)".format(len(PRESETS) + 1))
print(
"volume knob = DAC volume, {:.0f} to {:+.0f} dB".format(
VOLUME_MIN_DB, VOLUME_MAX_DB
)
)
print('handle travel = "$handle" block, 0.0 (out) to 1.0 (in)')
# The DAC is permanently un-muted. The paddle gates only mixer voice 0
# (the mic/effects chain) so that wave playback on voice 1 still works with the
# handle released.
codec.dac_soft_mute = False
apply_volume(data_context, force=True)
mixer.voice[0].level = 0.0 # mic chain silent until the paddle is squeezed
# Seed the handle before the first chain is built, so a preset that maps it
# starts at the handle's real position rather than sliding up from 0.
for _ in range(int(1 / HANDLE_SMOOTHING) + 1):
read_handle()
select_preset(data_context, 0)
try:
while True:
# Drain every button event that arrived since the last pass.
event = keys.events.get()
while event is not None:
if event.key_number == TOP:
data_context.top_held = event.pressed
if event.key_number == MIDDLE:
data_context.middle_held = event.pressed
if event.pressed and event.key_number == TOP:
# Step to the next spot in the cycle, wrapping around. The
# cycle is one longer than the preset count: spot 0 is clean.
select_preset(
data_context, (data_context.active + 1) % (len(PRESETS) + 1)
)
if event.pressed and event.key_number == MIDDLE:
# Cycle the white LEDs: 1 -> 2 -> 3 -> 4 -> 1 ...
# No empty spot; exactly one is always lit.
data_context.white_active = (
data_context.white_active % len(white_leds) + 1
)
update_white_leds(data_context)
load_selected_wave_sample(data_context)
print("white LED {}".format(data_context.white_active))
if event.pressed and event.key_number == BOTTOM:
play_current_wave_sample(data_context)
if event.released and event.key_number == BOTTOM:
release_current_wave(data_context)
event = keys.events.get()
# TOP + MIDDLE held together for SHUTDOWN_HOLD_SECONDS powers off.
if data_context.top_held and data_context.middle_held:
if data_context.combo_since is None:
data_context.combo_since = time.monotonic()
elif time.monotonic() - data_context.combo_since >= SHUTDOWN_HOLD_SECONDS:
print(
"TOP+MIDDLE held {}s: shutting down".format(SHUTDOWN_HOLD_SECONDS)
)
power_hold = digitalio.DigitalInOut(board.POWER_HOLD)
power_hold.direction = digitalio.Direction.OUTPUT
power_hold.value = False
else:
data_context.combo_since = None
# The paddle gates the mic/effects chain (mixer voice 0)
held = paddle.value
if held != data_context.paddle_held:
data_context.paddle_held = held
print("paddle {}".format("pressed" if held else "released"))
time.sleep(0.1)
mixer.voice[0].level = 1.0 if held else 0.0
# The knob is free-running: it is read every pass and only acted on
# when it has actually moved.
apply_volume(data_context)
# The handle is read every pass too, always applied
read_handle()
# Reading .overflow clears it. It trips if the playback side falls
# behind the mic, which should not happen here. Both run off the
# same BCLK, so a report means the DSP chain can't keep up.
if mic.overflow:
print("overflow")
time.sleep(0.01)
except KeyboardInterrupt:
teardown(data_context)
stop_current_wave(data_context)
for effect in data_context.current_effects:
effect.deinit()
if data_context.current_wave is not None:
data_context.current_wave.deinit()
mixer.deinit()
i2s.deinit()
mic.deinit()
keys.deinit()
paddle.deinit()
knob.deinit()
handle_pot.deinit()
for led in red_leds:
led.value = False
led.deinit()
for led in white_leds:
led.value = False
led.deinit()
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.
On the next page we'll go over how to use it.
Page last edited August 05, 2026
Text editor powered by tinymce.
Use
The CircuitPython code on the previous page makes the EP-2350 act similarly to the stock Teenage Engineering firmware and app. The device acts as a voice changing microphone that can store up to 4 presets with different effects chains to manipulate the voice audio. It can also play up to 4 wave sample files that can be sent through effects chains.
Here is a video that demonstrates the effects that are included in the config.json file in the project bundle.
Terms
The configuration this project supports is very dynamic and powerful, understanding a few key terms and concepts will make it much easier to experiment with and use to get the sound you're after.
- Pack - A config.json contains the configuration for a single pack. The pack encompasses the presets and samples. You can keep multiple pack config files on the CIRCUITPY drive and switch between them by renaming one to config.json and the others to alternate names. Preset effects are applied to the audio from the microphone before sending the modified audio out the 3.5mm jack.
- Preset - A preset is a list of effects and optionally blocks. A pack can contain up to 4 presets. The currently selected preset is indicated by the 4 top red LEDs on the front of the device.
- Sample - A sample is a wave audio file and optionally a list of effects and blocks used to modify it when played. A pack can contain up to 4 samples. The effects chains used on samples are separate from the ones in presets. The currently selected sample is indicated by the 4 bottom white LEDs on the device.
- Effect - An effect is a single item within an effect chain. In this CircuitPython implementation, the supported effects are the classes from the audiodelays, audiofilters, and audiofreeverb core modules. Effects can have their parameters set from the JSON config. Effect chains work top to bottom in the list. So each effect in the chain will modify the output of all of the effects that came before it.
-
Block - A block is a dynamic value that can be set as the parameter for an effect. The supported blocks are listed in the docs under synthio.BlockInput. LFOs, and math operations being the most interesting. They allow you to set up effect parameters to sweep a range of values over time. Blocks can be included inline as the value of a paremeter, or can be put into a
"blocks"section of the preset or sample and referred to by a given name using prepended dollar sign syntax like$sweep.
The basic controls are shown in the photo to the left.
- The orange top side button changes the selected preset. Currently selected preset is indicated by the top 4 red LEDs.
- The green middle side button changes the selected sample. Currently selected sample is indicated by the bottom 4 white LEDs.
- The white bottom side button triggers the current sample to play according to its playmode.
- Pressing the handle activates the voice passthrough with the currently selected preset's effects. If no preset is selected then it will be a clean passthrough.
To turn the device off, press and hold both the orange top side button, and green middle side button for 1 second. All LEDs will shut off and the device will power off.
To turn the device back on, press the handle all the way in and wait for the top white LED to blink before releasing.
Configuration Tool
Editing JSON by hand is tedious and error prone. To make the configuration process easier here is a web page GUI configuration tool (a similar tool exists for the stock MicroPython firmware here). This page allows you set all available configurations using standard UI controls. Once everything is set you can easily copy from the page to the config.json file on your CIRCUITPY drive.
This video provides a short tutorial on how to use the config tool.
The GUI config editor linked above is the easiest way to configure the device without having to worry about JSON syntax and typos breaking the config. But if you want to better understand how the JSON is structured or make modifications to it by hand the following section details the syntax.
Config Syntax
The config.json syntax used by the CircuitPython code is similar conceptually the config supported by the original TE firmware and app. CircuitPython has different effects and in some cases different parameter names, so the config is not a drop-in 1 to 1 replacement. However, it should feel very familiar to you if you have experience configuring the device in it's standard out-of-box state.
Pack
A config.json represents a single pack. The basic structure is a JSON dictionary with "name", "presets" and "samples" keys. name is a string that you can set as a reminder to yourself what the pack is. presets is a list of up to 4 preset definitions. samples is a list of up to 4 sample definitions.
{
"name": "DEMOPACK",
"presets": [
...
],
"samples": [
...
]
}
Preset
A preset is defined by a JSON dictionary containing a "list" key that holds a list of effects. You can also optionally include a "name", and/or "comment" key with strings containing a human readable note about the preset to jog your memory later. The "blocks" key can also be included to define a dictionary of named blocks which are used to change parameter values dynamically.
{
"name": "Robot Pitch Shift",
"comment": "Make you sound like a chipmunk robot",
"list": [
...
],
"blocks": {
...
}
},
Sample
A sample is defined by a JSON dictionary that contains at minimum "file", and "playmode" keys. File is the filepath to a wave file on the CIRCUITPY drive for the sample. Playmode is one of the following:
-
oneshot- Plays the sample wave file once in full per button press trigger. -
hold- Plays the sample wave file on a loop while the trigger button is held down. Cuts off immediately when released, even if the full sample has not played. -
startstop- Starts playing the sample wave file on a loop when you press the trigger button once, and stops playing it when you press the trigger button a second time.
The sample definition can also optionally include "effects" and "blocks" keys. They hold a list of effects to apply to the sample, and blocks for dynamic parameters. They're syntax is the same as the "list" and "blocks" keys from the preset definition.
Here is an example of a sample with some effects.
{
"file": "2.wav",
"playmode": "hold",
"effects": [
{
"effect": "chorus",
"max_delay_ms": 350,
"delay_ms": 100,
"voices": 3,
"mix": 0.85
}
]
},
Effect
An effect is defined by a JSON dictionary with "effect" and "mix" keys. mix is a float value from 0 to 1.0 that declares how strong the effect will be in the output. 1.0 is full effect, and 0.0 is no effect. The "effect" gets set to a string value naming the effect it should be. One of the following:
chorusechogranular_pitch_shiftmulti_tap_delaypitch_shiftdistortionfilterphaserfreeverb
Each one supports a different set of additional parameters to control how it will modify sound. See documentation for the audiodelays, audiofilters, and audiofreeverb modules for a comprehensive reference. Each of the above corresponds with a class in one of these modules.
Here is an example of an effect definition with parameters.
{
"effect": "distortion",
"drive": 0.6,
"pre_gain": 12,
"post_gain": -8,
"mode": "overdrive",
"soft_clip": true,
"mix": 1
},
Block
Blocks can be declared in-line within the value of a parameter, or as a named entry in the "blocks" dictionary.
A named block is defined by a JSON key/value pair with a name for a key and a value that holds a dictionary having the "block" key at a minimum, plus any other parameters needed to configure the specific block type. See the documentation for synthio.BlockInput to find parameters used by lfo and math, the two supported dynamic types of block. An in-line block uses the same syntax for the block definition, it just lacks a name.
Here is an example of an named LFO block:
"blocks": {
"semitone_sweep": {
"block": "lfo",
"waveform": "square",
"rate": 3,
"scale": 12.0,
"offset": 0
}
},
The block above, named semitone_sweep, oscillates with a square wave from -12 to +12 at 3hz, or 3 times per second.
Here is an example of an in-line math block that uses the special $handle value which maps to the analog position of the handle on the device:
{
"effect": "pitch_shift",
"semitones": {
"block": "math",
"operation": "constrained_lerp",
"a": -12,
"b": 12,
"c": "$handle"
},
"mix": 1,
"window": 1024,
"overlap": 128
},
The above example shows a full effect definition with a math block declared in-line for the semitones value. This math block uses linear interpolation to map the 0.0 to 1.0 values from $handle to a range of -12 to +12 and uses that for the semitones of the pitch shift. The result is the handle on the device giving analog control over the pitch. Squeeze the handle only a little get very low pitch, squeeze it half way for normal pitch, and squeeze it all the way for very high pitch.
Sample Config
The project bundle includes this sample configuration file that demonstrates basic and advanced configuration techniques. Reading over it can give you ideas for your own packs. This config can be imported into the GUI config editor as well to start tweaking and changing from it instead of a blank slate.
{
"name": "DEMOPACK",
"presets": [
{
"list": [
{
"effect": "pitch_shift",
"semitones": {"block":"math","operation":"constrained_lerp","a":-12,"b":12,"c":"$handle"},
"mix": 1,
"window": 1024,
"overlap": 128
},
{
"effect": "distortion",
"drive": 0.6,
"pre_gain": 12,
"post_gain": -8,
"mode": "overdrive",
"soft_clip": true,
"mix": 1
},
{"effect":"filter","filter":{"mode":"low_pass","frequency":3500,"Q":0.7071},"mix":1}
]
},
{
"list": [
{"effect":"filter","filter":{"mode":"high_pass","frequency":200,"Q":0.7071},"mix":1},
{"effect": "granular_pitch_shift", "semitones": 8.0, "density": 3.0},
{"effect":"echo","max_delay_ms":1200,"delay_ms":750,"decay":0.45,"mix":0.35,"freq_shift":true},
{"effect":"freeverb","roomsize":0.75,"damp":0.55,"mix":0.6}
]
},
{
"list": [
{"effect":"chorus","max_delay_ms":50,"delay_ms":25,"voices":3,"mix":0.5},
{"effect":"phaser","frequency":600,"feedback":0.7,"mix":0.5,"stages":8},
{
"effect": "multi_tap_delay",
"max_delay_ms": 750,
"delay_ms": 500,
"decay": 0.6,
"mix": 0.3,
"taps": [
[0.333,0.6],
[0.666,0.8],
1
]
}
]
},
{
"blocks": {
"sweep": {"block":"lfo","waveform":"sine","rate":1.5,"scale":1000,"offset":2000},
"delay_sweep": {"block":"lfo","waveform":"sine","rate":1,"scale":100,"offset": 200}
},
"list": [
{"effect":"pitch_shift","semitones":-6,"mix":1,"window":1024,"overlap":128},
{"effect":"chorus","max_delay_ms":450,"delay_ms":"$delay_sweep","voices":3,"mix":0.65},
{"effect":"phaser","frequency":"$sweep","feedback":0.7,"mix":0.5,"stages":6}
]
}
],
"samples": [{
"file": "1.wav",
"playmode": "startstop",
"blocks": {
"semitone_sweep": {
"block": "lfo",
"waveform": "square",
"rate": 3,
"scale": 12.0,
"offset": 0
}
},
"effects": [
{
"effect": "granular_pitch_shift",
"semitones": "$semitone_sweep",
"density": 3.0
}
]
},
{
"file": "2.wav",
"playmode": "hold",
"effects": [
{"effect":"chorus","max_delay_ms":350,"delay_ms":100,"voices":3,"mix":0.85}
]
},
{
"file": "3.wav",
"playmode": "oneshot",
"effects": [
{"effect":"filter","filter":{"mode":"high_pass","frequency":1200,"Q":0.7071},"mix":1},
{"effect": "granular_pitch_shift", "semitones": -8.0, "density": 3.0},
{"effect":"freeverb","roomsize":0.75,"damp":0.55,"mix":0.6}
]
},
{
"file": "4.wav",
"playmode": "oneshot",
"effects": [
{
"effect": "filter",
"filter": {
"mode": "high_pass",
"frequency": 1200,
"Q": 0.7071
},
"mix": 1
},
{
"effect": "granular_pitch_shift",
"semitones": 12.0,
"density": 3.0
},
{
"effect": "freeverb",
"roomsize": 0.55,
"damp": 0.55,
"mix": 0.6
}
]
}
]
}
Page last edited August 05, 2026
Text editor powered by tinymce.