Acoustic Communication Protocol

BanterClient API, codecs, chirp synchronization, and signal pipeline

Acoustic Communication Protocol

OctoMY includes an acoustic communication system — codenamed Banter — that transmits data over audio between nodes using only speakers and microphones. The stack supports multiple codecs (FSK, OFDM, MCFSK, DPSK, CSS), chirp-based synchronization with channel estimation, ARQ reliability, and rate adaptation.

Architecture

The stack is layered (see internal/specs/audio-stack-architecture/ for the full design):

Layer Library Purpose
Consumer libpair, libbanter CommsCarrierBanter (production), BanterLabActivity (tuning)
Protocol libbanter BanterClient, BanterProtocol, BanterTransport, OKChirpCalibrator
DSP composition libbanter BanterDspStack<T> composes Units into the TX/RX chain
DSP primitives libdsp, libmediacore Codecs (IBanterCodec<T> impls) and Unit<T> building blocks
Audio I/O libmediacore AudioInputNode, AudioOutputNode, BufferSourceNode, DAG schedule
Consumer (CommsCarrierBanter, BanterLab)
         |
    BanterClient  (QObject; framing, threading, signals)
         |
    BanterDspStack<T>  (pure C++ template composing Unit<T> primitives)
         |
    IBanterCodec<T>  (FSKCodec, OFDMCodec, MultichannelFSKCodec, DPSKCodec, CSSCodec)
         |
    AudioInputNode / AudioOutputNode / DAG schedule  (hardware I/O via libmediacore)

Hardware I/O is unified through the libmediacore DAG (internal/specs/audio-dag-unification/); the old Speaker / Microphone / AudioPump classes were retired in DAG migration Phase H.

BanterClient API

BanterClient is the single entry point for acoustic communication. It has no widget dependencies and is thread-safe.

Configuration

banter::BanterConfig config;
config.sampleRate = 44100.0f;
config.codec = "FSK";           // or "auto"
config.selfHosted = true;       // creates own audio thread
config.zcRootIndex = 1;         // this node's chirp identity
config.chirpCount = 3;          // chirps per frame (more = better channel estimate)

auto client = QSharedPointer<BanterClient>::create();
client->configure(config);
client->start();

Sending and receiving

// Send a packet
client->send("Hello acoustic world!");

// Receive packets via signal
connect(client, &BanterClient::packetReceived,
    this, [](QByteArray payload) {
        qDebug() << "Received:" << payload;
    });

Operating modes

Mode Description
Self-hosted BanterClient owns an AudioInputNode and AudioOutputNode and drives its own DAG schedule on a dedicated worker thread.
DAG-hosted External code (BanterLabPlayer, lab widgets) calls processRx() / generateTx() from a host schedule.

Key signals

Signal Description
packetReceived(QByteArray) Decoded data packet
packetSent() TX frame complete
chirpLevelMeasured(float) Received signal level (for AGC calibration)
unknownNodeDetected(quint8, QByteArray) Chirp from an unregistered node
frameAttemptComplete(CodecRxStats) RX frame statistics (success/failure, SNR, quality)

Node discovery

BanterClient is stateless regarding identity. It detects chirps and reports; the consumer (e.g. CommsCarrierBanter) handles persistence.

Discovery lifecycle

  1. Node broadcasts beacon (BanterDiscoveryPacket: 24 bytes with "OC" magic, nodeID, goldCode, SF, capabilities)
  2. Receiver detects chirp, emits unknownNodeDetected
  3. Consumer checks address book: known node -> map chirp to ID; unknown -> initiate identity exchange
  4. Identity exchange via fragmented frames (Ed25519 key fits in 1 frame; RSA-2048 needs 2-3 fragments)
  5. Consumer assigns trust level, establishes session

Decision components

Three independent, testable decision-making components manage multi-node coordination:

Component Purpose
BanterCodeManager Gold code (0-32) assignment, persistence, conflict resolution, dynamic reallocation. Ensures no two nodes share the same code.
BanterSFAdvisor Spreading factor (1-32) adaptation based on peer count, BER, desired throughput. Trades data rate for robustness.
BanterBandwidthManager Time-division fairness (1/N channel share per node), exponential backoff on collision, beacon priority over data.

Parameter negotiation

BanterCourier exchanges codec parameters (goldCode, spreading factor, bandwidth stats) between paired nodes. Parameter changes coordinate via the comms courier system.

Protocol frame format

The Banter protocol frame is codec-independent. Synchronization happens entirely via chirps (see Chirp synchronization); the protocol frame contains no preamble of its own.

