Skip to content

Arduino

pschatzmann edited this page Oct 29, 2026 · 4 revisions

Include AudioTools.h and then TinyModem.h, or TinyModemRTOS.h if you want to use the stream classes which receive in a separate task. All classes are in the tinymodem namespace, which is imported automatically. Define TINYMODEM_NO_NAMESPACE before the include to prevent that.

Classes

Each modem type has the same set of classes:

Modem type Configuration Encoder Decoder Stream Stream with receive task
FSK ModemConfig FSKEncoder FSKDecoder ModemStream ModemStreamRTOS
MFSK MFSKConfig MFSKEncoder MFSKDecoder MFSKStream MFSKStreamRTOS
PSK PSKConfig PSKEncoder PSKDecoder PSKStream PSKStreamRTOS
OFDM OFDMConfig OFDMEncoder OFDMDecoder OFDMStream OFDMStreamRTOS
AX.25 AX25Config AX25Encoder AX25Decoder AX25Stream AX25StreamRTOS
ggwave GGWaveConfig GGWaveEncoder GGWaveDecoder GGWaveStream GGWaveStreamRTOS
Olivia OliviaConfig OliviaEncoder OliviaDecoder OliviaStream OliviaStreamRTOS
CSS CSSConfig CSSEncoder CSSDecoder CSSStream CSSStreamRTOS
Morse MorseConfig MorseEncoder MorseDecoder MorseStream MorseStreamRTOS
DTMF DTMFConfig DTMFEncoder DTMFDecoder DTMFStream DTMFStreamRTOS
  • Configuration: extends the AudioTools AudioInfo (sample_rate, channels, bits_per_sample). setMode() selects a predefined mode (e.g. "1200", "mfsk16", "psk31", "qpsk63", "ofdm32", "aprs", "il2p", "ggwave-fast", "olivia-8-250", "css7", "morse-american", "dtmf"): call it first and then change individual fields. The default sample rate is 48000 Hz.
  • Encoder: converts the written data into 16-bit PCM audio. An AudioTools AudioEncoder (base class ModemEncoder).
  • Decoder: converts PCM audio (8, 16 or 32 bit, 1 to 8 channels) into data. An AudioTools AudioDecoder (base class ModemDecoder).
  • Stream: an Arduino Stream with both: print() transmits, read() receives (template BasicModemStream).
  • Stream with receive task: decodes in a FreeRTOS task (template BasicModemStreamRTOS).

In addition:

Class Purpose
PacketStream, PacketEncoder, PacketDecoder Packets with CRC and Reed-Solomon error correction over any Stream
AX25Frame Conversion between AX.25 frames, the text format and KISS
FSKToneGenerator Default tone generator of the FSK, MFSK and AX.25 encoders, see tone generator
Varicode, MFSKVaricode The PSK31 Varicode and the MFSK Varicode (8PSK)
QPSKCode The convolutional code of QPSK with the Viterbi decoder
FX25, IL2P The AX.25 frame formats with Reed-Solomon error correction
OliviaCodec The Walsh-Hadamard error correction of Olivia and Contestia
CSSFrame The frame format of CSS
DTMFKeys The tones of the DTMF keys
MorseCode The International and American Morse code tables and timing
RSCodec (GGWaveRS) Reed-Solomon code with lengths defined at runtime (first root α⁰ for ggwave and IL2P, α¹ for FX.25)
SampleRateConverter, SimpleFFT Sample rate conversion with a windowed sinc filter and a radix-2 FFT (Olivia, CSS)

Audio hardware

The modems only produce and consume PCM samples, so you can use any AudioTools stream for the audio input and output:

  • I2SStream: an I2S DAC, ADC, microphone or audio codec (e.g. AudioBoardStream for an AudioKit)
  • AnalogAudioStream: the built-in DAC and ADC (e.g. of the ESP32)
  • PWMAudioOutput: PWM output with a low-pass filter
  • files, memory or a network connection, if the signal should stay digital

A speaker and a microphone (an acoustic path) are the hardest case: use MFSK. With a cable from a DAC to an ADC, all modem types work, as long as the sample rates of the two devices match closely enough (OFDM needs ±0.2%, see the comparison). Make sure that the input level doesn't clip and that the signal isn't too weak (see the ampl= value of the status messages).

