← back to home

Overview

For my final project, I built a retro wooden radio around an ESP32 that supports four audio modes, including Internet radio over WiFi, MP3 playback from an SD card, Bluetooth, and FM radio.

I was motivated to build this project because I'm a huge fan of listening to and DJing music, and I've always been drawn to radios from the past, especially ones that do not depend on WiFi to play music. At the same time, I really enjoy using my Bluetooth speaker at home. I wanted to combine all of these into one "enhanced" speaker of sorts. My hope was to build a device that looks good and is functional. Here is my journey building the RetroRadio, from the MVP to the finished product!

Finished retro wooden ESP32 radio
The finished retro wooden ESP32 radio
Demo video and walkthrough of the finished radio
Close up video demonstrating all capabilities of radio

Bill of Materials

Electronics

Power

Enclosure and Finishing

MVP

For MVP week, I decided to build a small version of the radio and getting the fundamental input and output components working, including the OLED screen, rotary encoder, potentiometer, speaker, and amplifier. I designed the MVP to connect to internet radio stations like SomaFM and deliver real-time audio to the user. I also designed a 3D-printed enclosure for the radio, including a body, a speaker-mesh front panel, and a knob. Lastly, I milled a custom PCB based on the MVP wiring.

The MVP was functional, but there were several improvements I wanted to make, including expanding its capabilities and making the enclosure larger to accommodate the additional complexity I was hoping to add. For the full MVP process, see my Week 7 documentation.

Completed radio MVP with OLED display, speaker grille, and controls
The completed radio MVP with the OLED display, speaker grille, and controls

Integrated Project

For the integrated project, I wanted to add the remaining components I'd planned for the final radio, including an FM radio module, Bluetooth, and MP3. In practice, this meant I would need to program Bluetooth in the ESP32 Arduino code, wire up the TEA5767 module on the breadboard, and add the MicroSD card module so everything worked seamlessly. I also wanted to move all the wiring off the breadboard onto something more stable, either a protoboard or a custom milled PCB.

TEA5767 FM Module

First, I needed to hook up the TEA5767 FM module to the rest of the circuit. The wiring was fairly straightforward, except that the module has an audio output meant for an aux cable, and that aux cable needed to connect to both ground and an audio pin.

I hadn't anticipated needing extra equipment for the FM module, and since the PS70 lab didn't have an aux-to-bare-wire cable, I had to get scrappy. I dug through the lab's salvage bin, found a pair of headphones with a 3.5 mm aux cable, and snipped the wires to get at the internals. I identified the ground wire and the audio output wire and used those to send audio from the TEA5767 module to my ESP32.

First AUX cable used for TEA5767 audio
One of the AUX cables I used for the audio output of the TEA5767 chip
Second AUX cable used for TEA5767 audio
The second AUX cable I attempted to use for the TEA5767 audio path
Connecting an AUX cable from old headphones to the circuit board
Connecting the AUX cable from old headphones to the circuit board

Configuring the TEA5767 was one of the hardest challenges of the integrated project since there were a lot of software and even hardware issues in getting a consistent radio signal out of it. For many iterations, I heard no signal at all from the FM module. I even tried a different FM chip, the SI4703, but I didn't end up using it. Not only did it not work for me, it also had no module spot for an antenna, so it couldn't pick up signals as effectively as the TEA5767.

Troubleshooting the TEA5767 with an Arduino board
To troubleshoot the TEA5767, I used an Arduino board, which Kassia told me has less electronic interference compared to the ESP32
Testing out the FM radio chip outside of the PS70 lab where there is less interference
Testing the SI4703 FM chip
Testing out the SI4703 FM chip, which ultimately ended up not working due to its lack of an antenna

The core software conflict was between two I2S drivers. The FM audio path relies on the legacy I2S driver, while AudioTools uses the newer I2S driver, and the two can't both be active at once. The method that eventually worked was when I forced AudioTools to map onto the legacy driver and control the TEA5767 directly over I2C through a tea5767Write() function:

bool tea5767Write(float freqMHz) {
  unsigned long freqHz = (unsigned long)(freqMHz * 1000000.0f);
  unsigned long pll = (4UL * (freqHz + 225000UL)) / 32768UL;
  uint8_t b[5];
  b[0] = (pll >> 8) & 0x3F;
  b[1] =  pll & 0xFF;
  b[2] = 0x90;
  b[3] = 0x10;
  b[4] = 0x00;
  Wire.beginTransmission(TEA5767_ADDR);   // 0x60
  Wire.write(b, 5);
  return Wire.endTransmission() == 0;
}