Audio frame layout

[Chirp × N] [Guard silence] [Header (6 bytes)] [Payload (variable)] [CRC-32C (4 bytes)]

The chirps and the guard live at the audio level — they are generated by BanterDspStack (via ChirpSync) and prepended to the codec output. The codec sees only the framed bytes below.

Header (6 bytes, defined in BanterProtocol.hpp)

Byte Field Description
0 versionAndCodec [7:4] protocol version (currently 1); [3:0] codec ID
1 typeAndFlags [7:4] frame type; [3:0] flags
2-3 payloadLength u16 big-endian, payload byte count
4 senderId Sender identity (1 byte)
5 sequenceNumber Sequence number (1 byte, wraps)

Frame types

Value FrameType Purpose
0 Data User payload
1 Calibration OKChirp calibration exchange
2 Discover Node discovery beacon
3 Ack Acknowledgement (1-byte payload = acked seq)
4 Negotiate Parameter negotiation (key-value payload)

Flag bits

Bit Constant Meaning
0x01 FLAG_DIFF_ENCODED Payload differentially encoded
0x02 FLAG_FEC_ENABLED Payload wrapped in convolutional FEC
0x04 FLAG_COMPRESSED Reserved (LZ4, not yet implemented)
0x08 FLAG_FRAGMENTED Payload is a fragment; first 2 bytes are [fragIndex][fragTotal]

CRC

CRC-32 Castagnoli (polynomial 0x1EDC6F41 / reversed 0x82F63B78), computed over the 6-byte header and the entire payload. One canonical implementation lives at src/libs/libdsp/dsp/util/CRC32C.hpp; both BanterProtocol and every IBanterCodec use it.

Chirp preamble

The frame is preceded by N copies of a Zadoff-Chu sequence. The chirp root index (zcRootIndex in BanterConfig) identifies the sender. Chirps provide three things in one pass: frame synchronization, channel estimation (H), and carrier frequency offset estimation. See ChirpContract/ChirpResult for the codec hand-off.

Codecs

All codecs implement IBanterCodec<T> and live under src/libs/libdsp/dsp/codec/. Five codecs are shipped; pick via BanterConfig::codec or by setting the codec pseudo-param at runtime.

ID Codec Modes Bits/symbol Status
0 FSK 2-FSK, 4-FSK 1–2 Stable; the over-air workhorse
1 OFDM BPSK, QPSK, 16-QAM, bit loading 1–4 per subcarrier Standalone (replaced adapter pattern)
2 MCFSK N × (2-FSK or 4-FSK) parallel channels N × (1–2) Higher throughput when band allows
3 DPSK DBPSK, DQPSK 1–2 No absolute phase reference needed
4 CSS LoRa-style chirp spread spectrum, SF=5–12 Variable Long-range / low-SNR

FSK (Frequency Shift Keying)

The primary codec. Binary FSK uses two tones (mark and space) to encode bits.

Parameter Default Description
baudRate 300 Symbols per second
markFreq 1600 Hz Frequency for binary 1
spaceFreq 2400 Hz Frequency for binary 0
txAmplitude 1.0 TX signal amplitude
toneShaping 0.1 Raised-cosine ramp fraction (reduces spectral splatter)
goertzelWindow 0.85 Fraction of symbol period for Goertzel integration
guardSamples 0 Silent samples between symbols
fecEnabled false Forward error correction (rate-1/2 convolutional + interleaver)
diffEncoding false Differential encoding (eliminates polarity ambiguity)
numTones 2 2-FSK or 4-FSK (4-FSK doubles throughput)

FSK implementation details

Modulation: Phase-continuous oscillator with cosine-tapered amplitude ramps (configurable via toneShaping) at symbol boundaries. Phase continuity prevents spectral splatter; amplitude ramping prevents audible clicks.

Demodulation: Goertzel single-frequency DFT at mark and space frequencies. Chosen over FFT because: (1) O(N) vs O(N log N) for two frequencies, (2) deterministic integration window matching symbol period, (3) no complex output needed. Goertzel coefficient: coeff = 2 * cos(2 * pi * f / Fs).

RX state machine: Idle -> Preamble Detection -> Receiving -> Complete. Carrier detection uses adaptive threshold learned from preamble energy.

Channel access: Half-duplex with CSMA (carrier sense, multiple access). Random exponential backoff on collision. Half-duplex was chosen over full-duplex because echo cancellation (needed for same-band full-duplex) requires precise acoustic coupling models that vary wildly across devices.

