A buffering DAC for #raspberrypi using #stm32

Rationale

Linux is not an RTOS (real-time OS), so you can’t reliably use timers to produce music without creating distortions. It is possible to use libraries, such as portaudio, to produce music with proper music output. The problem is that you generally have to introduce some kind of threading model, callback system. Although blocking mechanisms also exist, they are generally not particularly useable. You only have a small window to produce stream output.

What is proposed in this article is a better blocking method. A SoC (System on a Chip) communicates with a MCU (microcontroller) over SPI. The MCU has a built-in DAC, and implements a buffering queue. When the queue approaches capacity it sets a pin for the MCU to block until the queue approaches depletion.

The MCU empties the queue at a fixed rate (44.1kHz in this case), which signals the SoC to produce samples as quickly as possible as the queue nears depletion.

The effect is to allow the SoC to implement a simple blocking mechanism, whilst the SoC takes care of the timing and queueing issues.

Components chosen

A Raspberry Pi 3 was chosen as the SoC. Other SoCs are suitable just so long as they are fast enough and capable of acting as SPI masters.

The producer-side code was written in the D programming language using a custom module that interfaces with the BCM2835 library.

A Nucleo-32 STM32L432KC was chosen as the MCU. This MCU has a built-in DAC, is fast, and has a small form-factor.

C was used as a programming language, interfacing with the CMSIS for that MCU.

Raspberry Pi code

Here’s the code:

import waved;
import std.stdio;
import std.math;
import std.conv;
import bcm2835;
import core.runtime;

enum BCM2835_GPIO_FSEL_INPT = 0; // input select
enum RDY = 22; // DAC ready

void main()
{
	writeln("play wav over l432");
	auto full_name = Runtime.args[1];

	Sound wav_in = decodeWAV(full_name);
	wav_in = wav_in.makeMono();
	auto fs = wav_in.sampleRate;
	//assert(fs == 44100);
	float[] samps1 = wav_in.channel(0);
	auto nsamps = samps1.length;

	writeln("Loaded. Playing...");

	bcm2835_init();
	bcm2835_spi_begin();
	bcm2835_spi_setClockDivider(32); // On Pi3, 32=> 12.5MHz, 2=>200<Hz
	bcm2835_gpio_fsel(RDY, BCM2835_GPIO_FSEL_INPT);


	foreach(i; 0..nsamps) {
		int vol = to!int((samps1[i] +1.0) * 4095.0 / 2.0);
		while(bcm2835_gpio_lev(RDY)==0) {};  // wait until pin goes high
		// NB 16-bit data is transmitted in MSB first
		ubyte[2] tx, rx;
		tx[1] = vol & 0xFF;
		tx[0] = (vol>>8) & 0xFF;
		bcm2835_spi_transfernb(cast(ubyte*)tx, cast(ubyte*) rx, 2);		
	}

	bcm2835_spi_end();
	bcm2835_close();

}

It’s gloriously simple. You invoke the compiled code like so:

sudo ./app song.wav

where song.wav is some music that you want to play in WAV format. 44.1kHz sampling rate is assumed.

The RDY pin is the “ready” pin. GPIO22 was chosen, although it doesn’t really matter. You need to block until the pin goes high. Then you send samples until it goes high again. You don’t need to worry about timing issues, but you do need to be able to produce samples quickly enough that the buffer doesn’t empty (a “buffer underrun”). You must respect the RDY pin state, too, otherwise you are likely to get a buffer overrun.

The waved package is used for reading wav files, which is readily available via dub.

The above code is available here. Your best bet is to clone the whole repo, because you’ll also need the bcm2835 module, which isn’t available via dub yet. The bcm2835 module is here. If you want to save yourself some hassle, just copy and paste the following code into app.d:

extern (C) int  bcm2835_init();
extern (C) int  bcm2835_close();
extern (C) int  bcm2835_gpio_lev(ubyte pin);
extern (C) void bcm2835_gpio_set(ubyte pin);
extern (C) void bcm2835_gpio_clr(ubyte pin);
extern (C) void bcm2835_gpio_fsel(ubyte pin, ubyte mode);
extern (C) void	bcm2835_spi_transfernb (ubyte *tbuf, ubyte *rbuf, uint len);
extern (C) int 	bcm2835_spi_begin();
extern (C) void	bcm2835_spi_end();
extern (C) void bcm2835_spi_setClockDivider(uint divider);

enum BCM2835_GPIO_FSEL_INPT = 0; // input select

You will need to download, compile and install the actual bcm2835 library if it isn’t already installed. You can download the code from many places, including from my own fork.

Microcontroller code

On the STM32 side, upload the following code:

#include "conf.h"
#include "gpio.h"

#include <string.h>


#include <inttypes.h>
#include <stdbool.h>

#define RDY A7 // PA2



#define CB_CAP 128 // capacity
uint32_t cb_data[CB_CAP];
volatile int cb_read_index;
volatile int cb_write_index;
volatile int cb_size;