So the audio path for FM was:

Due to the ADC sampling, the FM audio quality is a bit rough at times, but it plays through the main speaker without needing any headphones.

Another ongoing problem was that the FM module often crashed the ESP32 and made it reboot after working for about 30 seconds. To fix this issue, I went through six different changes. First, I added heap monitoring with ESP.getFreeHeap(), printing the free heap right after FM setup and then every 5 seconds. I also lowered the sample rate from 32 kHz to 16 kHz, which halved the CPU load in the audio task. Then I made sure to shut off WiFi and Bluetooth, with a 200 ms delay, before turning on FM radio, so there was no interference from the other wireless components.

WiFi.disconnect(true);
WiFi.persistent(false);
WiFi.mode(WIFI_OFF);
btStop();

Next, I uninstalled the I2S driver before installing the legacy driver, which cleared any leftover state from the previous mode, whether that was Bluetooth or WiFi.

i2s_driver_uninstall(I2S_NUM_0);

I then increased the audio task's stack from 8 KB to 16 KB, which eliminated stack overflow as one of the causes of the crashes, and I monitored the stack with uxTaskGetStackHighWaterMark(), printing it every five seconds.

There was also an issue where the radio button seemed to get pressed when I wasn't pressing it, a "phantom" phenomenon of sorts that triggered reboot after reboot. I learned this was because GPIO 35 has no internal pull-up, so it picks up electrical noise and reads random highs and lows. To fix it, I moved the radio button to GPIO 17, which does have an internal pull-up. For me the lesson of this ordeal was that you really need to study the pinout carefully. It turns out on the ESP32 I was using, GPIO 34, 35, 36, and 39 are input-only pins with no internal pull-ups, and it took me a long time to figure that out.

I ultimately got to a place where FM still needed to reboot after a while, but I made sure it rebooted automatically and saved which station you were on, so it stayed functional for the user. I suspect the remaining FM instability comes from memory pressure. Since all of this is running on just one microcontroller, including AudioTools, MP3, Bluetooth, WiFi, U8G2, the raw I2S, and the ADC, even when those tools aren't in use during FM playback, they're all still allocated in this same firmware. In the future, I might want to move to a more computationally powerful chip or board might solve this, but for now I was happy with the FM progress and moved on.

MP3 Playback

Next I worked on MP3, using a microSD card module and a 32 GB microSD card. The wiring of the module was fairly straightforward, but the software was an interesting problem. I had a few globals to declare at the start.

File audioFile;
StreamCopy copier(decoder, audioFile, 4096);

String tracks[32];
int trackCount = 0;
int currentTrack = -1;
bool paused = false;

I learned how to scan the SD card for MP3 files specifically without being bothered by the other, non-MP3 files on the card, from this ESP32 microSD card tutorial.

Then I wrote a function to load and play a track.

void playTrack(int idx, bool startPaused = false) {
  if (idx < 0 || idx >= trackCount) return;
  if (audioFile) audioFile.close();
  audioFile = SD.open(tracks[idx]);
  if (!audioFile) return;
  currentTrack = idx;
  paused = startPaused;
  Serial.print(startPaused ? "Loaded [" : "Playing [");
  Serial.print(idx); Serial.print("] ");
  Serial.println(tracks[idx]);
}

void nextTrack() { if (trackCount) playTrack((currentTrack + 1) % trackCount); }
void prevTrack() { if (trackCount) playTrack((currentTrack - 1 + trackCount) % trackCount); }

It was fun to see how the audio worked and to build the control logic so it advances to the next song once the current one finishes. It gave me more appreciation for my Spotify app!

void loopMP3Mode() {
  if (!paused && audioFile && audioFile.available()) {
    copier.copy();                                  // push next chunk of audio
  } else if (audioFile && !audioFile.available() && !paused) {
    nextTrack();                                    // track ended -> advance
  }
}
MP3 working for the first time, playing Justin Bieber!

Bluetooth

