Skip to content

Communication SPI

pschatzmann edited this page Oct 28, 2026 · 3 revisions

SPI is a fast synchronous bus with a separate clock line. With SPIAudioMaster and SPIAudioSlave you can send audio from one microcontroller to another at several Mbit/s - e.g. from a main controller to a separate audio processor. In contrast to a serial link the protocol has flow control built in: the master asks the slave how much space is left in its buffer. See Communication for the general considerations.

Both classes are in AudioTools/Communication/SPI/SPIAudio.h. They are supported on the ESP32. A register based variant for more platforms is described below.

Protocol

The master starts all transactions: a request is cmd(1) + payload_len(2) + payload, the slave answers with status(1) + response_len(2) + response. The commands are: set the MIME type, set the AudioInfo, write data, get the available / filled buffer size and clear the buffer.

SPIAudioMaster: sending

SPIAudioMaster is an AudioOutput: write the audio to it.

#include "AudioTools.h"
#include "AudioTools/Communication/SPI/SPIAudio.h"

AudioInfo info(44100, 2, 16);
SineGenerator<int16_t> sineWave(32000);
GeneratedSoundStream<int16_t> sound(sineWave);
SPIAudioMaster master(SPI);
StreamCopy copier(master, sound);

void setup() {
  SPI.begin();
  SPIAudioMasterConfig cfg;
  cfg.copyFrom(info);
  cfg.cs_pin = SS;
  cfg.clock_hz = 10000000;
  cfg.is_blocking_write = true;  // wait until the slave has space
  master.begin(cfg);
  master.setAudioInfo(info);  // tell the slave the format
  sineWave.begin(info, N_B4);
}

void loop() { copier.copy(); }
Field (SPIAudioMasterConfig) Default Description
cs_pin SS chip select (-1: handled by the hardware)
clock_hz 8000000 SPI clock
bit_order, data_mode MSBFIRST, SPI_MODE0 SPI settings
max_payload 1024 max bytes per write command
is_blocking_write false false: write() only sends what fits into the slave's buffer; true: waits for space

With is_blocking_write = true the master is slowed down to the speed at which the slave consumes the data - so a generator or file needs no Throttle.

SPIAudioSlave: receiving

SPIAudioSlave is an AudioStream: it writes the received data into a buffer that you provide, and you read it from there. On the ESP32 it uses the hardware SPI slave driver: each call of process() handles one byte of the SPI transfer, so call it continuously in a separate task:

#include "AudioTools.h"
#include "AudioTools/Communication/SPI/SPIAudio.h"
#include "AudioTools/Concurrency/RTOS.h"

AudioInfo info(44100, 2, 16);
BufferRTOS<uint8_t> buffer(10 * 1024);  // thread-safe: written and read in different tasks
SPIAudioSlave slave(buffer);
I2SStream out;
StreamCopy copier(out, slave);
Task spi_task("spi", 4096, 10, 0);

void setup() {
  SPIAudioSlaveConfig cfg;
  cfg.copyFrom(info);
  cfg.pin_mosi = 23;
  cfg.pin_miso = 19;
  cfg.pin_sclk = 18;
  cfg.pin_cs = 5;
  slave.addNotifyAudioChange(out);  // follow the format sent by the master
  slave.begin(cfg);

  auto icfg = out.defaultConfig(TX_MODE);
  icfg.copyFrom(info);
  out.begin(icfg);

  spi_task.begin([]() { slave.process(); });
}

void loop() { copier.copy(); }

The AudioInfo and the MIME type (mimeType()) sent by the master are available on the slave. There is no example in the library yet, so treat the sketches above as a starting point.

Field (SPIAudioSlaveConfig) Default Description
pin_mosi, pin_miso, pin_sclk, pin_cs 23, 19, 18, 5 the pins
host, dma_chan SPI2_HOST, 1 the SPI peripheral and DMA channel
queue_size 4 SPI transaction queue: increase for bursts
max_command_payload 1024 must be at least the max_payload of the master
use_hw_slave true use the ESP32 hardware slave driver

Register based SPI audio (TinySPISlave)

SPIRegAudioMaster and SPIRegAudioSlave are an alternative that works on more platforms: the slave is based on the TinySPISlave library, which supports the ESP32, RP2040 / RP2350 and STM32. Include AudioTools/Communication/SPI/SPIRegAudio.h. Instead of commands the slave offers a small register map:

Dependencies

  • Slave: install TinySPISlave (ESP32, RP2040 / RP2350 with the arduino-pico core, STM32). Add #include "TinySPISlave.h" to your sketch before SPIRegAudio.h: this makes the Arduino build system find the library, and SPIRegAudioSlave is only available if it is installed.
  • Master: only the Arduino SPI library, any board with an SPI master works. TinySPISlave is not needed.
  • Decoding on the slave (the example below): arduino-libhelix for MP3DecoderHelix and AACDecoderHelix; WAVDecoder is part of AudioTools.