bool cb_write(uint32_t value)
{
	if(cb_size == CB_CAP) return false;
	cb_data[cb_write_index] = value;
	if(++cb_write_index == CB_CAP) cb_write_index = 0;
	cb_size++;
	return true;
}

bool cb_read(uint32_t* value)
{
	*value = 0;
	if(cb_size == 0) return false;
	*value = cb_data[cb_read_index];
	if(++cb_read_index == CB_CAP) cb_read_index = 0;
	cb_size--;
	return true;
}

void cb_init(void)
{
	cb_read_index = 0;
	cb_write_index = 0;
	cb_size = 0;
	memset(cb_data, 0, sizeof(cb_data));
}



void SPI1_IRQHandler(void)
{
	SPI1->SR = 0;

	uint16_t val = SPI1->DR;
	cb_write(val);
	if(cb_size>CB_CAP*3/4)
		gpio_clr(RDY);
}

void TIM2_IRQHandler(void)
{
	TIM2->SR = 0;
	uint32_t val;
	cb_read(&val);
	DAC1->DHR12R1 =val;
	if(cb_size<CB_CAP/4) 
		gpio_set(RDY);
}

void tim2_init(void)
{
	RCC->APB1ENR1 |= RCC_APB1ENR1_TIM2EN;
	int fs = 44100; // sample rate
	TIM2->ARR = SystemCoreClock/fs-1;
	TIM2->EGR |= TIM_EGR_UG; // apply settings
	TIM2->DIER |= TIM_DIER_UIE; // enable update interrupt
	NVIC_EnableIRQ(TIM2_IRQn);
	TIM2->CR1 |= TIM_CR1_CEN; // enable the counter
}


void spi1_init(void)
{
	// a simple auciliary function
	void spi_pin(GPIO_TypeDef *GPIOx, uint32_t pin)
	{
		gpio_set_ospeedr(GPIOx, pin, 0b11); // high speed
		gpio_alt(GPIOx, pin, 5);
	}

	RCC->APB2ENR |= RCC_APB2ENR_SPI1EN; // enable the spi clock
	spi_pin(GPIOA, 1); // SCK
	spi_pin(GPIOA, 7); // MOSI
	spi_pin(GPIOB, 0); // NSS
	SPI1->CR1 |= SPI_CR1_RXONLY; // receive only
	SPI1->CR2 |= (0b1111 << 8); // 16-bit data width
	//volatile SPI_TypeDef* spi1 = SPI1;
	NVIC_EnableIRQ(SPI1_IRQn);
	SPI1->CR1 |= SPI_CR1_SPE; // enable spi1
	SPI1->CR2 |= SPI_CR2_RXNEIE; // enable recv not empty interrupt
}

int main(void)
{
	// init dac
	RCC->APB1ENR1 |= RCC_APB1ENR1_DAC1EN;
	DAC1->CR |= DAC_CR_EN1;

	gpio_out(RDY);
	cb_init();
	tim2_init();
	spi1_init();

	while(1);
}

The code is available here. It is easily compiled for me by typing “make”. However, your CMSIS is likely to be located in a different place than mine, so you will probably need to tweak “jagan.mk” in its parent directory.

The code uses a circular buffer, which I have talked about in a previous post. It has the capacity to store 128 samples.

I selected pin PA2 as the RDY pin. It is the counterpart to the RDY pin on the Pi.

I do have a kind of “HAL”, so you’ll need that, too. If you have cloned the whole repo, it should be too much of a problem to use.

Function main() sets up the DAC, the RDY pin, a timer (TIM2, see tim_init()) and SPI1 (spi1_init()) interrupts.

TIM2_IRQHandler() fires at a rate of 44100Hz. It reads a value from the queue, and sets the DAC to this value. The DAC is 12-bits. When the queue is 1/4 full or lower, it sets RDY to signal to the Pi to send more samples

SPI1_Handler() receives values from the Pi and stores them in the queue. When the queue become 3/4 full, it sets RDY low to signal to the Pi to stop sending samples.

So, not too many lines of code.

Connections

Wire the Pi and the MCU as follows:

      Pi   MCU
RDY   22   PA2
MOSI  10   PA7
SCK   11   PA1
SS     8   PB0

The Pi pins relate to the GPIO numbers rather than the physical layout.

You will also need to connect a common grounding wire between the two devices

Wrapping up

That’s about it, really. The code is fairly short and understandable, I think. Questions?

I think the project is a great basis for exploring real-time DSP (Digital Signal Processing), adding such things as filters, modulation and other effects. Maybe making a sequencer, drum machine, or whatever.

I haven’t decided where it’s best to add the signal processing. The Pi is likely to have more grunt, and is more flexible. So adding DSP to the producer side is good from that viewpoint. On the other hand, you can add fancy buttons and controls onto the MCU and control things that way. The MCU is also doing multi-tasking too, so that might be the best way to handle things. If you did it on the Pi, then you’d have to add some kind of concurrency. Thoughts?

About mcturra2000

Computer programmer living in Scotland.
This entry was posted in Uncategorized. Bookmark the permalink.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s