I also wanted to include Bluetooth. Initially I used an ESP32-S3 board, which I learned doesn't support Classic Bluetooth. As a result, I had to switch to a normal ESP32, which does have that feature. I needed Classic Bluetooth rather than Bluetooth Low Energy (BLE) because A2DP requires Classic Bluetooth. Note that the XIAO couldn't have done any of this at all!!

ESP32-S3 WROOM board used before switching to a normal ESP32
I initially used an ESP32-S3 WROOM board, but had to switch to a normal ESP32 because I needed Classic Bluetooth

I used the ESP32-A2DP library, which paired naturally with the AudioTools library that was already working for MP3. I got tripped up early on because older ESP32-A2DP examples online used a set_pin_config() call to tell the library which I2S pins to use, but that method no longer exists in the current library version. So instead, I went through the documentation and used a BluetoothA2DPSink that references my I2S stream. So confusing!

BluetoothA2DPSink a2dp_sink(i2s);

I then made the radio discoverable, but only when the user presses the pair button.

a2dp_sink.set_discoverability(ESP_BT_GENERAL_DISCOVERABLE);

To make the pairing feel like a polished final product, I added sound cues. While the radio is discoverable and waiting for a phone, it plays an 880 Hz beep every two seconds. When a phone connects, I added a connect chime. I thought an arpeggio from C up to G up to a higher C would sound nice, and I added a disconnect chime of those same three notes descending when a phone disconnects or loses Bluetooth.

However, because of Bluetooth, I had to make the ESP reboot every time you change modes. The ESP32-A2DP library doesn't like being started and stopped repeatedly. When a2dp_sink.start() runs, it brings up the entire Bluetooth stack, including the controller, the host stack, memory buffers, background tasks, and event handlers. All of this allocates a lot of memory. It's easier to just let the ESP shut off and reboot so it can free all of that.

On top of that, Bluetooth and WiFi conflict because the ESP32 has a single radio and antenna shared between them, both at 2.4 GHz. They can run at the same time, but they have to take turns, and since I want continuous audio streaming, it's cleaner to reboot so Bluetooth starts fresh and never has to share with WiFi, and vice versa. So ESP.restart() is called before every mode switch.

Moving to a Protoboard

With all the modes working together synchronously thanks to the mode-switch reboot, I wanted to move everything off the breadboard onto a more solid surface. Since I was still thinking about future changes, I decided against a custom milled PCB and went with a protoboard instead. I'd never used a protoboard before so I was excited.

Complete circuit wiring on a breadboard before moving to a protoboard
Wiring of the complete circuit board before putting it on the protoboard

I realized that to reach both sides of the ESP32's pins, I needed two protoboards rather than one, so I soldered the pins together across two protoboards. That made for a larger product but that was what needed to happen (I wonder if there are larger single protoboards where you can access both sides of ESP pins?). It took several hours to finish this, because there were so many pins to solder but at least I think I achieved flow state while doing it. The protoboard gave me great soldering practice, and I think I got much better at it. It also forced me to write down and confirm every wiring connection and pin number I was using, which made me appreciate that each pin has its own function and that it's important to track which pins you use, since some of them, as I saw with the GPIO 35 mishap, don't have the mechanisms you need.

ESP32 mounted across two protoboards
I decided to use two protoboards instead of one to reach the pins on both sides of the ESP32
Testing the protoboard after wiring changes
Checking protoboard after each change to ensure it was working properly and there were no solder bridges like I had in MVP

Final Wiring on the Protoboard

MicroSD Card Module (VSPI)

Pin ESP32 GPIO
VCC+3.3 V
GNDGND
CSGPIO 5
SCKGPIO 18
MISOGPIO 19
MOSIGPIO 23

MAX98357A I2S Amplifier

Pin ESP32 GPIO
VIN+5 V
GNDGND
DINGPIO 12
BCLKGPIO 13
LRCGPIO 14
Speaker +/-Speaker terminals

KY-040 Rotary Encoder (Volume)

Pin ESP32 GPIO
++3.3 V
GNDGND
CLKGPIO 27
DTGPIO 26
SWGPIO 25

Push Buttons

Button GPIO
MP3 modeGPIO 32
BT modeGPIO 33
Radio modeGPIO 17
PairGPIO 15
MuteGPIO 4
PreviousGPIO 16
NextGPIO 2

OLED 0.96" SSD1306 (I2C)