Streams

The stream classes hide the encoder and decoder behind the Arduino Stream API:

ModemStream modem(audio_out);            // transmit only
ModemStream modem(audio_out, audio_in);  // transmit and receive
ModemStream modem(i2s, RX_MODE);         // one stream: TX_MODE, RX_MODE or RXTX_MODE
  • begin(cfg, rx_buffer_size) starts the modem. The input is only read while less than rx_buffer_size decoded bytes (default 256) are waiting to be read. Decoded data is never dropped.
  • write() / print() modulate the data. flush() ends the transmission (OFDM: sends the collected frame, ggwave: sends the collected message).
  • available(), read() and peek() read the input stream and decode it in the calling task. Call one of them regularly in loop(). The decoding processes a block of audio at a time, which blocks loop() for a moment: use the RTOS version if that's a problem.
  • setStatusOutput(Serial) prints the ### CARRIER / ### NOCARRIER messages.
  • getEncoder() and getDecoder() give access to the codecs.

Receiving in a separate task

The RTOS versions (ModemStreamRTOS, MFSKStreamRTOS, …) have the same API, but a separate task reads the audio input and decodes it. loop() is never blocked by the decoding, and no audio is lost while loop() is busy. available(), read() and peek() only access the decoded data and can be called from any task.

#include "AudioTools.h"
#include "TinyModemRTOS.h"

I2SStream i2s;
MFSKStreamRTOS modem(i2s, RXTX_MODE);

void setup() {
  ...
  modem.setTaskConfig(8 * 1024, 1, 0);  // optional: stack, priority, core
  modem.begin(cfg);
}
  • Supported on ESP32, on RP2040 and on the desktop (with std::thread). On the RP2040 select Tools > Operating System > FreeRTOS SMP and add #include <FreeRTOS.h> before #include "AudioTools.h".
  • Transmitting (write(), print(), flush()) still happens in the calling task. The status output is written by the receive task.
  • setTaskConfig(stack_size, priority, core) must be called before begin(). The default stack is 8 KB (on ESP32 the size is in bytes, on other FreeRTOS platforms in words). On ESP32, core 0 keeps the decoding away from loop(), which runs on core 1.
  • end() waits until the task has finished the current decoding step, then stops it.

Using the AudioTools pipeline

The encoders and decoders are regular AudioTools codecs, so you can wrap them in an EncodedAudioOutput and combine them with any AudioTools stream (I2S, analog, A2DP, files, …).

Transmit

I2SStream i2s;
MFSKEncoder encoder;
EncodedAudioOutput modem(&i2s, &encoder);  // Print: data in, PCM to i2s
MFSKConfig cfg;

void setup() {
  auto i2s_cfg = i2s.defaultConfig(TX_MODE);
  i2s_cfg.copyFrom(cfg);
  i2s.begin(i2s_cfg);
  encoder.begin(cfg);  // modem settings
  modem.begin(cfg);    // activates the output
}

void loop() {
  modem.println("Hello");
  encoder.endTransmission(cfg.sample_rate);  // end + 1 second of silence
}

endTransmission(silence_samples) ends a transmission (FSK: trailer bits, MFSK: end marker, PSK: postamble, OFDM: sends the frame, AX.25: tail flags, ggwave: sends the message). writeIdle(n) sends an idle signal where the modem has one (FSK: mark tone, PSK: phase reversals, AX.25: flags), and writeSilence(n) / writeTone(freq, n) write raw audio.

Receive

I2SStream i2s;
MFSKDecoder decoder;
EncodedAudioOutput modem(&Serial, &decoder);  // PCM in, data to Serial
StreamCopy copier(modem, i2s);
MFSKConfig cfg;

void setup() {
  ...
  decoder.setStatusOutput(Serial);  // optional: ### CARRIER / ### NOCARRIER
  decoder.begin(cfg);
  modem.begin(cfg);
}

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

