This is an old revision of the document!
The project consists of creating a portable MP3 Player capable of playing audio files from a microSD card and displaying current track information on an OLED screen. Music is listened to through headphones connected to the auxiliary output.
The project integrates the following hardware modules:
“We believe that using a dedicated hardware decoding module (DFPlayer) will improve audio system performance because it offloads the ATmega328P microcontroller from the intensive task of processing the MP3 data stream, allowing for a smooth visual interface on the OLED screen.”
The electrical schematic was designed in KiCad, ensuring a modular and portable design.
| Component | Arduino Pin | Connection Type |
|---|---|---|
| OLED Display | A4 (SDA), A5 (SCL) | I2C (Hardware TWI) |
| DFPlayer Mini | D8 (TX), D9 (RX) | UART (9600 bps) |
| Buttons | D2, D3, D4 | Digital Input (Interrupts) |
| Audio Jack | DAC_R, DAC_L, GND | Analog Audio |
| Power Switch | VIN | Power Control |
The firmware is written entirely in bare-metal C with no external Arduino libraries. All peripheral control is done through direct manipulation of ATmega328P hardware registers (TWBR, TWCR, TWDR, DDRD, PORTD, PIND). The project is built with PlatformIO using the AVR-GCC toolchain.
Key metrics:
src/main.cpp)
| Component | Arduino Pin | Register | Type |
|---|---|---|---|
| OLED Display | A4 (SDA), A5 (SCL) | TWBR,TWCR,TWDR | I2C Hardware TWI |
| DFPlayer RX | D2 (PD2) | PORTD bit 2 | Software UART TX (bit-bang) |
| DFPlayer TX | D3 (PD3) | PIND bit 3 | Software UART RX (bit-bang) |
| Button PREV/VOL- | D4 (PD4) | PIND bit 4 | Digital Input, pull-up |
| Button PLAY/PAUSE | D5 (PD5) | PIND bit 5 | Digital Input, pull-up |
| Button NEXT/VOL+ | D6 (PD6) | PIND bit 6 | Digital Input, pull-up |
| Audio Jack | DAC_R, DAC_L | — | Analog (DFPlayer direct) |
The main loop (~110ms/iteration) drives a three-state UI machine:
| State | ui_mode | Enter trigger | Exit trigger |
|---|---|---|---|
| Track Mode | 0 | Default / PLAY in vol / timeout | — |
| Volume Mode | 1 | Long-press PREV or NEXT (>880ms) | Short PLAY or 5s inactivity |
| Track Select | 2 | Long-press PLAY | Short/long PLAY confirms track |
In each state the OLED shows different content. Transitions are triggered exclusively by button events or a countdown timer (vol_timer), with no hardware interrupts — the entire logic is polling-based inside the main while(1) loop.
The DFPlayer Mini communicates at 9600 baud using 10-byte packets:
0x7E 0xFF 0x06 CMD 0x00 P1 P2 CHK_H CHK_L 0xEF
A bit-bang TX function drives the PD2 line manually, respecting the 104µs bit period:
static void suart_tx_byte(uint8_t b) { SOFT_PORT &= ~(1 << SOFT_TX_PIN); // start bit _delay_us(104); for (uint8_t i = 0; i < 8; i++) { if (b & 0x01) SOFT_PORT |= (1 << SOFT_TX_PIN); else SOFT_PORT &= ~(1 << SOFT_TX_PIN); _delay_us(104); b >>= 1; } SOFT_PORT |= (1 << SOFT_TX_PIN); // stop bit _delay_us(104); }
A matching RX function reads PD3 with a timeout, used at startup to query the total number of tracks on the SD card (command 0x48). If the DFPlayer does not respond, the firmware falls back to a compile-time constant (MAX_TRACKS_FALLBACK = 6).
Commands used at runtime:
| Command | Hex | Description |
|---|---|---|
| Reset | 0x0C | Hardware reset on startup |
| Volume | 0x06 | Set volume level (0–30) |
| Play | 0x03 | Play specific track by number |
| Pause | 0x0E | Pause playback |
| Resume | 0x0D | Resume playback |
| Query | 0x48 | Read total file count from SD |
A 1024-byte framebuffer (oled_buf[1024]) mirrors the 128×64 display in RAM
(8 pages × 128 columns, 1 bit per pixel). Each frame follows this sequence:
memset(oled_buf, 0, 1024) — clear the bufferdraw_ui() — render current UI state into the bufferoled_flush() — push the full buffer to the OLED via I2CTwo rendering primitives exist with an important constraint:
oled_char(col, page, c) — writes a 5×8 font glyph using byte assignment (=)draw_pixel(x, y, color) — sets/clears one pixel using bitwise OR ( |= )
oled_char uses = which overwrites the entire page byte, erasing any pixels
set by draw_pixel in the same column. The screen layout is designed so that
each page is used exclusively by text OR by pixels, never both simultaneously.
The font is a 42-entry 5×8 bitmap array stored in PROGMEM (Flash), covering
digits 0–9, letters A–Z, and special glyphs for :, -, %, ▶ (\\x01), ⏸ (\\x02).
To make the track number and volume level visually prominent, a draw_big_char()
function scales every 5×8 glyph to 10×16 pixels by replacing each source pixel
with a 2×2 block:
static void draw_big_char(uint8_t x, uint8_t y_top, char c) { // resolve font index for c ... for (uint8_t col = 0; col < 5; col++) { uint8_t bits = pgm_read_byte(&font5x8[idx][col]); for (uint8_t row = 0; row < 8; row++) { if (bits & (1 << row)) { draw_pixel(x + col*2, y_top + row*2, 1); draw_pixel(x + col*2 + 1, y_top + row*2, 1); draw_pixel(x + col*2, y_top + row*2 + 1, 1); draw_pixel(x + col*2 + 1, y_top + row*2 + 1, 1); } } } }
In track mode this renders ⏸ 01 (icon + two digits) centered in the top 16px.
In volume mode it renders the current level 05 centered in the same area.
15 bars are animated each frame using a smooth target-following algorithm:
This produces a natural-looking equalizer effect without any real audio analysis.
Each button uses a hold counter incremented every ~110ms loop iteration:
A prev_long / next_long flag is set on long-press activation to prevent
the short-press action from also firing when the button is released.
Volume is stored as an integer 0–10. The DFPlayer always receives vol_level × 3
(mapping 0–10 → 0–30, which is the DFPlayer's native 0–30 range).
Track Mode: y=0–15 (pages 0–1): ⏸/▶ 01 ← 16px big characters (draw_pixel only) y=16: ──────── ← separator line y=17–47 (pages 2–5): spectrum ← 15 bars × 5px wide, 7px spacing y=47: ──────── ← baseline y=48–55 (page 6): 00:23 ← elapsed time (oled_char only) y=62–63 (page 7): ▓▓▓░░░ ← 2px volume fill bar (draw_pixel only) Volume Mode: y=0–15 (pages 0–1): 05 ← big vol number (draw_pixel only) y=16–39 (pages 2–4): [▓▓▓░░░] ← horizontal fill bar with border y=40–47 (page 5): VOLUME ← label (oled_char only) y=62–63 (page 7): ▓▓▓░░░ ← same 2px volume indicator Track Select: page 0: SELECT TRACK pages 1–6: ▶ 01 / 02 / ... / 06 ← cursor + track number list page 7: PLAY=CONFIRM