Pin ESP32 GPIO
VCC+3.3 V
GNDGND
SDAGPIO 21
SCLGPIO 22

TEA5767 FM Module

Pin ESP32 GPIO
VCC+3.3 V or +5 V, module-dependent
GNDGND
SDAGPIO 21, shared with OLED
SCLGPIO 22, shared with OLED
Audio outGPIO 35, ADC1_CHANNEL_7

Powering the Radio

I was interested in making the radio rechargeable, using either a LiPo battery or one that attaches directly to an ESP32 LiPo board, so I tested a few options. From most of the batteries I had on hand, I was getting about 3.3 V on the multimeter, but I needed a way to step that up to 5 V, since some of my components run on 5 V. I went around the lab trying different voltage boosters and converters on the 18650 rechargeable battery module.

Testing the 300 mAh battery from the ESP32 LiPo module
I tried using the 300 mAh battery that the ESP32 LiPo module came with, but it did not provide sufficient voltage

When I tried the HW-432 boost converter, I soldered it onto a TP4056 charging module. But when I tested it on the actual circuit, I started getting smoke from something on my breadboard. I pulled the power, but apparently not quickly enough. My SD card and my 2.4" OLED display became fried and unusable. This was a big lesson for me in being careful with high voltages. I think I hadn't dialed the booster's voltage down enough, and it overpowered the rest of the circuit. From then on, I always used a multimeter to confirm the voltage before connecting anything to the circuit. I became a beast on the multimeter. I also tried another booster that needed a USB-A to bare wire connector, but it only gave me about 3.7 V when I needed 5 V.

First boost converter used for the radio power circuit
The first booster I used, which fried the board because too much voltage was going in
Second booster with USB-A to raw wire converter
The second booster I used needed a USB-A to raw-wire converter, but it only provided 3.7 V, which was not enough for the radio

I eventually realized it might just be easier to power the whole system through a USB-C plug-in, rather than going through a rechargeable battery. So I soldered a USB-C port to the ESP32's ground and power, and found it much easier to just plug in this way.

USB-C port connected to the ESP32 ground and power pins
USB-C port to power the radio (soldered onto power and ground wires!)
Fried OLED display after the boost converter power issue
The 2.4-inch OLED display got fried, so I had to switch to a 0.96-inch OLED display instead

Enclosure

For the final project, I wanted to have a better enclosure than the MVP with something more retro, so I planned to build a wood cover. I created a 2D design in CAD and sketched out a diagram of exactly what I wanted. After measuring every dimension with calipers, I 3D-printed the corresponding parts, including the body, the back panel, and the knob. I also added a few extrusions from each corner so that the wooden cover could easily be glued to the body as well as the back panel.

Sketch for the radio enclosure
My sketch for the radio enclosure prior to building it
Fusion 360 model of the radio body with corner extrusions
Radio body CAD model with corner extrusions for attaching the wooden cover and back panel
Radio body printing on the PRUSA MK4
The body of the radio printing on the PRUSA MK4. It took 12 hours!

Through several iterations, I realized I had to add tolerances for the rotary encoder to fit into the knob. Specifically, 0.2 mm turned out to be the perfect tolerance for press-fitting a knob onto a rotary encoder. I'd initially used zero tolerance, which made it impossible to fit the encoder into the knob at all. I found it empowering to take something I'd sketched on paper and turn it into a real physical product, especially the knob, which I custom-designed myself. I also switched the volume control from a potentiometer to a rotary encoder, which worked much better. It was more precise and didn't fluctate once I set the volume like it did in the MVP.

Radio knob STL
Radio back cover STL
Radio body STL
Potentiometer and rotary encoder used for volume control
I switched from a potentiometer in the MVP to a rotary encoder for more precise volume control
Press-fitting the 3D-printed knob onto the rotary encoder
Press-fitting the 3D-printed knob onto the rotary encoder

I then laser-cut the wooden front panel, going through several iterations to get the dimensions right for the speaker hole and the button holes. I'd initially made circular holes for the buttons, thinking only the yellow part of each button would poke through. That didn't work because the button cap was larger than the hole, at least for these buttons, so instead I switched to square laser-cut holes, pushed the whole square button through, and glued them in place.

Fusion 360 sketch for the laser-cut radio front cover
Fusion 360 sketch for the laser-cut radio front cover

