Coding the passthrough

In this section, we will guide you through programming the microcontroller in order to implement the passthrough. Many of the concepts in this section lay the foundations for how to structure and code a real-time audio application on the microcontroller. In later sections we will build more complex processing functions, but the architecture of the code will remain the same.

In the previous section, you should have copied the blinking LED project before updating the IOC file with CubeMX. From the SW4STM32 software, open the file "Src/main.c" in the new project; we will be making all of our modifications here.

Macros

In programming a microcontroller, it is customary to define preprocessor macros to set the values of reusable constants and to concisely package simple tasks that do not require much logic and flow control and for which, therefore, a function call would be overkill. See here for more on macros and preprocessor directives when programming in C.

Macros are usually defined before the main function; we will place our macros between the USER CODE BEGIN Includes and USER CODE END Includes comment tags.

The MUTE macro

As an example, we will begin by creating macros to change the logical level of the MUTE pin. As in the blinking LED example, we will be using HAL library calls in order to modify the state of the MUTE GPIO pin.

TASK 1: Complete the two macros below -MUTE and UNMUTE- in order to mute/unmute the output. Simply replace the XXX in the definitions with eitherGPIO_PIN_SET or GPIO_PIN_RESET, according to whether you need a HIGH or LOW level.

Hint: you should check the datasheet of the DAC to determine whether you need a HIGH or LOW value to turn on the mute function of the DAC.

#define MUTE HAL_GPIO_WritePin(MUTE_GPIO_Port, MUTE_Pin, XXX);
#define UNMUTE HAL_GPIO_WritePin(MUTE_GPIO_Port, MUTE_Pin, XXX);

Note how the MUTE pin that we configured before automatically generates two constants called MUTE_GPIO_Port and MUTE_Pin, which is why we suggested giving meaningful names to pins configured with the CubeMX tool.

If you press "Ctrl" ("Command" on MacOS) + click on MUTE_GPIO_Port or MUTE_Pin to see its definition, you should see how the values are defined according to the pin we selected for MUTE. In our case, we chose pin PC0 which means that Pin 0 on the GPIO C port will be used. The convenience of the CubeMX software is that we do not need to manually write these definitions for the constants! The same can be said for LR_SEL.

The Channel Select macro

We will now define two more macros in order to assign the MEMS microphone to the left or right channel of the I2S bus, using the LR_SEL pin we defined previously. As before, you should place these macros between the USER CODE BEGIN Includes and USER CODE END Includes comments.

TASK 2: Define two macros - SET_MIC_RIGHT and SET_MIC_LEFT - in order to assign the microphone to the left or right channel. You will need to use similar commands as for the MUTE macros.

Hint: you should check the I2S protocol (and perhaps the datasheet of the microphone) to determine whether you need a HIGH or LOW value to set the microphone to the left/right channel.

Private variables (aka Constants)

In most applications we will need to set some numerical constants that define key parameters used in the application.

These definitions are also preprocessing macros and they are usually grouped together at the beginning of the code between the USER CODE BEGIN PV and USER CODE END PV comment tags.

We will now define a few constants which will be useful in coding our application. Before defining them in our code, let's clarify some of the terminology:

  1. Sample: a sample is a single discrete-time value; for a stereo signal, a sample can belong either to the left or right channel.

  2. Frame: a frame collects all synchronous samples from all channels. For a stereo signal, a frame will contain two samples, left and right.

  3. Buffer length: a buffer is a collection of frames, stored in memory and ready for processing (or ready for a DMA transfer). The buffer's length is a key parameter that needs to be fine-tuned to the demands of our application, as we explained before.

Audio Parameters

Add the following lines to define the frame length (in terms of samples) and the buffer length (in terms of frames):

#define SAMPLES_PER_FRAME 2   /* stereo signal */
#define FRAMES_PER_BUFFER 32  /* user-defined */

SAMPLES_PER_FRAME is set to 2 as we have two input channels (left and right) as per the I2S protocol.

Since our application is a simple passthrough, which involves no processing, we can set the buffer length - FRAMES_PER_BUFFER - to a low value, e.g. 32.

Data buffers

Again, as explained in Lecture 2.2.5b in the second DSP module, for real-time processing we normally need to use alternating buffers for input and output DMA transfers. The I2S peripheral of our microcontroller, however, conveniently sends two interrupt signals, one when the buffer is half-full and one when the buffer is full. Because of this feature, we can simply use an array that is twice the size of our target application's buffer and let the DMA transfer fill one half of the buffer while we simultaneously process the samples in the other half.

TASK 3: Using the constants defined before - SAMPLES_PER_FRAME and FRAMES_PER_BUFFER - define two more constants for the buffer size and for the size of the double buffer. Just replace the ellipsis in the macros below with the appropriate expressions.

#define HALF_BUFFER_SIZE (...)
#define FULL_BUFFER_SIZE (...)

Finally, we can create the input and output buffers as such:

int16_t dma_in[FULL_BUFFER_SIZE];
int16_t dma_out[FULL_BUFFER_SIZE];

Private function prototypes

In this section we will declare the function prototypes that implement the final application. The code should be placed between the USER CODE BEGIN PFP and USER CODE END PFP comment tags.

Main processing function

Ultimately, the application will work by obtaining a fresh data buffer filled by the input DMA transfer, processing the buffer and placing the result in a data buffer for the output DMA to ship out. We will therefore implement a main processing function with the following arguments:

  1. a pointer to the input buffer to process

  2. a pointer to the output buffer to fill with the processed samples

  3. the number of samples to read/write.

