An iPod-inspired pocket music player that streams WAV files from an SD card to AirPods via Bluetooth, featuring an OLED menu system, volume control, sleep mode and song selection.
Devices and components
Grove - 0.96" OLED display
Push button
Software and tools
Arduino IDE
Project description
Unlike traditional ESP32-CAM projects, the camera module is not used. Instead, the built-in microSD interface and Bluetooth capabilities are used to create a compact portable music player with a menu-driven user interface.
Play/Pause music playback
Three-level software volume control (low, medium, high)
OLED sleep mode to save power
Return to main song menu
a2dp_source.start("AirPods Pro", get_data_frames);
//change "AirPods Pro" to device you want to connect to
1#include "Arduino.h"
2#include "BluetoothA2DPSource.h"
3#include "SD_MMC.h"
4#include "FS.h"
5#include <Wire.h>
6#include <Adafruit_GFX.h>
7#include <Adafruit_SSD1306.h>
8
9// ─── Pin Config ────────────────────────────────────────────
10#define OLED_SDA 1
11#define OLED_SCL 3
12#define BTN_SCROLL 12
13#define BTN_PLAYSTOP 13
14
15// ─── OLED ──────────────────────────────────────────────────
16#define SCREEN_WIDTH 128
17#define SCREEN_HEIGHT 64
18#define OLED_ADDR 0x3C
19Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
20
21// ─── A2DP ──────────────────────────────────────────────────
22BluetoothA2DPSource a2dp_source;
23
24// ─── State ─────────────────────────────────────────────────
25#define MAX_SONGS 50
26#define VISIBLE_ITEMS 4
27
28char songList[MAX_SONGS][64];
29int songCount = 0;
30int selectedIndex = 0;
31int playingIndex = -1;
32volatile bool isPlaying = false;
33bool btConnected = false;
34bool screenOn = true;
35bool inMenuScreen = true;
36
37File wavFile;
38volatile bool fileOpen = false;
39
40// ─── Volume ────────────────────────────────────────────────
41// 0 = low (25%), 1 = medium (60%), 2 = high (100%)
42volatile int volumeLevel = 2;
43const float volumeTable[3] = { 0.25f, 0.60f, 1.0f };
44const char* volumeLabels[3] = { "Low", "Medium", "High" };
45
46// ─── Now Playing menu ──────────────────────────────────────
47// Options: 0=Volume, 1=Sleep, 2=Play/Pause, 3=Back
48#define NP_OPT_COUNT 4
49#define NP_OPT_VOLUME 0
50#define NP_OPT_SLEEP 1
51#define NP_OPT_PLAYPAUSE 2
52#define NP_OPT_BACK 3
53int npSelectedOpt = 0; // currently highlighted option
54
55// ─── Debounce ──────────────────────────────────────────────
56unsigned long lastScrollPress = 0;
57unsigned long lastPlayPress = 0;
58const unsigned long DEBOUNCE_MS = 300;
59
60// ─── BT ────────────────────────────────────────────────────
61unsigned long btConnectedTime = 0;
62bool showingBTMsg = false;
63const unsigned long BT_MSG_DURATION = 3000;
64
65// ─── Menu scroll ───────────────────────────────────────────
66int menuScrollOffset = 0;
67
68// ─── Forward declarations ───────────────────────────────────
69void drawMenu();
70void drawNowPlaying();
71void drawBTConnecting();
72void openAndPlaySong(int index);
73void stopSong();
74bool loadSongList();
75
76// ─── WAV streaming callback ─────────────────────────────────
77int32_t get_data_frames(Frame* frames, int32_t frame_count) {
78 if (!btConnected) {
79 btConnected = true;
80 showingBTMsg = true;
81 btConnectedTime = millis();
82 }
83
84 if (!isPlaying || !fileOpen) {
85 memset(frames, 0, frame_count * sizeof(Frame));
86 return frame_count;
87 }
88
89 int32_t bytes_needed = frame_count * sizeof(Frame);
90 int32_t bytes_read = wavFile.read((uint8_t*)frames, bytes_needed);
91
92 if (bytes_read <= 0) {
93 isPlaying = false;
94 fileOpen = false;
95 wavFile.close();
96 playingIndex = -1;
97 inMenuScreen = true;
98 memset(frames, 0, frame_count * sizeof(Frame));
99 return frame_count;
100 }
101
102 if (bytes_read < bytes_needed)
103 memset((uint8_t*)frames + bytes_read, 0, bytes_needed - bytes_read);
104
105 // ─── Apply software volume ────────────────────────────────
106 float vol = volumeTable[volumeLevel];
107 if (vol < 1.0f) {
108 int16_t* samples = (int16_t*)frames;
109 int count = frame_count * 2; // stereo L+R
110 for (int i = 0; i < count; i++)
111 samples[i] = (int16_t)(samples[i] * vol);
112 }
113
114 return frame_count;
115}
116
117// ─── BT callback ───────────────────────────────────────────
118void bt_connected_cb(esp_a2d_connection_state_t state, void* ptr) {
119 if (state == ESP_A2D_CONNECTION_STATE_CONNECTED) {
120 btConnected = true;
121 showingBTMsg = true;
122 btConnectedTime = millis();
123 } else if (state == ESP_A2D_CONNECTION_STATE_DISCONNECTED) {
124 btConnected = false;
125 showingBTMsg = false;
126 }
127}
128
129// ─── Load song list ─────────────────────────────────────────
130bool loadSongList() {
131 File root = SD_MMC.open("/");
132 if (!root || !root.isDirectory()) return false;
133 songCount = 0;
134 File entry = root.openNextFile();
135 while (entry && songCount < MAX_SONGS) {
136 if (!entry.isDirectory()) {
137 String name = entry.name();
138 String nameUpper = name;
139 nameUpper.toUpperCase();
140 if (nameUpper.endsWith(".WAV")) {
141 String orig = entry.name();
142 if (orig.startsWith("/")) orig = orig.substring(1);
143 orig.toCharArray(songList[songCount], 64);
144 songCount++;
145 }
146 }
147 entry = root.openNextFile();
148 }
149 root.close();
150 return songCount > 0;
151}
152
153// ─── Open and play ──────────────────────────────────────────
154void openAndPlaySong(int index) {
155 if (fileOpen) { wavFile.close(); fileOpen = false; }
156 char path[70];
157 snprintf(path, sizeof(path), "/%s", songList[index]);
158 wavFile = SD_MMC.open(path);
159 if (!wavFile) return;
160 wavFile.seek(44);
161 fileOpen = true;
162 isPlaying = true;
163 playingIndex = index;
164 inMenuScreen = false;
165 npSelectedOpt = 0; // reset option highlight
166}
167
168// ─── Stop song ──────────────────────────────────────────────
169void stopSong() {
170 isPlaying = false;
171 if (fileOpen) { wavFile.close(); fileOpen = false; }
172 playingIndex = -1;
173 inMenuScreen = true;
174}
175
176// ─── Draw: BT Connecting ────────────────────────────────────
177void drawBTConnecting() {
178 display.clearDisplay();
179 display.fillRect(0, 0, 128, 12, SSD1306_WHITE);
180 display.setTextColor(SSD1306_BLACK);
181 display.setTextSize(1);
182 display.setCursor(4, 2);
183 display.print("mini iPod");
184 display.setTextColor(SSD1306_WHITE);
185
186 if (showingBTMsg && btConnected) {
187 display.setCursor(28, 22); display.print("AirPods");
188 display.setCursor(22, 34); display.print("Connected!");
189 display.drawLine(48, 50, 54, 57, SSD1306_WHITE);
190 display.drawLine(54, 57, 68, 43, SSD1306_WHITE);
191 display.drawLine(49, 50, 55, 57, SSD1306_WHITE);
192 display.drawLine(55, 57, 69, 43, SSD1306_WHITE);
193 } else {
194 display.setCursor(14, 22); display.print("Searching for");
195 display.setCursor(22, 34); display.print("AirPods");
196 int dots = (millis() / 500) % 4;
197 for (int i = 0; i < dots; i++) display.print(".");
198 }
199 display.display();
200}
201
202// ─── Draw: Menu ─────────────────────────────────────────────
203void drawMenu() {
204 display.clearDisplay();
205 display.fillRect(0, 0, 128, 12, SSD1306_WHITE);
206 display.setTextColor(SSD1306_BLACK);
207 display.setTextSize(1);
208 display.setCursor(4, 2);
209 display.print("Songs");
210 char countStr[8];
211 snprintf(countStr, sizeof(countStr), "%d", songCount);
212 display.setCursor(128 - strlen(countStr)*6 - 4, 2);
213 display.print(countStr);
214 display.setTextColor(SSD1306_WHITE);
215
216 for (int i = 0; i < VISIBLE_ITEMS; i++) {
217 int songIdx = menuScrollOffset + i;
218 if (songIdx >= songCount) break;
219 int y = 14 + i * 13;
220 bool isSel = (songIdx == selectedIndex);
221 if (isSel) {
222 display.fillRect(0, y - 1, 128, 13, SSD1306_WHITE);
223 display.setTextColor(SSD1306_BLACK);
224 } else {
225 display.setTextColor(SSD1306_WHITE);
226 }
227 String name = String(songList[songIdx]);
228 int dotPos = name.lastIndexOf('.');
229 if (dotPos > 0) name = name.substring(0, dotPos);
230 if (name.length() > 18) name = name.substring(0, 17) + "~";
231 display.setCursor(4, y + 1);
232 display.print(name);
233 if (isSel) { display.setCursor(118, y + 1); display.print(">"); }
234 display.setTextColor(SSD1306_WHITE);
235 }
236
237 if (songCount > VISIBLE_ITEMS) {
238 int barHeight = max(4, (int)(50 * VISIBLE_ITEMS / songCount));
239 int barY = 14 + (50 - barHeight) * menuScrollOffset / (songCount - VISIBLE_ITEMS);
240 display.drawRect(124, 14, 4, 50, SSD1306_WHITE);
241 display.fillRect(125, barY, 2, barHeight, SSD1306_WHITE);
242 }
243 display.display();
244}
245
246// ─── Draw: Now Playing ──────────────────────────────────────
247void drawNowPlaying() {
248 display.clearDisplay();
249
250 // ── Top bar ──
251 display.fillRect(0, 0, 128, 12, SSD1306_WHITE);
252 display.setTextColor(SSD1306_BLACK);
253 display.setTextSize(1);
254 display.setCursor(4, 2);
255 display.print("Now Playing");
256 display.setTextColor(SSD1306_WHITE);
257
258 // ── Song name (top area) ──
259 if (playingIndex >= 0) {
260 String name = String(songList[playingIndex]);
261 int dotPos = name.lastIndexOf('.');
262 if (dotPos > 0) name = name.substring(0, dotPos);
263 if (name.length() > 21) name = name.substring(0, 20) + "~";
264 display.setTextSize(1);
265 display.setCursor(4, 14);
266 display.print(name);
267 }
268
269 // ── Divider ──
270 display.drawLine(0, 24, 128, 24, SSD1306_WHITE);
271
272 // ── 4 options, each 9px tall starting at y=26 ──
273 const char* optLabels[NP_OPT_COUNT] = {
274 "Volume",
275 "Sleep",
276 isPlaying ? "Pause" : "Play",
277 "Back"
278 };
279
280 // Sub-labels (shown next to Volume and Play/Pause)
281 for (int i = 0; i < NP_OPT_COUNT; i++) {
282 int y = 26 + i * 9;
283 bool isSel = (i == npSelectedOpt);
284
285 if (isSel) {
286 display.fillRect(0, y - 1, 128, 10, SSD1306_WHITE);
287 display.setTextColor(SSD1306_BLACK);
288 } else {
289 display.setTextColor(SSD1306_WHITE);
290 }
291
292 display.setCursor(6, y);
293 display.print(optLabels[i]);
294
295 // Show current value as hint on right side
296 if (i == NP_OPT_VOLUME) {
297 String hint = String(volumeLabels[volumeLevel]);
298 display.setCursor(128 - hint.length()*6 - 4, y);
299 display.print(hint);
300 } else if (i == NP_OPT_SLEEP) {
301 display.setCursor(92, y);
302 display.print("Off");
303 }
304
305 display.setTextColor(SSD1306_WHITE);
306 }
307
308 display.display();
309}
310
311// ─── Setup ──────────────────────────────────────────────────
312void setup() {
313 pinMode(BTN_SCROLL, INPUT_PULLUP);
314 pinMode(BTN_PLAYSTOP, INPUT_PULLUP);
315
316 Wire.begin(OLED_SDA, OLED_SCL);
317 if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR))
318 while (true) delay(1000);
319
320 display.clearDisplay();
321 display.display();
322
323 if (!SD_MMC.begin("/sdcard", true)) {
324 display.clearDisplay();
325 display.setTextColor(SSD1306_WHITE);
326 display.setCursor(10, 24);
327 display.print("SD Card failed!");
328 display.display();
329 while (true) delay(1000);
330 }
331
332 if (!loadSongList()) {
333 display.clearDisplay();
334 display.setTextColor(SSD1306_WHITE);
335 display.setCursor(10, 24);
336 display.print("No WAV files!");
337 display.display();
338 while (true) delay(1000);
339 }
340
341 a2dp_source.set_on_connection_state_changed(bt_connected_cb);
342 //change "AirPods Pro" to device you want to connect
343 a2dp_source.start("AirPods Pro", get_data_frames);
344}
345
346// ─── Loop ───────────────────────────────────────────────────
347void loop() {
348 unsigned long now = millis();
349
350 // ── BTN_SCROLL ──────────────────────────────────────────
351 if (digitalRead(BTN_SCROLL) == LOW && now - lastScrollPress > DEBOUNCE_MS) {
352 lastScrollPress = now;
353
354 if (inMenuScreen) {
355 // Scroll song list, wrap around
356 selectedIndex = (selectedIndex + 1) % songCount;
357 if (selectedIndex < menuScrollOffset)
358 menuScrollOffset = selectedIndex;
359 else if (selectedIndex >= menuScrollOffset + VISIBLE_ITEMS)
360 menuScrollOffset = selectedIndex - VISIBLE_ITEMS + 1;
361
362 } else {
363 // Now Playing: cycle through the 4 options
364 npSelectedOpt = (npSelectedOpt + 1) % NP_OPT_COUNT;
365 }
366 }
367
368 // ── BTN_PLAYSTOP ────────────────────────────────────────
369 if (digitalRead(BTN_PLAYSTOP) == LOW && now - lastPlayPress > DEBOUNCE_MS) {
370 lastPlayPress = now;
371
372 if (inMenuScreen) {
373 // Select & play song
374 openAndPlaySong(selectedIndex);
375 if (!screenOn) {
376 display.ssd1306_command(SSD1306_DISPLAYON);
377 screenOn = true;
378 }
379
380 } else {
381 // Execute whichever option is highlighted
382 switch (npSelectedOpt) {
383
384 case NP_OPT_VOLUME:
385 // Cycle Low → Medium → High → Low
386 volumeLevel = (volumeLevel + 1) % 3;
387 break;
388
389 case NP_OPT_SLEEP:
390 // Toggle screen
391 if (screenOn) {
392 display.ssd1306_command(SSD1306_DISPLAYOFF);
393 screenOn = false;
394 } else {
395 display.ssd1306_command(SSD1306_DISPLAYON);
396 screenOn = true;
397 }
398 break;
399
400 case NP_OPT_PLAYPAUSE:
401 // Toggle play/pause
402 if (fileOpen) isPlaying = !isPlaying;
403 break;
404
405 case NP_OPT_BACK:
406 // Stop song, return to menu
407 stopSong();
408 npSelectedOpt = 0;
409 if (!screenOn) {
410 display.ssd1306_command(SSD1306_DISPLAYON);
411 screenOn = true;
412 }
413 break;
414 }
415 }
416 }
417
418 // ── BT message timeout ──────────────────────────────────
419 if (showingBTMsg && now - btConnectedTime > BT_MSG_DURATION)
420 showingBTMsg = false;
421
422 // ── Display update ───────────────────────────────────────
423 if (screenOn) {
424 if (!btConnected || showingBTMsg)
425 drawBTConnecting();
426 else if (inMenuScreen)
427 drawMenu();
428 else
429 drawNowPlaying();
430 }
431
432 delay(50);
433}
Note: Content and images are from: https://projecthub.arduino.cc/, with some modifications.
If you want it removed due to copyright reasons, please leave a comment. Thank you.
I want to share this article more widely so that everyone knows about Arduino and your project.