Performance: At 300 baud: 37.5 bytes/sec throughput, ~0.5 sec per 10-byte message, ~300 multiplies per Goertzel evaluation per bit.

MCFSK (Multichannel FSK)

Divides the frequency band into N parallel FSK channels for N-times throughput. Payload is split round-robin across channels.

OFDM

dsp::OFDMCodec<T> is a standalone implementation (it replaced the earlier OFDMCodecAdapter → BanterTxCore chain). Modes: BPSK, QPSK, 16-QAM, and per-subcarrier bit loading driven by CQI feedback. Synchronization is shared with the rest of the stack via chirps (ChirpContract::probeSubcarriers carries the pilot indices); no Schmidl–Cox preamble in the protocol frame.

DPSK (Differential PSK)

dsp::DPSKCodec<T> provides DBPSK and DQPSK. Useful when absolute phase recovery is unreliable; pairs naturally with the chirp's CFO output.

CSS (Chirp Spread Spectrum)

dsp::CSSCodec<T> is LoRa-style chirp spread spectrum at spreading factors 5–12. Trades throughput for processing gain.

Codec interfaces

Codecs implement three interfaces:

Interface Methods Purpose
IBanterCodec<T> Combined surface Top-level codec interface
IModulator<T> modulate(), generate(), enqueue(), isBusy() TX-only
IDemodulator<T> feedSamples(), startFrame(), lastStats() RX-only

Codec lifecycle is mediated by ChirpContract / ChirpResult (dsp/sync/ChirpContract.hpp): a codec declares its sync requirements (symbol period, FFT/CP sizes for OFDM, probe subcarriers) via chirpRequirements(), and ChirpSync later hands back timing, channel H, CFO, and SNR via startFrame().

Chirp synchronization

Frame synchronization uses Zadoff-Chu (ZC) sequences — wideband chirps with excellent autocorrelation properties.

How it works

  1. TX sends N copies of a ZC chirp (the preamble)
  2. RX continuously cross-correlates incoming samples against the reference chirp
  3. When normalized correlation exceeds the threshold, a sync event fires
  4. The pipeline accumulates N chirp detections, then delivers a ChirpResult to the codec
  5. The codec begins data demodulation at the computed data start position

Coherent summing

When multiple chirps are transmitted, the correlator can sum them coherently for an SNR gain of +10 log10(N) dB. For N=10 chirps, this is +10 dB improvement in detection sensitivity.

Channel estimation

At detection time, the correlator measures the received chirp's energy at the FSK mark and space frequencies using Goertzel filters. The ratio of received-to-reference energy gives per-tone channel gain, which the codec uses to calibrate its demodulation thresholds.

Signal preprocessing pipeline

BanterDspStack<T> composes a chain of Unit<T> primitives between hardware I/O and the codec. All stages are independently configurable through the parameter registry; the same stack is reused by self-hosted BanterClient, BanterLabPlayer, and the lab widgets.

RX pipeline order

Mic samples
  -> ChirpSync (non-destructive monitoring)
  -> Adaptive EQ (NLMS-trained FIR from chirp analysis)
  -> Transducer compensation (mic post-EQ)
  -> Channel equalization (from chirp channel estimate)
  -> Post-EQ FIR (legacy manual override)
  -> CFO correction (carrier frequency offset)
  -> Noise reduction (FFT spectral subtraction)
  -> Bandpass filter (isolate codec frequency band)
  -> Codec input

TX pipeline order

Codec output
  -> Transducer compensation (speaker pre-distortion + pre-emphasis)
  -> Polynomial pre-distortion (legacy manual override)
  -> Pre-emphasis FIR (legacy manual override)
  -> AudioOutputNode (DAG sink to hardware)

Implemented DSP units

All streaming DSP units live in libmediacore/audio/units/ and inherit Unit<T>:

Unit Purpose
BandpassUnit<T> 4th-order IIR bandpass (RBJ biquad cascade)
FirUnit<T> General-purpose FIR filter (circular delay line)
NlmsUnit<T> Normalized LMS adaptive filter (echo cancellation, EQ training)
PolyDistortionUnit<T> Polynomial pre-distortion for speaker nonlinearity
DcBlockerUnit<T> First-order DC blocker (high-pass at ~7 Hz)
CfoCorrectionUnit<T> Carrier frequency offset correction (phase rotation)
ChirpCorrelatorUnit<T> Single-chirp matched filter with circular buffer
CoherentCorrelatorUnit<T> N-chirp coherent summing with peak tracking

AudioNode wrappers

Compound processors are wrapped as AudioNode subclasses in libmediacore/audio/nodes/ for DAG composition:

Node Wraps Purpose
ChirpSyncNode<T> dsp::ChirpSync<T> Monitoring node (pass-through + chirp detection)
OverlapAddNode<T> dsp::OverlapAddProcessor<T> FFT-based noise reduction
CodecNode<T> dsp::IBanterCodec<T> Dual RX/TX codec wrapper
FskModulatorNode<T> dsp::IModulator<T> TX-only source node
FskDemodulatorNode<T> dsp::IDemodulator<T> RX-only monitoring node
SchmidlCoxNode<T> dsp::SchmidlCoxSync<T> OFDM preamble detection

AGC (Automatic Gain Control)

The AGC normalizes received signal levels before demodulation. Multiple strategies are available because AGC behavior critically affects decode success over air.

Mode Name Behavior
0 Off No gain adjustment
1 Normal Envelope follower: 1ms attack, 100ms release
2 Freeze on signal Tracks during silence, freezes gain when carrier detected
3 Block RMS One gain value per 1024-sample block
4 Primer-locked Fast convergence during 300ms noise primer, locks gain for data frame

Key finding from OTA testing: The AGC is the primary bottleneck for over-air decode. Gain changes during data bits distort the mark/space amplitude ratio that Goertzel depends on. Mode 4 (PrimerLocked) is the most promising: a 300ms ramped white noise burst before chirps gives the AGC a stable broadband reference to converge on, then gain locks for the entire data frame.

AGC primer

When enabled, TX emits a 300ms (configurable) ramp of white noise before the chirp preamble. This gives the RX-side AGC enough broadband signal to converge to a stable gain. Without the primer, AGC has only ~60ms of chirp transient, leading to wildly inconsistent gain selection.

Test channel simulation

AudioTestChannel provides configurable impairments for testing without hardware:

Impairment Description
Additive noise Gaussian white noise at configurable SNR
Clipping Hard clipping at configurable threshold
Dropout Random sample zeroing at configurable rate
Echo Delayed copy at configurable delay and gain
Jitter Random sample timing perturbation
DC offset Constant offset added to signal

Design rationale

Why half-duplex

Full-duplex was considered via two approaches:

  • FDD (Frequency Division Duplex): Different TX/RX bands. No echo cancellation needed (bandpass filters separate signals). Halves available bandwidth.
  • Echo cancellation: NLMS adaptive filter to subtract own TX from RX. Requires precise acoustic coupling model; speaker-to-mic leakage varies wildly across cheap hardware.

Half-duplex with CSMA is simplest and sufficient for short messages (pairing, verification, commands).

Why Goertzel over FFT

For detecting two known frequencies (mark/space), Goertzel is:

  • O(N) per frequency vs O(N log N) for full FFT
  • Deterministic integration window matching exactly one symbol period
  • Produces magnitude directly (no complex output to post-process)

Why 1600/2400 Hz

Cheap speakers and MEMS microphones have a response peak at 1000-3000 Hz. The 800 Hz mark-space separation (1600 Hz mark, 2400 Hz space) places both frequencies in this sweet spot while providing enough separation for reliable Goertzel discrimination. Wider separation improves detection but risks one frequency falling outside the hardware's usable range.

Channel access strategy

Five strategies were evaluated for multi-node coordination:

  1. No handling (collisions corrupt data)
  2. CSMA (carrier sense + random backoff) -- chosen
  3. CSMA/CA (collision avoidance with RTS/CTS)
  4. TDMA (time-division: assigned slots)
  5. FDD (frequency-division: assigned bands)

CSMA was chosen as the simplest approach that works for <10 nodes with short messages.

BanterLabPlayer

BanterLabPlayer is a thin host that runs the acoustic pipeline through the libmediacore DAG without requiring the BanterLab UI.

  • Owns a BanterClient and its DAG schedule
  • Drives audio I/O via the standard AudioInputNode / AudioOutputNode plus an optional BufferSourceNode for scripted TX
  • Built-in bus loopback (TX feeds back to RX for testing)
  • Thread-safe configuration via stop/configure/start pattern
  • Signals forwarded via Qt::QueuedConnection

Performance

Digital bus mode (no audio hardware): 100% decode rate across all test conditions.

Over-air results (speaker-to-microphone):

Configuration Sync rate Decode rate
Baseline (no DSP features) ~24% 4%
+ FEC + soft Viterbi 76% 46%
+ Chirp-driven frame start 100% 28%
+ Channel EQ 100% 65%

Note: Over-air performance depends heavily on hardware, room acoustics, and AGC configuration. The auto-test infrastructure (see BanterLab reference) provides systematic parameter tuning.

