Skip to content
pschatzmann edited this page Oct 29, 2026 · 1 revision

Chirp spread spectrum, like LoRa but in the audio band: each symbol is a chirp, a tone whose frequency rises linearly over the bandwidth (1000 Hz around 1500 Hz by default). The symbol value is the frequency at which the chirp starts: when it reaches the upper band edge, it continues at the lower edge. With a spreading factor SF there are 2^SF start frequencies, so each symbol carries SF bits. The receiver multiplies the chirp with the conjugate chirp (dechirping), which turns it into a single tone, and finds its frequency with an FFT. CSS is an extension of TinyModem: it is not compatible with LoRa radios or other programs.

Advantages

  • Very robust: css9 decodes about 12 dB below the noise (in 3 kHz bandwidth), similar to Olivia.
  • Each step of the spreading factor doubles the symbol duration and improves the sensitivity by about 2.5 dB: you can trade speed against robustness.
  • Robust against frequency offsets (±60 Hz with css7) and different clocks (see below).
  • The data is sent in frames with a length header, a CRC-16 and Reed-Solomon error correction.

Disadvantages

  • Slow: 10 to 156 bit/s.
  • The data is sent in frames of up to 200 bytes: there is a delay until a frame is full or flush() is called.
  • The receiver needs more CPU than the simple modems (an FFT of 2 × 2^SF points per symbol).
  • Not compatible with LoRa or other programs.

Modes

Mode SF Bandwidth Symbol Data rate
css5 5 1000 Hz 32 ms 156 bit/s
css6 6 1000 Hz 64 ms 94 bit/s
css7 7 1000 Hz 128 ms 55 bit/s
css8 8 1000 Hz 256 ms 31 bit/s
css9 9 1000 Hz 512 ms 18 bit/s
css10 10 1000 Hz 1024 ms 10 bit/s

css is css7. The bandwidth can be added to the name, e.g. css9-500 or css6-2000: half the bandwidth doubles the symbol duration (half the data rate) and narrows the signal. The data rate is the raw rate: the header, the CRC and the Reed-Solomon parity (8 bytes per frame by default) come on top.

Usage

I2SStream i2s;
CSSStream modem(i2s, RXTX_MODE);

void setup() {
  CSSConfig cfg = modem.defaultConfig();  // css7
  cfg.sample_rate = 16000;
  ...
  modem.begin(cfg);
}

void loop() {
  modem.println("Hello");
  modem.flush();  // sends the frame
  while (modem.available()) Serial.write(modem.read());
}
echo "Hello" | tinymodem --tx -f hello.wav css7
tinymodem --rx -f hello.wav css7

CSSConfig, CSSEncoder, CSSDecoder, CSSStream and CSSStreamRTOS are the CSS classes. The encoder collects the written bytes and sends a frame when max_frame bytes were written or when flush() (or endTransmission()) is called. On the command line, -R, -v, -M (center frequency), -q, --rx-one, --packets and --Xrxnoise are supported.

CSSConfig field Default
spreading_factor 7 Bits per symbol (5 - 10)
bandwidth 1000 Bandwidth of the chirps (Hz)
center_freq 1500 Center frequency (Hz)
volume 1.0 Transmit amplitude
preamble_symbols 8 Transmitter: up chirps before the sync symbols
max_frame 64 Transmitter: maximum number of bytes per frame (1 - 200)
ecc_bytes 8 Reed-Solomon parity bytes per frame (2 - 64): corrects ecc_bytes / 2 wrong bytes. Sender and receiver must use the same value
detect_threshold 8.0 Receiver: min ratio of the peak power to the mean power of the other FFT bins
rx_one false Receiver: stop after the first frame
channel 0 Receiver: input channel
sample_rate, channels, bits_per_sample 48000, 1, 16 Audio format

How it works

A frame consists of:

  1. The preamble: 8 up chirps with the value 0.
  2. Two down chirps: the frequency falls over the bandwidth. A time offset moves the peak of the up chirps and the down chirps in opposite directions, a frequency offset in the same direction: so the receiver can determine both from the peaks.
  3. The header: the length of the frame with 4 Reed-Solomon parity bytes.
  4. The payload: the data, a CRC-16 and ecc_bytes Reed-Solomon parity bytes.

The bytes are sent as SF bits per symbol, most significant bit first. The symbol value is the inverse Gray code of the bits: the most likely error (the neighboring FFT bin) only changes one bit.

The receiver mixes the audio to the complex baseband and converts it to twice the bandwidth (2 samples per chip) with a windowed sinc filter. The FFT of the dechirped window has 2 × 2^SF bins: the part of the chirp before the wrap around and the part after it are separate tones, whose power is added. This makes the detection robust against any time offset of the window. The fractional position of the peak is calculated with Quinn's estimator.

The preamble is detected when 3 consecutive windows have their peak in the same bin. Then the window is aligned to the symbols and the receiver waits for the down chirps, which provide the time and frequency offset. The change of the preamble peaks from window to window is the drift caused by a different clock of the sender: if it is clearly measurable, the receiver resamples the following symbols with the clock of the sender. During the frame a second order loop follows the remaining frequency offset and drift, and the window is moved by a whole chip when the timing has drifted by one chip.

Measured performance

Measured with the unit tests and the command line tool at 8000 Hz:

  • Noise: added uniform noise (--Xrxnoise) up to 3 (css7) or 6 (css9) is decoded without errors: about -6.5 dB and -12.5 dB SNR in 3 kHz bandwidth. Beyond that, frames are lost.
  • Frequency offset: ±60 Hz (css7).
  • Different clocks of sender and receiver (sound cards usually differ by less than 0.01%):
Mode Clock difference
css5, css6 ±1%
css7, css8 ±0.2 - 0.3%
css9 ±0.1%
css10 ±0.05%
  • Speed at 8000 Hz: modulation and demodulation about 5 × real time on the ESP32-S3 and 7 - 8 × real time on the ESP32-P4 (css7 and css10); the RP2040 demodulates just in real time (1.0), without headroom, see Performance.
  • Over the air on the ESP32-P4 (speaker to the onboard microphone of the same board): css7 and css9 were received without errors.

Clone this wiki locally