Skip to content

Communication Serial

pschatzmann edited this page Oct 22, 2026 · 1 revision

A serial (UART) connection is the simplest way to send audio from one microcontroller to another: connect TX of the sender with RX of the receiver (and GND). Every HardwareSerial is an Arduino Stream, so it can be used directly with StreamCopy. The same applies to any other link that is a Stream, e.g. RS485, a Bluetooth serial connection or a radio module in transparent mode. See Communication for the general considerations.

PCM

From send-receive.ino: sender and receiver run on the same ESP32, with RX connected to TX of Serial2:

#include "AudioTools.h"

AudioInfo info(22000, 1, 16);
SineGenerator<int16_t> sineWave(32000);
GeneratedSoundStream<int16_t> sound(sineWave);
I2SStream out;
auto &serial = Serial2;
Throttle throttle(serial);
StreamCopy copierOut(throttle, sound, 256);  // sound -> serial
StreamCopy copierIn(out, serial, 256);       // serial -> I2S

void setup() {
  Serial2.begin(3000000, SERIAL_8N1);
  sineWave.begin(info, N_B4);
  throttle.begin(info);
  auto config = out.defaultConfig(TX_MODE);
  config.copyFrom(info);
  out.begin(config);
}

void loop() {
  copierOut.copy();
  copierIn.copy();
}

The generator provides the data as fast as possible: the Throttle slows it down to the audio rate, so that the receiver is not overrun.

Baud rate: with 8N1 each byte needs 10 bits: 22000 Hz, mono, 16 bit needs 440 kbit/s, 44100 Hz stereo 16 bit needs 1.76 Mbit/s. The ESP32 supports up to about 5 Mbit/s, but the higher the speed, the shorter the cable must be and the more errors you get.

Making it robust

A UART does not detect lost or wrong bytes. With PCM data this is critical: if a single byte is lost, all following samples are misaligned and you only hear noise. You have the following options:

  • 8 bit samples: with EncoderL8 / DecoderL8 a sample is one byte, so a lost byte is just a short click - see send-8bit-receive:
EncoderL8 enc;
DecoderL8 dec;
EncodedAudioStream enc_stream(&serial, &enc);  // sender
EncodedAudioStream dec_stream(&out, &dec);     // receiver
  • Codec: compressing the data reduces the needed baud rate - e.g. IMA ADPCM (4:1) in send-adpcm-receive. An encoded stream however is even more sensitive to data loss.
  • Container: the BinaryContainerEncoder / BinaryContainerDecoder (AudioTools/AudioCodecs/ContainerBinary.h) put each frame into a record with a header and a checksum, so the receiver can detect broken frames and resynchronize - see send-adpcm_framed-receive:
ADPCMEncoder enc(AV_CODEC_ID_ADPCM_IMA_WAV);
ADPCMDecoder dec(AV_CODEC_ID_ADPCM_IMA_WAV);
BinaryContainerEncoder bin_enc(&enc);
BinaryContainerDecoder bin_dec(&dec);
EncodedAudioOutput enc_stream(&serial, &bin_enc);
EncodedAudioOutput dec_stream(&out, &bin_dec);
  • HDLC framing: HDLCStream (AudioTools/Communication/HDLCStream.h) packs each write into a frame with flag bytes (0x7E), byte stuffing and a CRC-16. The receiver only returns complete and valid frames:
HDLCStream hdlc_enc(Serial1, 256);  // stream, max frame size
HDLCStream hdlc_dec(Serial1, 256);
StreamCopy copierOut(hdlc_enc, throttle, 256);  // write frames
StreamCopy copierIn(out, hdlc_dec, 256);        // read valid frames only
  • Forward error correction: HammingFEC<bytecount, block_t> (AudioTools/Communication/HammingFEC.h) and ReedSolomonFEC<bytecount, additional_bytes> (AudioTools/Communication/ReedSolomonFEC.h) add redundant data, so that the receiver can correct wrong bits. They are streams that wrap the serial: ReedSolomonFEC<200, 20> fec(Serial1); and then copy to / from fec.
  • Objects: ObjectStream (AudioTools/Communication/ObjectStream.h) writes each write with a size prefix, so that the receiver reads exactly the same blocks again.

Flow control: sending MP3

If you send encoded data (e.g. an MP3 stream from the internet), a Throttle is not possible: the sender does not know the audio rate. The receiver must tell the sender when to stop. The serial examples show three ways:

  • mp3: hardware flow control with RTS/CTS of the ESP32 UART:
HardwareSerial MP3Serial(1);
...
MP3Serial.begin(115200, SERIAL_8N1);
MP3Serial.setPins(rx, tx, cts, rts);
MP3Serial.setHwFlowCtrlMode(UART_HW_FLOWCTRL_CTS_RTS);
  • mp3-custom: a flow control pin: the receiver collects the data in a buffer and sets the pin HIGH when the buffer is 90% full and LOW below 10%; the sender only copies while the pin is LOW:
// receiver: fill the buffer in a separate task
BufferRTOS<uint8_t> buffer(0);
QueueStream<uint8_t> queue(buffer);
StreamCopy copierFill(queue, Serial1);
StreamCopy copierPlay(dec, queue);
...
task.begin([]() {
  copierFill.copy();
  if (buffer.levelPercent() >= 90) digitalWrite(flowControlPin, HIGH);  // stop
  else if (buffer.levelPercent() <= 10) digitalWrite(flowControlPin, LOW);  // continue
});

// sender
void loop() {
  if (digitalRead(flowControlPin) == LOW) copier.copy();
}
  • mp3-xon-xoff: software flow control: the same logic, but the receiver sends XON (17) / XOFF (19) characters back on its TX line, so no extra pin is needed.

Things to consider

  • Connect GND of both devices. Use a level shifter between 5 V and 3.3 V boards.
  • A big setRxBufferSize() / setTxBufferSize() (call it before begin()) helps against short delays.
  • For a faster and more reliable wired link between two ESP32 see SPI.

Clone this wiki locally