The resulting function prototype is:

void Process(int16_t *pIn, int16_t *pOut, uint16_t size);

This will be the main processing function which will be invoked by the interrupts raised by the DMA transfer every time either the first or the second half of the buffer has been filled.

DMA callback functions

As previously mentioned, the STM32 board uses DMA to transfer data in and out of memory from the peripherals and issues interrupts when the DMA buffer is half full and when it's full.

The HAL family of instructions allows us to define callback functions triggered by these interrupts. Add the following function definitions for the callbacks, covering the four cases of two input and output DMAs times two interrupt signals:

void HAL_I2S_RxHalfCpltCallback(I2S_HandleTypeDef *hi2s) {
}

void HAL_I2S_RxCpltCallback(I2S_HandleTypeDef *hi2s) {
}

void HAL_I2S_TxHalfCpltCallback(I2S_HandleTypeDef *hi2s) {
  Process(dma_in, dma_out, HALF_BUFFER_SIZE);
}

void HAL_I2S_TxCpltCallback(I2S_HandleTypeDef *hi2s) {
  Process(dma_in + HALF_BUFFER_SIZE, dma_out + HALF_BUFFER_SIZE, HALF_BUFFER_SIZE);
}

Note that the Rx callbacks (that is, the callbacks triggered by the input DMAs), have an empty body and only the Tx callbacks (that is, the ones driven by the output process) perform the processing via our process function.

This is a simple but effective way of synchronizing the input and the output peripherals when we know that the data throughput should be the same for both devices. Of course we can see that if the process function takes too long, the buffer will not be ready in time for the next callback and there will be audio losses. In the next chapter, we will introduce a mechanism to monitor this.

You can read more about the HAL functions for DMA Input/Output for the I2S protocol in the comments of the file "Drivers/STM32F0XX_HAL_Driver/Src/stm32f0xx_hal_i2s.c" from the SW4STM32 software:

/* 
...
*** DMA mode IO operation ***
==============================
[..] 
(+) Send an amount of data in non blocking mode (DMA) using HAL_I2S_Transmit_DMA() 
(+) At transmission end of half transfer HAL_I2S_TxHalfCpltCallback is executed and user can 
add his own code by customization of function pointer HAL_I2S_TxHalfCpltCallback 
(+) At transmission end of transfer HAL_I2S_TxCpltCallback is executed and user can 
add his own code by customization of function pointer HAL_I2S_TxCpltCallback
(+) Receive an amount of data in non blocking mode (DMA) using HAL_I2S_Receive_DMA() 
(+) At reception end of half transfer HAL_I2S_RxHalfCpltCallback is executed and user can 
add his own code by customization of function pointer HAL_I2S_RxHalfCpltCallback 
(+) At reception end of transfer HAL_I2S_RxCpltCallback is executed and user can 
add his own code by customization of function pointer HAL_I2S_RxCpltCallback
(+) In case of transfer Error, HAL_I2S_ErrorCallback() function is executed and user can 
add his own code by customization of function pointer HAL_I2S_ErrorCallback
(+) Pause the DMA Transfer using HAL_I2S_DMAPause()
(+) Resume the DMA Transfer using HAL_I2S_DMAResume()
(+) Stop the DMA Transfer using HAL_I2S_DMAStop()
...
*/

The user application

Between the USER CODE BEGIN 4 and USER CODE END 4 comment tags, we will define the body of the process function which, in this case, implements a simple passthrough.

TASK 4: Complete the main processing function which simply copies the input to the output buffer.

void inline Process(int16_t *pIn, int16_t *pOut, uint16_t size) {
  // copy input to output
  ...
}

Initial Setup

Between the USER CODE BEGIN 2 and USER CODE END 2 comment tags, we need to initialize our STM32 board, namely we need to:

  1. un-mute the DAC using the macro defined before.

  2. set the microphone to either left or right channel using the macro defined here.

  3. start the receive and transmit DMAs with HAL_I2S_Receive_DMA and HAL_I2S_Transmit_DMA respectively.

This is accomplished by the following lines:

// Control of the codec
UNMUTE
SET_MIC_LEFT

// Start DMAs
HAL_I2S_Transmit_DMA(&hi2s1, (uint16_t*) dma_out, FULL_BUFFER_SIZE);
HAL_I2S_Receive_DMA(&hi2s2, (uint16_t*) dma_in, FULL_BUFFER_SIZE);

We can now try building and debugging the project (remember to press Resume after entering the Debug perspective). If all goes well, you should have a functioning passthrough and you should be able to hear in the headphones the sound captured by the microphone.

Going a bit further

If you still have time and you are curious to go a bit further, we propose to make a modification to theProcess function. In the current implementation, since the input is mono and the output is stereo, you may have noticed that only one output channel carries the audio while the other is silent. Wouldn't it be nice if both had audio, thereby converting the mono input to a stereo output?

BONUS: Modify theProcess function so that both output channels contain audio.

Note: remember to copy your project before making any significant modifications; that way you will always be able to go back to a stable solution!

Congrats on completing the passthrough! This project will serve as an extremely useful starting point for the following (more interesting) applications. The first one we will build is an alien voice effect. But first, let's talk about some key issues in real-time DSP programming.

Solutions

Are you sure you are ready to see the solution? ;)

Last updated