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
- Node broadcasts beacon (BanterDiscoveryPacket: 24 bytes with "OC" magic, nodeID, goldCode, SF, capabilities)
- Receiver detects chirp, emits
unknownNodeDetected - Consumer checks address book: known node -> map chirp to ID; unknown -> initiate identity exchange
- Identity exchange via fragmented frames (Ed25519 key fits in 1 frame; RSA-2048 needs 2-3 fragments)
- 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
- TX sends N copies of a ZC chirp (the preamble)
- RX continuously cross-correlates incoming samples against the reference chirp
- When normalized correlation exceeds the threshold, a sync event fires
- The pipeline accumulates N chirp detections, then delivers a
ChirpResultto the codec - 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:
- No handling (collisions corrupt data)
- CSMA (carrier sense + random backoff) -- chosen
- CSMA/CA (collision avoidance with RTS/CTS)
- TDMA (time-division: assigned slots)
- 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
BanterClientand its DAG schedule - Drives audio I/O via the standard
AudioInputNode/AudioOutputNodeplus an optionalBufferSourceNodefor 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.
Link quality tracking
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/ |