Source files

Protocol reliability layer

BanterTransport

BanterTransport wraps a BanterClient with stop-and-wait ARQ (Automatic Repeat reQuest). It layers on top via composition — existing code using BanterClient directly continues to work unchanged.

BanterTransport transport(&client);
transport.setSenderId(1);
transport.sendReliable("Hello");
// emits deliveryComplete(payload, attempts) on ACK
// emits deliveryFailed(payload, attempts) after max retransmits

ACK frame

The existing FrameType::Ack carries a 1-byte payload: the acknowledged sequence number. On receiving a Data frame, the receiver sends an ACK. The sender retransmits on timeout (default 3 seconds, configurable).

Payload segmentation

Payloads larger than MTU (default 200 bytes) are automatically split into fragments. Each fragment is sent via stop-and-wait ARQ, then reassembled on the receiver. Fragment header (2 bytes): [fragIndex][fragTotal], indicated by FLAG_FRAGMENTED (0x08) in the frame flags.

BanterLinkStats tracks EMA-smoothed per-link metrics from existing codec signals:

  • SNR (dB) — from chirp preamble correlation peak/floor ratio
  • RSSI (linear RMS) — from chirp received amplitude
  • PER (0.0-1.0) — from CRC pass/fail rate

MCS rate adaptation

5 discrete MCS (Modulation and Coding Scheme) levels, selected automatically by CommsCarrierBanter based on smoothed SNR with 3 dB hysteresis. Rate changes are guarded during active sends to prevent mid-frame codec reconfiguration:

Level Baud FEC Repeat Min SNR Throughput
0 50 Yes 3 -3 dB ~1 B/s
1 100 Yes 2 3 dB ~3 B/s
2 200 Yes 1 8 dB ~10 B/s
3 300 Yes 1 12 dB ~15 B/s
4 300 No 1 18 dB ~37 B/s

Production integration

CommsCarrierBanter is injected into the comms stack via Comms::setAudioCarrier(). The carrier is created in DiscoveryClient::discoverBanter() (in libpair, which has access to both libcomms and libbanter). The UI toggle is already wired: ChannelSelectorWidget::audioToggled() → Comms::toggleAudio() → mCarrierAudio->maintainConnection(). Mandate persistence saves and restores the useAudio state across restarts.

Source files

Component Location
BanterClient src/libs/libbanter/banter/BanterClient.{hpp,cpp}
BanterDspStack src/libs/libbanter/banter/BanterDspStack.hpp
BanterLabPlayer src/libs/libbanter/banter/BanterLabPlayer.{hpp,cpp}
BanterCodeManager src/libs/libbanter/banter/BanterCodeManager.{hpp,cpp}
BanterSFAdvisor src/libs/libbanter/banter/BanterSFAdvisor.hpp
BanterBandwidthManager src/libs/libbanter/banter/BanterBandwidthManager.hpp
BanterProtocol src/libs/libbanter/banter/BanterProtocol.hpp
BanterTransport src/libs/libbanter/banter/BanterTransport.{hpp,cpp}
BanterLinkStats src/libs/libbanter/banter/BanterLinkStats.hpp
BanterMcsTable src/libs/libbanter/banter/BanterMcsTable.hpp
BanterEnergyScanner src/libs/libbanter/banter/BanterEnergyScanner.hpp
ChannelEqualizer src/libs/libbanter/banter/ChannelEqualizer.hpp
TransducerCompensator src/libs/libbanter/banter/TransducerCompensator.hpp
OKChirpCalibrator src/libs/libbanter/banter/OKChirpCalibrator.hpp
BanterDiscovery src/libs/libbanter/discovery/BanterDiscovery.{hpp,cpp}
BanterDiscoveryPacket src/libs/libbanter/discovery/BanterDiscoveryPacket.{hpp,cpp}
CommsCarrierBanter src/libs/libbanter/comms/carriers/CommsCarrierBanter.{hpp,cpp}
Codecs (FSK, OFDM, MCFSK, DPSK, CSS) src/libs/libdsp/dsp/codec/
ChirpSync, ChirpContract src/libs/libdsp/dsp/sync/
CRC32C utility src/libs/libdsp/dsp/util/CRC32C.hpp
AudioTestChannel test/testBanter/AudioTestChannel.hpp
Streaming units (Unit<T>) src/libs/libmediacore/mediacore/audio/units/
AudioNode wrappers src/libs/libmediacore/mediacore/audio/nodes/
Topics
reference communications acoustic banter dsp