The decoders accept 8, 16 or 32-bit signed samples with up to 8 channels. Only cfg.channel is decoded. If the input reports a new AudioInfo, for example from a WAV header, the decoder restarts with the new format. Other methods: isCarrier(), isFinished() (after the first transmission with rx_one: the decoder ignores further input, also after flush(), until begin() is called again), setQuiet(), and flush() / end() to process the remaining buffered samples.

Tone generator

The FSK, MFSK and AX.25 encoders produce the tones with an AudioTools SoundGenerator<int16_t>. The default FSKToneGenerator reproduces minimodem's signal. You can replace it with setGenerator() before begin():

FastIntSineGenerator<int16_t> sine;  // fixed point: no FPU needed
FSKEncoder encoder;
encoder.setGenerator(sine);

With a stream, use modem.getEncoder().setGenerator(sine). The generator must switch the frequency immediately and keep the phase continuous in setFrequency(). That's the case for FastIntSineGenerator (recommended for MCUs without an FPU, like the RP2040). FloatSineGenerator and FastSineGenerator reset the phase, and SineFromTable only changes the frequency at the end of a cycle, so they don't work. The generator should produce full scale: the encoder applies the volume and writes silence itself. PSK, OFDM and ggwave generate their signal themselves.

Memory and CPU

Modem type Decoder memory at 8000 Hz
FSK 2.6 KB (1200 baud), 12.4 KB (RTTY)
MFSK 8 symbols of samples: 2 KB (mfsk16), 8.5 KB (mfsk64)
PSK 3 symbols of samples: 3 KB (psk31), 0.2 KB (psk500)
OFDM 6.6 KB (ofdm32)
AX.25 less than 1 KB
ggwave 23 KB at 24000 Hz (DT: 12.5 KB). ggwave needs at least 12000 Hz

The memory grows with the sample rate: use the lowest sample rate your hardware and the mode support. The encoders need less than 1 KB (OFDM: 1 KB for the DFT tables and the frame of 512 bytes, ggwave: 4 KB at 24000 Hz for the sine table). The RTOS streams need a task stack (8 KB by default) in addition, and packets about 650 bytes of stack.

The speed of the modems on a PC and on microcontrollers (ESP32-S3, ESP32-P4) is documented in Performance. Run the benchmark example to check your board.

The library needs float math and vsnprintf with %f support (for the status messages). It runs on ESP32-S3, ESP32-P4, RP2040 and RP2350 (see Performance; the RP2040 is too slow for Olivia) and compiles for the other ESP32 variants. The objects of the Olivia and CSS decoders and of the AX.25 decoder are 2.4 to 2.9 KB: avoid many of them as local variables in loop() (8 KB stack), declare them as globals or allocate them with new. AVR boards don't have enough RAM.

Troubleshooting

  • begin() returns false: the reason is logged as an error. Activate the logger with AudioToolsLogger.begin(Serial, AudioToolsLogLevel::Warning). Typical causes: the decoders only accept 8, 16 or 32-bit samples (configure 24-bit I2S microphones as 32 bits), the encoders only produce 16 bits, a tone is above half the sample rate, and Caller-ID and UIC can't be transmitted.
  • Nothing is received: activate the status output with setStatusOutput(Serial). If you never see ### CARRIER, check the input channel: many I2S microphones deliver the signal on only one channel, so try cfg.channel = 1. Check that the sample rate in cfg matches the real sample rate of your audio input, and that both sides use the same mode.
  • Wrong or missing characters: the ampl= value of the status messages shows the input level (1.0 = full scale). Values below about 0.01 are too weak, values close to 1.0 may be clipped: adjust the volume on the transmitting side. Use a more robust modem (MFSK) or packets.
  • OFDM doesn't work over the air: OFDM needs a good signal and closely matching clocks (±0.2%). Try MFSK.
  • ggwave: begin() returns false at 44100 Hz: the sample rate must be a multiple of 187.5 Hz, e.g. 48000 or 24000 Hz.
  • Data is lost while receiving: the decoder must get the audio in real time. Call available() or read() often enough, or use the RTOS version of the stream. Check the speed with the benchmark example.
  • RP2040 compile error "select Tools > Operating System > FreeRTOS SMP": the RTOS streams need FreeRTOS, see above. The other classes don't.

Examples

Clone this wiki locally