Address Register Access Content
0x01 SPIREG_AUDIO_INFO rw sample rate (u32), channels (u16), bits per sample (u8), reserved - 8 bytes, little endian
0x02 SPIREG_MIME rw zero terminated MIME type, 32 bytes
0x03 SPIREG_AVAILABLE ro u32: free space in the slave's buffer, used for the flow control
0x10 SPIREG_DATA wo the audio data: the bytes of the frame are appended to the slave's buffer

The command byte is the register address (bit 7 = read). A write is one frame [cmd][bytes...]. A read is a request frame [cmd][n], a short pause with CS high and a second frame in which the master clocks n bytes. Address 0 is reserved for these dummy bytes. The master sets the AudioInfo and the MIME type, reads the available space and writes only as much data as fits.

Master

#include "AudioTools.h"
#include "AudioTools/Communication/SPI/SPIRegAudio.h"

AudioInfo info(44100, 2, 16);
SineWaveGenerator<int16_t> sineWave(16000);
GeneratedSoundStream<int16_t> sound(sineWave);
SPIRegAudioMaster master;
WAVEncoder wav;
EncodedAudioOutput enc(&master, &wav);
StreamCopy copier(enc, sound);

void setup() {
  auto cfg = SPIRegAudioMasterConfig();
  cfg.copyFrom(info);
  cfg.cs_pin = 5;
  cfg.is_blocking_write = true;  // the encoder can not repeat a write
  master.begin(cfg);             // defines the AudioInfo on the slave
  master.setMime(wav.mime());    // "audio/wav"
  sineWave.begin(info, N_B4);
  enc.begin(info);
}

void loop() { copier.copy(); }
Field (SPIRegAudioMasterConfig) Default Description
cs_pin SS chip select (-1: handled by the hardware)
clock_hz 4000000 SPI clock
bit_order, data_mode MSBFIRST, SPI_MODE0 SPI settings
max_payload 256 max audio bytes per frame, must not be bigger than the slave's
reply_gap_us 300 pause between a read request and the reply frame
is_blocking_write false false: write() only sends what fits; true: waits for space

Slave

The slave is an AudioStream and also a MimeSource: a MultiDecoder can select the decoder from the MIME type sent by the master. For audio/pcm the decoder is bypassed and the AudioInfo register defines the output format.

#include "TinySPISlave.h"  // must be included before SPIRegAudio.h
#include "AudioTools.h"
#include "AudioTools/AudioCodecs/CodecHelix.h"
#include "AudioTools/Communication/SPI/SPIRegAudio.h"

RingBuffer<uint8_t> buffer(16 * 1024);
SPIRegAudioSlave slave(buffer);
I2SStream out;
MP3DecoderHelix mp3;
AACDecoderHelix aac;
WAVDecoder wav;
MultiDecoder multi(slave);             // decoder is selected by the mime of the slave
EncodedAudioOutput dec(&out, &multi);  // the decoders set the format of out
StreamCopy copier(dec, slave);         // encoded data
StreamCopy pcm_copier(out, slave);     // audio/pcm: directly to the output
bool is_pcm = false;

// A new mime type: restart the decoder so that it gets selected again
void onMime(const char* mime, void*) {
  is_pcm = StrView(mime).equalsIgnoreCase("audio/pcm");
  dec.end();
  dec.begin();
}

void setup() {
  multi.addDecoder(mp3, "audio/mpeg");
  multi.addDecoder(aac, "audio/aac");
  multi.addDecoder(wav, "audio/wav");
  out.begin(out.defaultConfig(TX_MODE));

  auto cfg = SPIRegAudioSlaveConfig();
  cfg.sck = 18; cfg.miso = 19; cfg.mosi = 23; cfg.cs = 5;
  slave.addNotifyAudioChange(out);  // AudioInfo register -> output (pcm)
  slave.setMimeCallback(onMime);
  slave.begin(cfg);
  dec.begin();
}

void loop() {
  slave.update();  // applies AudioInfo / mime changes
  if (is_pcm) pcm_copier.copy(); else copier.copy();
}
Field (SPIRegAudioSlaveConfig) Default Description
sck, miso, mosi, cs -1 the pins (RP2040: fixed by the function select, see TinySPISlave)
spi_mode 0 SPI mode 0-3
max_payload 256 max audio bytes per frame
spi_host 2 ESP32 only: SPI2 or SPI3
  • The SPI frames are processed in an interrupt (RP2040 / STM32) or in a task (ESP32): use a buffer for one writer and one reader, e.g. RingBuffer. Bytes which are sent despite a full buffer are dropped and counted in overflowBytes().
  • Call update() from loop(): it applies the AudioInfo / MIME changes in the application context.
  • Examples: examples-communication/spi/spi-reg-master and spi-reg-slave. They have only been compiled so far (ESP32, RP2040), not tested on hardware.

Things to consider

  • Keep the wires short and connect GND; reduce clock_hz if you get errors.
  • The slave's buffer determines the latency: big enough to cover the jitter, small enough for the delay you accept.

Clone this wiki locally