I went around the lab looking for speaker mesh. I found one candidate for the mesh which was a metal grille, but it didn't give off the retro vibe I wanted, so instead I used a piece of dark denim from the fabrics bin as my speaker mesh.

I also didn't know how to hold the USB-C and microSD card adapters in place at the back but I ended up hot gluing a cardboard structure to the back panel so it held the modules right next to the openings.

Cardboard positioning structure for the USB-C and SD card ports
Using cardboard to position the USB-C port and SD card port in the back panel
Back panel of the radio with USB-C power and SD card access
Back panel of the radio with USB-C power and SD card!
Retro radio front cover made with denim and laser-cut wood
I built the retro front cover of the radio using denim and laser-cut wood
Radio front cover with components secured on top with glue
The front cover with the components secured on top of it with glue
Testing out the radio functionality after completing the protoboard and front cover
Messy internals of the radio before the enclosure hides the hardware
The messy internals of the radio. It's amazing how a good enclosure can abstract away all of the inner hardware!

Software Fixes and Final Touches

Filtering Out macOS Metadata Files

There were a lot of bogus files showing up from the SD card on the radio from macOS metadata files, including a phantom track /._song.mp3 that appeared in the playlist. This makes sense because macOS sometimes writes hidden files that start with a . and leaves them on the card. The ._ check inside scanSD(), shown earlier, skips any file whose name starts with ._, so only real music shows up.

void scanSD() {
  trackCount = 0;
  File root = SD.open("/");
  while (trackCount < 32) {
    File entry = root.openNextFile();
    if (!entry) break;
    String name = entry.name();
    if (!entry.isDirectory() && (name.endsWith(".mp3") || name.endsWith(".MP3"))) {
      int slash = name.lastIndexOf('/');
      String basename = (slash >= 0) ? name.substring(slash + 1) : name;
      if (basename.startsWith("._")) {       // <-- macOS metadata filter
        entry.close();
        continue;
      }
      tracks[trackCount++] = name.startsWith("/") ? name : ("/" + name);
    }
    entry.close();
  }
  root.close();
}

Reversing the Volume Encoder Direction

After adding the rotary encoder for volume, I needed to reverse its direction by swapping the ++ and -- in the interrupt handler. I also learned that the KY-040 rotary encoder has internal resistors, so I didn't need to add resistors as I had in earlier weeks.

void IRAM_ATTR onEncoderTurn() {
  unsigned long now = millis();
  if (now - lastEncIRQ < 2) return;       // debounce: ignore IRQs <2ms apart
  lastEncIRQ = now;
  if (digitalRead(ENC_DT) == digitalRead(ENC_CLK)) encoderDelta--;
  else encoderDelta++;
}

Internet Radio Stream Issues

When I was adding more internet radio stations, I realized some stream URLs returned HTTP 301/302 redirects. This is because many commercial stations lock their streams behind their apps, so no public URL exists. iHeartRadio in LA and Boston, for example, doesn't have public streams. Only public radio, like NPR Classical or college radio, reliably offers open MP3 streams. I also added some AAC stations, but they didn't play as smoothly as the MP3-only streams, since they're higher quality and a heavier memory load.

Reflection

After making all these fixes and assembling the whole radio with some hot glue and a few screws, I ended up with a radio I'm really proud of. I learned so much through this process, including what it really means to take an idea from beginning to completion, and it was deeply satisfying to see it all come together at the end.

Moving forward, I would like to look into a few changes, including a chemical solvent transfer onto wood, so I could print images from a toner-based printer directly onto the wood, things like button labels and maybe a logo. I'd also consider designing a custom milled PCB, and swapping the ESP32 with a more computationally powerful microcontroller to handle the memory load of all four audio modes.

Reflecting on this project, I'm so grateful I was able to take this class. As someone who started out with no experience in fabrication, seeing all of my work over the past semester come to fruition is very gratifying. I'm so thankful to the teaching staff. Thank you to Nathan, Bobby, Kassia, and Jessica for their support and constant guidance throughout the course! I couldn't have done anything without your help (like, literally). Thank you PS70. I will miss all of seniors in this class!

Finished retro wooden ESP32 radio
I'm really proud of my work, thank you PS70!

Files

Download Final Project files (.zip)