This project involved the design and implementation of a bare-metal DIY MP3 player on the RT-Thread RT-Spark development board, built around the STM32F407ZGT6 microcontroller. Musical tones are synthesized in software and streamed to the on-board ES8388 audio codec via the I2S3 peripheral using DMA in circular mode. Eight songs were encoded as structured note arrays, navigable through three GPIO interrupt-driven buttons. Playback state, track information, and button usage instructions appear on the 240x240 ST7789 LCD, driven through FSMC in 8-bit parallel mode. No RTOS was used. Instead, DMA transfer callbacks handle real-time audio buffer filling, EXTI callbacks handle button events, and the main loop handles LCD redraws through a volatile flag. The approach was chosen specifically to keep audio timing deterministic and avoid scheduler overhead.
Design and TestingConcept and System Overview
The system works by generating a square wave in software, one sample at a time, at 44, 100 samples per second. Each song is a constant array of MusicalNote structures, each holding a frequency in Hz and a duration in beats. The audio buffer holds 512 16-bit samples (256 stereo pairs) and is split into two halves. The DMA controller streams this buffer to I2S3 in circular mode, triggering a half-complete callback when the first half is consumed and a full-complete callback when the second half is consumed. While the DMA transmits one half, the CPU refills the other. This keeps audio output continuous without stalling.
The phase increment per sample is derived from the target frequency:
A square wave is produced by comparing the running phase to pi. Samples below pi output +4096; samples above output -4096. A 1, 323-sample silence gap which is approximately 30 ms at 44.1 kHz and is inserted before each note boundary to keep adjacent tones from bleeding into each other.
Note duration in samples is computed from BPM:
When the sample counter reaches the target, the note index advances. At the end of the song, it wraps to zero.
Hardware ConfigurationMicrocontroller
The RT-Spark board uses the STM32F407ZGT6 in LQFP144 packaging. The system clock for this project runs from the internal HSI RC oscillator at 16 MHz with no PLL multiplication, giving SYSCLK = 16 MHz, AHB = 16 MHz, APB1 = 4 MHz, APB2 = 8 MHz. A dedicated PLLI2S (N=169, R=2) produces a 135.2 MHz I2S clock, from which I2S3 derives the exact 44.1 kHz audio sample rate.
Audio Subsystem
The ES8388 stereo codec (Everest Semiconductor) connects to the STM32 over two buses:
- I2C2 (PB10 SCL, PB11 SDA, 100 kHz): register configuration
- I2S3 (SPI3, Philips standard, 16-bit data, 16-bit frame, 44.1 kHz, master TX): audio sample streaming
The codec operates in slave mode, receiving clock and word select from the STM32. DAC output routes to LOUT1/ROUT1, which feed the board's 3.5 mm headphone jack. Volume is set by writing attenuation values to ES8388_DACCONTROL4 and ES8388_DACCONTROL5. The driver maps a 0-100 percentage to the 0-192 register range, inverted, because 0 in the register is maximum output. Startup volume is 100%.
The most critical step during codec initialization was writing 0x18 to ES8388_DACCONTROL1 before enabling DAC power. This register explicitly sets 16-bit word length and Philips I2S alignment. Getting this wrong produces silence or noise, regardless of all other settings.
LCD Display
The on-board 1.3-inch ST7789 LCD (240x240, RGB565) is accessed through FSMC Bank 3 in 8-bit parallel mode. The data/command select line is tied to FSMC_A18, placing the command register at base address 0x68000000 and the data register at 0x68000000 + 0x3FFFE. The LCD driver was ported from the RT-Thread BSP to bare-metal by replacing all RT-Thread OS types (rt_uint16_t, rt_err_t, rt_thread_mdelay) with standard C types and HAL delay calls.
FSMC timing was set conservatively during initialization (AddressSetupTime = 15, DataSetupTime = 255) and then tightened after the LCD wakeup sequence by writing directly to FSMC_Bank1E->BWTR (ADDSET = 3, DATAST = 2). The conservative values are necessary during reset and init; the tighter values are necessary for acceptable refresh speed during normal operation.
Backlight is a simple GPIO high on PF9 at startup.
Button Inputs
All four navigation buttons use GPIO polling with internal pull-up resistors configured in STM32CubeMX, and pin PC0 is assigned to LEFT, PC1 to DOWN, PC4 to RIGHT, and PC5 to UP, now each pin is configured as a floating input with GPIO_PULLUP enabled, meaning the pin reads logic HIGH when unpressed and logic LOW when pressed, the active-low reading is inverted in firmware using the expression !HAL_GPIO_ReadPin(GPIOx, GPIO_Pin_n) so that a value of 1 indicates a pressed state.
In the button state is sampled every 50 ms inside the main loop via a HAL_Delay(50) at the end of each iteration, so edge detection is implemented by comparing the current pin state to the previous state stored in a static variable for each button, a press event fires only on the transition from 0 to 1, so holding the button down does not cause repeat triggers, but no separate debounce timer or interrupt is required because the 50 ms polling interval naturally suppresses contact bounce, which typically settles within 5 to 20 ms.
The DOWN button pin PC1 is a modifier key, when DOWN is held and RIGHT is pressed, the master volume increases by 10%, so when DOWN is held and LEFT is pressed, the volume decreases by 10%, and then when DOWN is not held, LEFT and RIGHT perform track navigation, so LEFT moves to the previous track and RIGHT advances to the next track, the UP button toggles playback between PLAYING and PAUSED states on each press regardless of the DOWN modifier state.
Status LEDs
Peripheral Summary
Software Architecture
Song Data Structure
Each song is defined using two custom structs:
typedef struct {
float note; // Frequency in Hz (e.g., 440.0f for A4)
float duration; // Duration in beats (e.g., 1.0f = one beat)
} MusicalNote;
typedef struct {
const char* name; // Display name shown on LCD
const MusicalNote* notes; // Pointer to note array in flash
uint16_t length; // Number of notes
uint16_t bpm; // Tempo in beats per minute
} Song;Eight songs are stored as constant arrays in program flash and referenced through a playlist[] pointer array. The active song is tracked by a volatile pointer current_song and an index current_song_idx. Track changes go through Switch_song(), which disables interrupts briefly to update the pointer, note index, and sample counter atomically before re-enabling them.
Audio Buffer Filling
Fill_Audio_Buffer(offset, length) is called by both DMA callbacks and writes square wave samples into the correct half of debug_audio_buffer[512]. It does five things in order:
- Checks is_paused and writes zeros if true
- Increments the sample counter and compares it to the current note's target duration
- Inserts a trailing 30 ms silence gap before the note boundary
- Advances the note index and wraps at the end of the song
- Computes the square wave output from the current phase accumulator
Display Update
The Update_LCD_Display() redraws the entire 240x240 screen using lcd_fill(0, 0, 240, 240, Black) as the first operation to guarantee a clean black canvas regardless of prior LCD state. This approach bypasses lcd_clear(), which depends on the last set foreground/background color register and produced color bleed artifacts when called after the splash screen. The full fill directly writes black pixel data to every address in the FSMC window and is not affected by color register state.
The screen is divided into four regions, a header showing the player title, a status line showing PLAYING in green or PAUSED in red, a content area showing track number and song name, and a footer showing button controls and current volume. Song names longer than 13 characters are split at the last space before character 13 and rendered centered across two lines at 24px font size, the footer controls and volume line use 16px font to conserve vertical space.
The iupdateLCD volatile flag is set inside the main loop when a button edge is detected for track changes or play/pause toggles. The main loop polls the flag at the end of each 50 ms cycle and calls Update_LCD_Display() when set, then clears the flag. Volume changes do not set updateLCD. Instead, they call Update_Volume_Only() directly, which uses lcd_fill() to clear only the bottom row and redraws the volume string. This keeps the rest of the screen not destroyed or broken during volume adjustment.
Concurrency Model
Work is distributed across four execution contexts:
The Audio runs entirely in interrupt context via the DMA callbacks HAL_12S_TxHalfCpltCallback and Hal_12S_TxCpltCallback, the main loop only touches the LCD and button state, neither of which has a real-time deadline, and shared global state between contexts (current_song, is_paused, updateLCD, volume_level) is declared volatile to prevent compiler optimization from caching stale values. the multi-variable updates in Switch_song() are wrapped in __disable_irq() and __enable_irq() to ensure good access.
Inline comments are present throughout all source files. The Fill_Audio_Buffer() function is commented at each decision point: the pause check, the phase increment formula, the gap logic, and the note advancement. The es8388_init() driver comments explain why 0x18 is written to DACCONTROL1 (format lock) and why the mute bit must be set before the register sequence begins (speaker protection). The LCD driver comments identify which RT-Thread calls were replaced and why, including the two-phase FSMC timing strategy.
All figures marked in this report are to be inserted at the indicated positions. Schematics should be drawn in draw.io or an equivalent tool and inserted as figures before submission.
PlaylistAll songs loop from the first note when the last note completes. No user action is required to restart a track.
Audio Output
The audio signal is a 16-bit square wave, generated at 44, 100 samples per second and transmitted over I2S3 via DMA. The note frequency range spans C3 (130.81 Hz) through B5 (987.77 Hz), covering three full octaves. The phase accumulator matches the target period to within one sample period, roughly 22 microseconds.
The 30 ms inter-note gap (1, 323 samples) is clearly audible between notes and prevents adjacent tones from blurring. Without this gap, rapid sequences of notes at the same frequency sound like one long continuous tone rather than separate events.
The ES8388 DAC output feeds the 3.5 mm headphone jack directly. Codec volume was set to 100% at startup, corresponding to a register attenuation value of 0 in both DACCONTROL4 and DACCONTROL5.
Button Response
The button state is sampled every 50 ms in the main loop, as for the static variables up_prev, left_prev, right_prev, and down_prev store the previous pin reading for each button. A button press is registered only when the current reading differs from the previous reading in the pressed direction, so each physical press generates exactly one event regardless of how long the button is held.
When track changes, it takes effect immediately on the press edge, then Switch_Song() resets the note index and sample counter under a critical section, and the new song begins playing within the next DMA callback cycle, which occurs within 5.8 ms at 44, 100 Hz with a 256-sample half-buffer, so track transitions produced no audible glitch, because the sample counter resets cleanly to zero.
For the volume is adjustable in 10% steps from 10% to 100% by holding the DOWN button and pressing RIGHT to increase or LEFT to decrease, the ES8388 volume register is updated immediately via es8388_volume_set() on each press event, so the volume level is displayed persistently at the bottom of the LCD and is refreshed by Update_Volume_Only(), which redraws only the bottom row using lcd_fill() to clear the previous value before writing the new one, as this avoids a full screen redraw on every volume change, the partial redraw eliminates visible flicker during adjustment.
LCD Display
All display states rendered correctly. Multi-word song names (e.g., "Bohemian Rhapsody" at 18 characters, "Eye of the Tiger" at 16) split cleanly at whitespace and displayed centered across two lines. Status text changed between green "PLAYING" and red "PAUSED" on each UP button press. The flag-gated refresh strategy meant the screen was only redrawn when the state actually changed, which kept the LCD idle during uninterrupted playback.
Waveform Parameters
A working bare-metal MP3 player was built on the STM32F407ZGT6 without an RTOS. Audio synthesis, codec control, button handling, and LCD output all run simultaneously through a combination of DMA circular buffering, EXTI interrupt callbacks, and a flag-polled main loop.
The hardest part of the project was not the audio synthesis, that part is straightforward once the DMA callbacks are running. The harder problems were the ES8388 initialization sequence and the LCD driver port. The codec requires a specific register write order, and the format register (0x18in DACCONTROL1) has to be set before the DAC power stages are enabled. Getting this wrong cost considerable debugging time. The LCD port required working through a long list of RT-Thread API replacements, and the two-phase FSMC timing approach (conservative during init, tighter after wakeup) was found empirically rather than from the datasheet.
The bare-metal approach was chosen over FreeRTOS for a concrete reason, It's because the audio buffer must be refilled every 5.8 ms. A preemptive scheduler introduces jitter, and task switching at the wrong moment would cause an audible glitch in the output. Running the audio fill directly in the DMA ISR eliminates this risk entirely. The trade-off is that the main loop and interrupt contexts share global state (current_song, is_paused, updatedLCD), which requires careful use of volatile and brief critical sections around multi-variable updates.
Two extensions would improve this design. The first is ADC-based volume control through the on-board potentiometer on PA5, which would allow real-time attenuation adjustments without button interactions. The second is streaming actual PCM audio from the NOR Flash or SD card rather than generating square waves in software, which would give true audio fidelity instead of the buzzer-like tone the current approach produces.



Comments