Some time ago I received a NUCLEO-STM32H743ZI board.
It features one of the high-end H7 ST microcontrollers - and it’s quite a beast indeed!
480MHz CPU, 1MB of RAM and a rich set of peripherals including CAN interface, LCD-TFT driver, DAC and Ethernet.
So what do I do with all this power?
I built a WAV file player.
…
I know, not really impressive. But keep in mind that this was done mainly as an exercise and a way to get familiar with
the microcontroller. The software makes use of onboard 12-bit DAC, SPI peripheral (to communicate with an SD card), DMA and three HW timers.
Storage
A second of 8-bit PCM 2-channel audio uses ~88kB of memory.
Thus, storing the entirety of an uncompressed song in the internal flash is not possible.
As the storage medium I used a microSD card connected via an SPI adapter.
A better solution would be to use the MCU’s internal SD-MMC controller,
but I didn’t have a SD socket laying around, so i chose the SPI adapter instead.
Remember: Not all SD cards support the SPI mode! Kingston cards seem to not work most of the time based on my and others’ experience.
The SD cards usually are formatted with FAT32. To manage files i used the FatFs filesystem and kiwih’s SPI SD driver that handles the hardware layer.
So let’s start, shall we? Open CubeMX, create new project and go to SPI settings. Set Data size to 8 bits.
Set prescaler so you get around 250Kbit/s baudrate. It doesn’t have to be exactly that value, but it can’t be too big.
The SD card in SPI mode must first be initialized in low speed mode, and only then we can switch to high speed.
CubeMX will set up default MISO, MOSI and SCK pins for you. You can change the pins used by SPI by selecting another supported pin from the pinout view and setting it as SPI pin.
You also need to set one GPIO pin as output - it will be used as the CS pin.
When you’re done go to Middleware>FATFS tab and enable FATFS in User-Defined mode.
You can also enable USE_LFN if you want to be able to use filenames longer than 13 characters.
Generate project. Grab kiwih’s SPI driver and put user_diskio_spi.c and user_diskio_spi.h into your source code.
In user_diskio.c make each USER function call corresponding function from the SPI driver.
For example:
In user_diskio.c You need to #define the handle to SPI peripheral (SD_SPI_HANDLE) and the port (SD_CS_GPIO_Port) and pin (SD_CS_GPIO_Pin) used by the CS pin.
In the same file you need to change lines 38 and 39 and set the prescalers to get approximately the listed speeds.
In my case SPI was clocked at 64MHz, so I set the prescalers to 256 and 16 to get 250kbps and 4Mbps accordingly.
And here’s something very important that cost me an hour of debugging later in the project
The SPI registers of the H7 family MCU’s are a bit different.
While in other ST microcontrollers prescalers are set in the CR1 register, in H7 they’re set in CFG1 register.
So if you’re using a H7 change the above two lines and replace CR1 with CFG1. Then you’re good to go.
Now the driver and filesystem should be configured.
You can find more detailed instructions of setting up the driver in the blog post by its author himself.
Generating an audio signal
Audio is an analog signal and we need a way to generate one.
As far as I’m aware, there are three ways to do so:
Generate signal using PWM. The advantage of this approach is that it can be used even with microcontrollers that don’t have any kind of analog output.
It uses PWM (usually generated by a hardware timer) to generate pseudo-analog signal. The sound quality - as expected - is worse compared to a DAC.
External Digital to Analog Converter. On the market you can find various external DACs.
Those suited for audio playback usually offer at least 16-bit precision stereo output and an I2S interface.
This is the best way to get a good sound quality from the microcontroller, especially since our H7 has hardware support for I2S.
Those converters are a bit costly though, and I wanted to only use stuff I had already had.
Internal DAC. The STM32H743 features a 12-bit internal DAC.
In this project I’m going to use the internal DAC with 8-bit precision.
The player will use double buffering, which uses DMA timed by a hardware timer to load consecutive samples into the DAC register,
while at the same time the CPU reads data from storage to another buffer.
When DMA transfer finishes, the buffers are swapped and the process starts again.
Let’s start with configuring the timer. In CubeMX select the timer you want to use and activate it (For some timers you need to select a clock source - use internal clock).
Set Trigger Event Selection to Update Event. You don’t need to set counter period yet - we’ll do it later in software.
Next, set up the DAC. Set both outputs to external pin, normal mode.
Select timer you set up earlier as the trigger. I also recommend enabling buffers for outputs.
Next we’ll set the DMA. Go to DMA tab and add new request. Select DAC1_CH1 from the list.
Click on the newly created request and set mode to Normal and the data width to Half Word. Finally, go to NVIC tab and enable the DAC interrupt.
In my case it’s named “TIM6 global interrupt, DAC1_CH1 and DAC1_CH2 underrun error interrupts”.
Wait, why are we only setting DAC1_CH1 transfer? We want to play on both channels!
Also, half-word data width? Shouldn’t it be bytes since we’re using 8-bit samples?
Don’t worry - those settings are correct and I will explain why in a moment.
To play the audio file we need to know a few things: the sample rate, sample format, number of channels and data length.
All of this information can be found in the header of a WAV file.
If you want to learn more about the WAV format, you can read about it here.
We need to extract relevant data from the fmt chunk. For the sake of simlicity we are going to support only 8-bit integer samples, max 2 channels and no extensions.
Let’s write a simple wav header parser! Create files wavparser.h and wavparser.c.
In wavparser.h define a struct for wave data and define the parsing function.
In wavparser.c we put the implementation of our parsing function.
I put the implementation of audio player into audio_player.c and audio_player.h files.
In the header file we define enums for player status and result of loading file.
We also declare functions that we want to expose to the rest of the program.
Let’s head over to audio_player.c file and start writing the implementation.
Let’s start by declaring some constants and static variables.
The player_init function should be called once. We pass handles to required peripherals and timer clock frequency.
The clock frequency will be used to calculate auto-reload value so that timer can trigger DAC on intervals equal to the audio file’s sampling rate.
Let’s add parse_wav function that uses our previously implemented WAV header parser to extract
the data we need to play the file. It’s quite long with all the error handling but we want the filesystem errors to be distinguished from other kinds of errors.
Non-FS error can be handled by simply not loading the file. Filesystem errors are critical and require re-mounting of the filesystem or restarting the device.
The player_loadfile function takes FATFS’s FILEINFO structure containing data about the file we want to open.
It calls the wav parser, sets up the DAC timer with correct frequency and fills up the buffers.
Now we need to implement the load_bytes function.
For some reason I get filesystem errors when trying to read more than 512 bytes from SD card in a single transfer.
I am not sure if this is the issue with the SD card i got, FATFS, or the SPI driver.
The workaround is to read data in 512b chunks.
When loading from mono file we need to duplicate the samples so that the same sample plays in both channels.
Now it’s time for player_start and player_stop functions. To start playing file, we need to start the DAC timer and DMA transfer.
HAL_DACEx_DualStart_DMA is a function from the extended HAL module driver. It allows us to transfer samples to both DAC channels in a single DMA request.
Instead of transfering data to data holding registers corresponding to specific channels (DAC_DHR8R1 and DAC_DHR8R2) this function instead sets the destination
address to DAC_DHR8RD dual DAC register, which can hold data for both channels.
This allows us to transfer two 8-bit samples in one DMA write cycle.
This is why we set data width to half-word earlier - half word equals two bytes and we want to transfer two samples at once.
Why is the purpose of the second argument then? It just sets the callbacks we want to use.
If you set the channel as channel 1, DMA will call callbacks for channel 1
(like HAL_DAC_ConvCpltCallbackCh1) and if you set channel 2 - corresponding callbacks for channel 2.
To stop playback we simply stop the DMA transfer and DAC timer. Don’t forget to also close the file!
Here comes the most important part. The DMA transfer complete callback. When we finish playing contents of the buffer, we get the transfer complete callback.
In the callback we need to do the following:
Check position of file and stop the player if we already played the entirety of the file
Swap pointers to playing buffer and reading buffer
Start the DMA transfer from the playing buffer
Read new samples into the reading buffer.
This way playing samples from the playing buffer and loading samples into the reading buffer can be done at the same time.
The function player_dac_dma_callback needs to be called in the HAL_DAC_ConvCpltCallbackCh1 callback.
Put this in your main.c:
Finally, let’s add functions to get the current state and position of the player:
User input
The device takes input from the user through a rotary encoder and a button.
A hardware timer in encoder mode is used to receive input from the encoder.
Using the timer instead of simple GPIO interrupts has an advantage of using the timer’s input filter to reduce the effect of contact bounce of the encoder.
Encoder is used to select the file and the button is used to start and stop playback.
To configure timer in encoder mode you need to select Encoder mode from the Combined Channels list.
I recommend setting input filter to 10 or higher for better debouncing.
You also need to enable TIM global interrupt in the NVIC settings.
To configure the play/stop button just pick a pin and set it to GPIO_EXTI mode. Remember to enable the interrupt in NVIC settings.
To display data to the user I used HD44780 based 16x2 LCD display with my own simple driver.
I am not going to describe it here in detail. See the lcd.c and lcd.h files in the project’s repository if you want to study or copy/paste the code.
Putting it all together
Time to write our main.c file.
First, let’s list things we need to do:
Mount the filesystem and enumerate WAV files
Enable the user to select the file they want to play
Display the name of the currently selected file on the display
Display the progress bar of currently playing file
Show errors on the LCD if any occur
First, let’s declare some global variables.
Let’s start with two functions for displaying things on the LCD.
Then functions for mounting the filesystem and enumerating files in directory.
To make this simple, we are going to just list the files from the FS root, without traversing subdirectories.
Encoder handler checks the direction in which the encoder was turned and increments or decrements the selected_file index (also does bound checking).
You need to call handle_encoder_input in your TIM IRQ handler (in my case it’s TIM4_IRQHandler defined in stm32h7xx_it.c file).
The button interrupt handler is very simple. If the player is playing, it stops the player.
Otherwise, it calls the start_player function passing the file_info of currently selected file.
The start_player function takes the file info and tries to load the file and play it.
Here we do some error checking and display proper alerts on the LCD screen.
And finally, we define a delay function. This one is used by the LCD driver and needs to be passed to it.
It uses hardware timer. Just select one in CubeMX, set prescaler to get 1Mhz clock rate and activate it.