Community project

Mini Internet Radio with Alarm Clock

Photo of Mini Internet Radio with Alarm Clock

erikgermany

Published November 10, 2026

This project turns an ESP32 into a connected internet radio with alarm clock functionality. The device streams MP3 audio from internet radio stations through the built-in speaker, stores station presets and alarm settings in persistent memory, and wakes up at scheduled times to play your chosen station.

The guide includes a complete parts list, wiring diagram for the ESP32 and speaker connections, the full Arduino firmware with MP3 decoding and alarm logic, and step-by-step assembly instructions. Builders will configure WiFi credentials, select from preset radio stations, and set alarm times using the device's interface.

Wiring diagram

Loading diagram…

Assemble it in 3 steps

1. Use the built-in hardware

The Grey already contains the screen, three front buttons, and speaker. No extra parts or wires are needed. Leave the expansion sockets empty.

2. Connect USB power

Connect the Grey to your computer with a USB data cable. Use a USB-A to USB-C cable if an older Grey does not power on with a USB-C to USB-C cable. Keep the speaker opening clear.

  • Start with low volume; the small built-in speaker can distort at high levels.

3. Configure the alarm clock

After connecting the radio to Wi-Fi, open the Settings IP address shown on its screen in a browser on the same network. Set your time zone, separate weekday and weekend times, enable the desired alarms, set the alarm volume, and press Save alarms. Both alarms are disabled initially. Settings remain saved after power is removed. The large clock shows local time after Internet time synchronization.

  • Hold B to switch between radio and clock-only standby. Tap B during an alarm to stop it and return to standby. Outside an alarm, tapping B mutes playing radio or resumes radio from standby.
  • The alarm plays the currently selected station through the built-in speaker. Use A and C to choose a station before going to sleep.
  • Keep USB power and Wi-Fi connected overnight. Internet time and a working radio stream are required; there is no offline alarm fallback. Do not use this as your only important wake-up alarm.
  • Time zone defaults to UTC. Select your region before enabling alarms; the listed daylight-saving regions adjust automatically. Hold B during startup to change Wi-Fi.

Deploy the firmware

#include <Arduino.h>
#include <M5Unified.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <AudioOutput.h>
#include <AudioFileSourceICYStream.h>
#include <AudioFileSourceBuffer.h>
#include <AudioGeneratorMP3.h>
#include <atomic>
#include <time.h>

