Music Player Architecture¶
The Music Player app turns the Cyber Fidget into a portable Bluetooth music player. It reads MP3 files from the SD card, decodes them on the ESP32, and streams audio over BT Classic A2DP to a wireless speaker or headphones.
What is this?¶
Think of it like an iPod that connects to a Bluetooth speaker:
- Pair with a BT speaker (scan or pick a remembered device)
- Browse your music library with ID3 metadata (artist, title)
- Play — the ESP32 decodes MP3 in real-time and streams it over Bluetooth
- Control — play/pause, next/prev, shuffle, volume via the physical slider
The player UI runs on the 128x64 OLED with a menu system inspired by the classic iPod click wheel navigation.
State machine¶
The player has 11 states, navigated with Up/Down/Enter/Back:
stateDiagram-v2
[*] --> DEVICE_MENU : No saved devices
[*] --> MAIN_MENU : Has saved devices
DEVICE_MENU --> BT_SCANNING : "Scan for new..."
DEVICE_MENU --> BT_CONNECTING : Select remembered device
BT_SCANNING --> BT_CONNECTING : Select found device
BT_SCANNING --> DEVICE_MENU : Back
BT_CONNECTING --> MAIN_MENU : Connected
BT_CONNECTING --> CONNECT_FAIL : Timeout (15s)
CONNECT_FAIL --> DEVICE_MENU : Any button
MAIN_MENU --> PLAYER : "Now Playing"
MAIN_MENU --> FILE_BROWSER : "Browse Songs"
MAIN_MENU --> BT_SUBMENU : "Bluetooth"
MAIN_MENU --> LED_SUBMENU : "LEDs"
MAIN_MENU --> [*] : "Exit" / Back
BT_SUBMENU --> DEVICE_MENU : "Disconnect"
BT_SUBMENU --> MAIN_MENU : Back
LED_SUBMENU --> MAIN_MENU : Back
FILE_BROWSER --> SCANNING_LIBRARY : First open (no cache)
FILE_BROWSER --> PLAYER : Select track
FILE_BROWSER --> MAIN_MENU : Back
SCANNING_LIBRARY --> FILE_BROWSER : Scan complete
PLAYER --> MAIN_MENU : Back (playback continues)
BT_SWITCHING --> BT_CONNECTING : Settle complete
Back from Now Playing keeps playing
Pressing Back in the player screen returns to the main menu but doesn't stop playback. Music keeps streaming in the background. This matches the iPod behavior — you can browse while listening.
Audio pipeline¶
The audio chain is built from the arduino-audio-tools library:
SD Card (SPI)
│
▼
AudioSourceIdxSD ── scans /music/*.mp3, builds file index
│
▼
AudioPlayer ── manages playback state, drives the pipeline
│
▼
MP3DecoderHelix ── real-time MP3 decoding (libhelix, runs in IRAM)
│
▼
SpectrumAnalyzer ── passthrough providing volumeRatio() + 16-band FFT
│
▼
A2DPStream ── BT Classic A2DP Source → wireless speaker
The pipeline is driven by calling pPlayer->copy() every update() cycle (~50Hz). Each call reads a chunk from SD, decodes MP3 frames, and writes PCM samples to the A2DP buffer. The BT stack's internal callback drains the buffer and transmits over the air.
The SpectrumAnalyzer is a custom ModifyingStream that replaced the original VolumeMeter. It provides both volumeRatio() (0.0–1.0 peak amplitude) for LED reactive effects and bands()[16] (16-band FFT frequency spectrum) for the OLED visualizer. Audio passes through unchanged — zero latency, zero quality impact. See LED Effects & Visualizer for details.
Pipeline objects are never destroyed
The A2DPStream, AudioPlayer, and AudioSourceIdxSD are heap-allocated once and kept alive for the entire app lifetime. Deleting them after active playback causes crashes due to internal FreeRTOS tasks and ring buffers that can't be cleanly shut down. See the BT A2DP Guide for details.
Volume control¶
The physical slider maps to A2DP volume (0-100%). The mapping is inverted: slider fully extended = 0%, fully retracted = 100%. This is updated every update() cycle via updateVolumeFromSlider().
AVRCP media controls (BT speaker buttons)¶
Many Bluetooth speakers and headphones have physical media buttons (play/pause, next, previous). These send AVRCP passthrough commands back to the audio source. The CyberFidget handles these commands so you can control playback from your BT device.
The ESP32 runs dual AVRCP roles simultaneously:
- AVRCP Controller (CT): Sends commands to the speaker (existing, used for volume)
- AVRCP Target (TG): Receives commands from the speaker (added in Phase 4.3)
Supported commands:
| Button | AVRCP Key Code | Action |
|---|---|---|
| Play | ESP_AVRC_PT_CMD_PLAY (0x44) |
Toggle play/pause |
| Pause | ESP_AVRC_PT_CMD_PAUSE (0x46) |
Toggle play/pause |
| Stop | ESP_AVRC_PT_CMD_STOP (0x45) |
Stop playback |
| Next | ESP_AVRC_PT_CMD_FORWARD (0x4B) |
Next track |
| Previous | ESP_AVRC_PT_CMD_BACKWARD (0x4C) |
Previous track |
Thread safety
AVRCP callbacks fire from the BTC FreeRTOS task, not the main Arduino loop. The callback sets a volatile uint8_t pendingAvrcCmd flag, which is consumed and dispatched in update(). This avoids cross-task race conditions on audio pipeline state.
Speaker compatibility
Not all BT speakers send AVRCP commands when buttons are pressed. Some only support A2DP audio without AVRCP at all. The feature is additive — if the speaker doesn't send commands, nothing happens and physical CyberFidget buttons still work normally.
BT device management¶
Scanning¶
The BTScanner class uses raw ESP-IDF GAP APIs (esp_bt_gap_start_discovery()) to find nearby Bluetooth audio devices. Results are filtered by Class of Device (COD) — specifically ESP_BT_COD_SRVC_RENDERING which indicates audio sink capability.
Scan results show device names with signal strength bars (based on RSSI).
Remembered devices¶
Up to 8 paired devices are stored in NVS (Non-Volatile Storage) using the ESP32's Preferences library:
- Namespace:
"btdevs" - Keys:
"count","d0n".."d7n"(names),"d0a".."d7a"(6-byte BT addresses)
On app launch, if remembered devices exist, the player goes straight to the main menu and auto-reconnects to the last used device in the background.
Device switching¶
Switching from Speaker A to Speaker B requires a careful two-phase process:
- Phase 1: Disconnect current device, poll until
isConnected()returns false (3s timeout) - Phase 2: 1 second settle time for BT stack cleanup
- Reconnect: Restore buffer timeouts, call
reconnect(), enter connecting state
A "Switching..." animation plays during this process.
ID3 metadata¶
Library scan¶
On first browse, the app scans all MP3 files for ID3 tags. It checks both formats:
- ID3v2 (at file start): Parse header, look for TIT2 (title) and TPE1 (artist) frames
- ID3v1 (last 128 bytes): Check for "TAG" header, extract fixed-width title/artist fields
Results are cached to /music.idx on the SD card. Subsequent opens load from cache unless the file count changes.
Now Playing metadata¶
During playback, the AudioPlayer fires a metadata callback that captures title and artist from the MP3 stream headers. This updates the Now Playing display in real-time.
Player UI layout¶
The Now Playing screen on the 128x64 OLED:
┌────────────────────────────┐
│ ⚡ Now Playing 87 ▊│ ← Header: BT icon (x=5), battery outline (right-aligned)
│ The Artist Name │ ← Artist (y=8)
│ ♫ Track Title Here ←scroll │ ← Title with marquee (y=19)
│ │
│ ▶ Playing [S] │ ← Status + shuffle indicator (y=31)
│ 1:23 ████████░░░░░ -2:58 │ ← Playhead with elapsed/remaining time (y=50)
└────────────────────────────┘
- Battery: Programmatic outline with percentage text inside. Lightning bolt icon when charging.
- Marquee: Long track titles scroll horizontally.
- Playhead: Progress bar with elapsed (M:SS) and remaining (-M:SS) time. Time estimated from MP3 bitrate read from the first MPEG frame header.
- Volume overlay: When the physical slider is moved, the playhead temporarily shows a volume bar for 2 seconds, then fades back to the playhead.
- Visualizer: When enabled (Up/Down toggle), a 16-bar amplitude graph replaces the playhead. See LED Effects & Visualizer.
Scrubbing (seek within track)¶
Hold the left or right button for 500ms to start scrubbing backward or forward within the current track. The player seeks ~5 seconds worth of MP3 data every 200ms while the button is held.
Tap (quick press and release < 500ms) still triggers previous/next track.
The seek uses the Arduino SD File::seek() on the underlying stream (pPlayer->getStream() cast to File*). The MP3 decoder re-syncs automatically after the jump — there may be a brief audio artifact (~50ms) at the seek point.
Resume playback¶
The player remembers your position when you pause or exit the app. On re-entry, selecting "Now Playing" from the main menu resumes the saved track at the saved byte position.
Resume state is stored in NVS ("mpstate" namespace) with the file path and byte offset. It's cleared when you start a new track.
Sleep prevention¶
The firmware's power manager puts the device to sleep after 60 seconds of inactivity. During music playback, the app resets the interaction timer every update cycle:
if (isPlaying) {
millis_APP_LASTINTERACTION = millis_NOW;
}
This keeps the device awake as long as music is playing, without requiring any button interaction.
File structure¶
lib/MusicPlayerApp/
├── MusicPlayerApp.h — Class definition, state enum, all members
├── MusicPlayerApp.cpp — State machine, audio pipeline, UI rendering (~1600 lines)
├── BTScanner.h / .cpp — ESP-IDF GAP Bluetooth scanner
└── ID3Scanner.h / .cpp — ID3v1/v2 metadata reader + SD cache