// PCM adapter follows M5Stack's WebRadio example. Buffers remain valid until released.
class SpeakerOutput : public AudioOutput {
  int16_t pcm[3][1024] = {};
  std::atomic<bool> busy[3] = {};
  size_t index=0, used=0;
  static void released(void* arg,const void* data,uint8_t) {
    auto p=static_cast<SpeakerOutput*>(arg);
    for(int i=0;i<3;i++) if(data==p->pcm[i]) p->busy[i]=false;
  }
public:
  void setup() { M5.Speaker.setBufferReleaseCallback(this,released); }
  bool begin() override { return true; }
  bool ConsumeSample(int16_t s[2]) override {
    if(used==1024) { flush(); return false; }
    pcm[index][used++]=s[0]; pcm[index][used++]=s[1]; return true;
  }
  void flush() override {
    if(!used) return;
    busy[index]=true;
    if(!M5.Speaker.playRaw(pcm[index],used,hertz,true,1,0)) busy[index]=false;
    index=(index+1)%3; used=0;
    while(busy[index]) delay(1);
  }
  bool stop() override {
    flush(); for(int i=0;i<3;i++) while(busy[i]) delay(1);
    return true;
  }
} output;
const char* names[]={"Radio Paradise","FIP","FIP Jazz","KEXP","Pink Floyd Radio"};
const char* urls[]={"http://stream.radioparadise.com/mp3-128","http://icecast.radiofrance.fr/fip-midfi.mp3","http://icecast.radiofrance.fr/fipjazz-midfi.mp3","http://kexp-mp3-128.streamguys1.com/kexp128.mp3","http://streaming.exclusive.radio/er/pinkfloyd/icecast.audio"};
constexpr int stationCount=sizeof(names)/sizeof(names[0]);
AudioFileSourceICYStream* stream=nullptr;
AudioFileSourceBuffer* buffer=nullptr;
AudioGeneratorMP3 decoder;
uint8_t netBuffer[16384];
WebServer server(80);
Preferences prefs;
int station=0, volume=48;
bool muted=false, setupMode=false, dirty=true;
bool standby=false, ringing=false;
int weekdayAlarm=420, weekendAlarm=540, alarmVolume=64;
bool weekdayEnabled=false, weekendEnabled=false;
String timezone="UTC0";
int lastAlarmDay=-1;
String timeValue(int minutes) {
  char text[6]; snprintf(text,sizeof(text),"%02d:%02d",minutes/60,minutes%60); return text;
}
void loadAlarms() {
  weekdayAlarm=prefs.getInt("wdtime",420); weekendAlarm=prefs.getInt("wetime",540);
  weekdayEnabled=prefs.getBool("wden",false); weekendEnabled=prefs.getBool("ween",false);
  alarmVolume=prefs.getInt("alvol",64); timezone=prefs.getString("tz","UTC0");
}
void startRadio();
void stopRadio();
void alarmServer() {
  server.on("/",HTTP_GET,[]{
    String page="<html><meta name='viewport' content='width=device-width,initial-scale=1'><h2>Radio alarm clock</h2><form method='POST' action='/alarm'>";
    page+="<p><label><input type='checkbox' name='wd' "+String(weekdayEnabled?"checked":"")+">Monday-Friday</label> <input type='time' name='wt' value='"+timeValue(weekdayAlarm)+"' required></p>";
    page+="<p><label><input type='checkbox' name='we' "+String(weekendEnabled?"checked":"")+">Saturday-Sunday</label> <input type='time' name='et' value='"+timeValue(weekendAlarm)+"' required></p>";
    page+="<p>Alarm volume (1-128): <input type='number' name='v' min='1' max='128' value='"+String(alarmVolume)+"' required></p>";
    page+="<p>Time zone: <select name='tz'>";
    const char* zones[]={"UTC0","GMT0BST,M3.5.0/1,M10.5.0","CET-1CEST,M3.5.0,M10.5.0/3","EST5EDT,M3.2.0,M11.1.0","CST6CDT,M3.2.0,M11.1.0","MST7MDT,M3.2.0,M11.1.0","PST8PDT,M3.2.0,M11.1.0","JST-9","AEST-10AEDT,M10.1.0,M4.1.0/3"};
    const char* labels[]={"UTC","UK","Central Europe","US Eastern","US Central","US Mountain","US Pacific","Japan","Sydney"};
    for(int i=0;i<9;i++) page+="<option value='"+String(zones[i])+"' "+String(timezone==zones[i]?"selected":"")+">"+labels[i]+"</option>";
    page+="</select></p><button>Save alarms</button></form><p>Alarms use the currently selected station. Hold B for clock-only standby; tap B to stop an alarm. Internet and power are required. A failed station cannot sound an alarm.</p></html>";
    server.send(200,"text/html",page);
  });
  server.on("/alarm",HTTP_POST,[]{
    auto parse=[](String t)->int {
      if(t.length()!=5 || t[2]!=':' || !isDigit(t[0]) || !isDigit(t[1]) || !isDigit(t[3]) || !isDigit(t[4])) return -1;
      int h=t.substring(0,2).toInt(), m=t.substring(3).toInt();
      return h<24 && m<60?h*60+m:-1;
    };
    int wd=parse(server.arg("wt")), we=parse(server.arg("et"));
    String tz=server.arg("tz");
    if(wd<0 || we<0 || tz.length()==0 || tz.length()>80) {server.send(400,"text/plain","Invalid settings"); return;}
    weekdayAlarm=wd; weekendAlarm=we; weekdayEnabled=server.hasArg("wd"); weekendEnabled=server.hasArg("we");
    alarmVolume=constrain(server.arg("v").toInt(),1,128); timezone=tz;
    prefs.putInt("wdtime",wd); prefs.putInt("wetime",we);
    prefs.putBool("wden",weekdayEnabled); prefs.putBool("ween",weekendEnabled);
    prefs.putInt("alvol",alarmVolume); prefs.putString("tz",timezone);
    configTzTime(timezone.c_str(),"pool.ntp.org","time.nist.gov");
    dirty=true; server.send(200,"text/html","Saved. <a href='/'>Back to alarms</a>");
  });
  server.begin();
}
void checkAlarm() {
  time_t now=time(nullptr); if(now<1700000000) return;
  struct tm local; localtime_r(&now,&local);
  bool weekend=local.tm_wday==0 || local.tm_wday==6;
  int day=(local.tm_year+1900)*366+local.tm_yday;
  if(lastAlarmDay==day || !(weekend?weekendEnabled:weekdayEnabled)) return;
  if(local.tm_hour*60+local.tm_min!=(weekend?weekendAlarm:weekdayAlarm)) return;
  lastAlarmDay=day; standby=false; ringing=true; muted=false; volume=alarmVolume;
  M5.Speaker.setVolume(volume); dirty=true; startRadio();
}
String statusText="Connecting Wi-Fi";
uint32_t retryAt=0;
void status(const String& s) { if(s!=statusText) {statusText=s; dirty=true;} }
// Fixed portrait layout: every region fits inside the 240 x 320 display.
constexpr uint16_t face=0x0841, panel=0x10C3, accent=0x4E7F, subtle=0x9CD3;
String displayedTime;
M5Canvas clockCanvas(&M5.Display);
int clockCanvasHeight=0;
void centered(const String& text, int y, int size, uint16_t color) {
  M5.Display.setTextSize(size);
  M5.Display.setTextColor(color,face);
  int x=(M5.Display.width()-int(text.length())*6*size)/2;
  M5.Display.setCursor(x<0?0:x,y);
  M5.Display.print(text);
}
void digit(int x,int y,char c) {
  static const uint8_t masks[]={0x3F,0x06,0x5B,0x4F,0x66,0x6D,0x7D,0x07,0x7F,0x6F};
  uint8_t m=(c>='0' && c<='9')?masks[c-'0']:0x40;
  const int seg[7][4]={{6,0,28,6},{34,6,6,25},{34,37,6,25},
                        {6,62,28,6},{0,37,6,25},{0,6,6,25},{6,31,28,6}};
  for(int i=0;i<7;i++) if(m&(1<<i))
    clockCanvas.fillRect(x+seg[i][0],y+seg[i][1],seg[i][2],seg[i][3],accent);
}
void clockScreen() {
  time_t now=time(nullptr);
  char text[6]="--:--", date[32]="Waiting for network time";
  if(now>1700000000) {
    struct tm local; localtime_r(&now,&local);
    strftime(text,sizeof(text),"%H:%M",&local);
    strftime(date,sizeof(date),"%a, %d %b %Y",&local);
  }
  String key=String(text)+date;
  if(displayedTime==key) return;
  const int height=standby?164:104;
  if(clockCanvasHeight!=height) {
    clockCanvas.deleteSprite();
    clockCanvas.setColorDepth(8);
    if(!clockCanvas.createSprite(M5.Display.width(),height)) return;
    clockCanvasHeight=height;
  }
  // Erase and compose in RAM, never erase visible digits between frames.
  clockCanvas.fillSprite(face);
  auto label=[&](const String& value,int y,int size,uint16_t color) {
    clockCanvas.setTextSize(size);
    clockCanvas.setTextColor(color,face);
    clockCanvas.setCursor((clockCanvas.width()-int(value.length())*6*size)/2,y);
    clockCanvas.print(value);
  };
  if(standby) {
    char day[16]="", calendar[32]="Waiting for network time";
    if(now>1700000000) {
      struct tm local; localtime_r(&now,&local);
      strftime(day,sizeof(day),"%A",&local);
      strftime(calendar,sizeof(calendar),"%d %B %Y",&local);
    }
    label(day,8,2,accent);
    label(calendar,40,1,subtle);
  } else {
    label(date,2,1,subtle);
    label("LOCAL TIME  /  24 HOUR",97,1,subtle);
  }
  const int x=(clockCanvas.width()-188)/2, y=standby?76:24;
  digit(x,y,text[0]); digit(x+46,y,text[1]);
  clockCanvas.fillRect(x+91,y+20,6,6,accent);
  clockCanvas.fillRect(x+91,y+43,6,6,accent);
  digit(x+102,y,text[3]); digit(x+148,y,text[4]);
  clockCanvas.pushSprite(0,standby?88:30);
  displayedTime=key;
}
void screen() {
  if(dirty) {
    dirty=false; displayedTime="";
    M5.Display.fillScreen(face);
    if(standby && !setupMode) {clockScreen(); return;}
    centered("RADIO / ALARM CLOCK",10,1,accent);
    M5.Display.drawFastHLine(4,25,M5.Display.width()-8,panel);
    if(setupMode) {
      centered("WI-FI SETUP",52,2,TFT_WHITE);
      centered("1. Join this Wi-Fi network",96,1,subtle);
      centered("M5-Radio-Setup",117,2,accent);
      centered("Password: radio1234",148,1,TFT_WHITE);
      centered("2. Open in your browser",191,1,subtle);
      centered("192.168.4.1",214,2,accent);
      centered("3. Save your home Wi-Fi",257,1,subtle);
      centered("Use a 2.4 GHz network",279,1,subtle);
      return;
    }
    centered(names[station],146,2,TFT_WHITE);
    String state=ringing?"ALARM - TAP B TO STOP":(standby?"CLOCK STANDBY":statusText);
    centered(state,169,1,ringing?TFT_YELLOW:subtle);
    centered(muted?"SOUND MUTED":String("VOLUME ")+String(volume)+" / 128",187,1,accent);
    M5.Display.drawFastHLine(4,204,M5.Display.width()-8,panel);
    // Center the full 33-character row, with equal spacing around the divider.
    const int rowX=(M5.Display.width()-33*6)/2;
    M5.Display.fillRoundRect(4,210,M5.Display.width()-8,57,4,panel);
    M5.Display.setTextSize(1);
    M5.Display.setTextColor(TFT_WHITE,panel);
    M5.Display.setCursor(rowX,224);
    M5.Display.print("MON - FRI ");
    M5.Display.setTextColor(weekdayEnabled?accent:subtle,panel);
    M5.Display.print(timeValue(weekdayAlarm));
    M5.Display.setTextColor(subtle,panel);
    M5.Display.print(" | ");
    M5.Display.setTextColor(TFT_WHITE,panel);
    M5.Display.print("SAT - SUN ");
    M5.Display.setTextColor(weekendEnabled?accent:subtle,panel);
    M5.Display.print(timeValue(weekendAlarm));
    const String weekdayState=weekdayEnabled?"ALARM ON":"ALARM OFF";
    const String weekendState=weekendEnabled?"ALARM ON":"ALARM OFF";
    M5.Display.setCursor(rowX+45-int(weekdayState.length())*3,248);
    M5.Display.setTextColor(weekdayEnabled?accent:subtle,panel);
    M5.Display.print(weekdayState);
    M5.Display.setCursor(rowX+153-int(weekendState.length())*3,248);
    M5.Display.setTextColor(weekendEnabled?accent:subtle,panel);
    M5.Display.print(weekendState);
    centered(String("Setup: http://")+WiFi.localIP().toString(),273,1,subtle);
    centered("A: Previous  B: Mute  C: Next",286,1,TFT_WHITE);
    centered("Hold A/C: Volume",299,1,subtle);
    centered("Hold B: Standby / Radio",311,1,subtle);
  }
  if(!setupMode) clockScreen();
}
void stopRadio() {
  if(decoder.isRunning()) decoder.stop();
  if(buffer) {delete buffer; buffer=nullptr;}
  if(stream) {delete stream; stream=nullptr;}
}
void startRadio() {
  stopRadio(); status("Opening stream..."); screen();
  stream=new AudioFileSourceICYStream(urls[station]);
  buffer=new AudioFileSourceBuffer(stream,netBuffer,sizeof(netBuffer));
  if(stream->isOpen() && decoder.begin(buffer,&output)) status("Playing");
  else {stopRadio(); status("Unavailable; retrying"); retryAt=millis()+5000;}
}
void wifiSetup() {
  setupMode=true;
  WiFi.mode(WIFI_AP); WiFi.softAP("M5-Radio-Setup","radio1234");
  server.on("/",HTTP_GET,[]{server.send(200,"text/html","<h2>M5 Radio Wi-Fi</h2><form method='POST' action='/save'>Network name (2.4 GHz):<br><input name='s' maxlength='32' required><br>Password:<br><input type='password' name='p' maxlength='63'><br><button>Save and restart</button></form>");});
  server.on("/save",HTTP_POST,[]{
    if(server.arg("s").isEmpty()) {server.send(400,"text/plain","Network name required"); return;}
    prefs.putString("ssid",server.arg("s")); prefs.putString("pass",server.arg("p"));
    server.send(200,"text/plain","Saved. Rejoin your normal Wi-Fi. Radio restarting."); delay(700); ESP.restart();
  }); server.begin(); dirty=true;
}
void setup() {
  auto cfg=M5.config(); cfg.internal_spk=true; cfg.internal_imu=false; cfg.internal_rtc=false;
  M5.begin(cfg); M5.Display.setRotation(0); M5.Display.setBrightness(100);
  output.setup(); M5.Speaker.begin(); M5.Speaker.setVolume(volume);
  prefs.begin("radio",false); loadAlarms();
  M5.update(); bool resetWifi=M5.BtnB.isPressed();
  String ssid=prefs.getString("ssid","");
  if(resetWifi || ssid.isEmpty()) {wifiSetup(); screen(); return;}
  screen(); WiFi.mode(WIFI_STA); WiFi.setSleep(false);
  WiFi.begin(ssid.c_str(),prefs.getString("pass","").c_str());
  uint32_t start=millis();
  while(WiFi.status()!=WL_CONNECTED && millis()-start<20000) {M5.update(); delay(20);}
  if(WiFi.status()!=WL_CONNECTED) {wifiSetup(); screen(); return;}
  configTzTime(timezone.c_str(),"pool.ntp.org","time.nist.gov");
  alarmServer();
  startRadio();
}
void loop() {
  M5.update();
  if(setupMode) {server.handleClient(); screen(); delay(2); return;}
  server.handleClient(); checkAlarm();
  bool changed=false;
  if(M5.BtnB.wasHold()) {
    standby=!standby; ringing=false; dirty=true;
    if(standby) stopRadio(); else {muted=false; startRadio();}
  }
  if(M5.BtnA.wasClicked()) {station=(station+stationCount-1)%stationCount; changed=true;}
  if(M5.BtnC.wasClicked()) {station=(station+1)%stationCount; changed=true;}
  if(M5.BtnB.wasClicked()) {
    if(ringing) {ringing=false; standby=true; stopRadio();}
    else if(standby) {standby=false; muted=false; startRadio();}
    else muted=!muted;
    dirty=true;
  }
  if(M5.BtnA.pressedFor(500) || M5.BtnC.pressedFor(500)) {
    static uint32_t last=0;
    if(millis()-last>150) {last=millis(); volume=constrain(volume+(M5.BtnC.isPressed()?4:-4),0,128); dirty=true;}
  }
  M5.Speaker.setVolume(muted?0:volume);
  if(WiFi.status()!=WL_CONNECTED) {
    stopRadio(); status("Wi-Fi reconnecting");
    if((int32_t)(millis()-retryAt)>=0) {WiFi.reconnect(); retryAt=millis()+10000;}
  } else if(standby) { /* Clock and alarms remain active. */ }
  else if(changed) startRadio();
  else if(decoder.isRunning()) {
    if(!decoder.loop()) {stopRadio(); status("Stream lost; retrying"); retryAt=millis()+5000;}
  } else if((int32_t)(millis()-retryAt)>=0) startRadio();
  screen(); delay(1);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace, diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik