# Reliability — FEC and NACK [← Home](Home.md) The voice protocol uses a two-stage reliability scheme: 1. **Reed-Solomon FEC** absorbs sub-`parity_count` losses with no round-trip. 2. **Selective NACK** with bitmap recovers larger losses in a single round-trip. The combination keeps best-case latency low while bounding worst-case airtime. --- ## Reed-Solomon FEC Implementation: [`reed-solomon-erasure`](https://docs.rs/reed-solomon-erasure) crate over **GF(2⁸)**, with `(total_data, parity_count)` shards. ### Sender 1. Split audio into `total_data` chunks of `chunk_size` bytes (zero-pad the last chunk). 2. RS-encode `parity_count` parity shards. 3. Send all `total_data + parity_count` shards (padding stripped on the final DATA frame; receivers re-pad for FEC math). ### Receiver A receiver MUST be able to reconstruct the message if it has any **`total_data`** shards out of the `total_data + parity_count` total — any combination of DATA and PARITY shards counts toward the threshold. ### Choosing `parity_count` `parity_count` is sender policy, expressed as a percentage of `total_data`: | Mesh profile | `parity_count` | |---------------------------------------|----------------| | Short / quiet | 10 % | | Medium / mixed | 20 % | | Long / lossy | 33 % | | Broadcast (no NACK feedback channel) | 50 % | `parity_count = 0` is allowed — it disables FEC entirely. NACK still works. ### Why GF(2⁸)? Byte-aligned, 256-shard ceiling fits perfectly in `u8` indices. No bit-shuffle pre/post processing. Throughput on commodity hardware is well above what LoRa airtime can deliver. --- ## Selective NACK When loss exceeds `parity_count`, the receiver issues a NACK after a **quiet period** of `NACK_WINDOW_MS` (default 3000 ms) since the last chunk arrived for that message. ### Bitmap A bitmap of length `⌈total_data / 8⌉` lists missing DATA chunks (one bit per data shard). The sender retransmits only those chunks. On a single NACK round, all missing chunks are listed in **one** bitmap — there's no chunk-by-chunk retry. ### Round budget `NACK_MAX_ROUNDS = 400` per message of **consecutive rounds without progress** - the counter is reset every time a new shard lands, so a sender that's still actively servicing every NACK round keeps the assembly slot alive indefinitely (capped only by `message_timeout`, default 1200 s). After 400 NACK rounds in a row with zero new chunks the receiver gives up and either emits a partial message (`partial_play_on_timeout = true`, the default) or discards the work. At a `nack_window` of 3000 ms that's a ~1200 s ceiling on a truly silent sender. The `400` is only the seed default. Hosts that change `message_timeout` or `nack_window` (a GUI slider, a CLI flag, a LoRa preset change) call [`AssemblerConfig::sync_nack_cap_to_timeout`](https://github.com/voicetastic/voicetastic-core/blob/main/crates/voicetastic-core/src/voice/assembler/config.rs), which re-derives `max_nack_rounds = ⌈message_timeout / nack_window⌉` so the user-configured per-message timeout - not the round cap - is always the practical ceiling. `1200 s / 3 s = 400`. > Earlier revisions used a *cumulative* counter that never reset. That > turned out to be indistinguishable from genuine silence on healthy > slow-trickle messages — a 67-chunk Long-Slow broadcast could rack up > 32 productive rounds long before delivery and surface a phantom > `partial: 47/51 chunks` line. The consecutive semantic preserves the > protection against a chatty bad sender (`message_timeout` is still > the absolute upper bound) without the false positive. ### Dead-sender suppression NACK rounds do **not** continue all the way to `message_timeout` if the sender goes quiet. If no real data (a DATA or PARITY frame) arrives for `dead_sender_timeout` (default **120 s**), the receiver presumes the sender has dropped off the mesh and **stops emitting NACKs** for that message, letting it sit until the hard `message_timeout` fires (and then finalize partial or discard). This stops a long tail of cap-multiplied NACKs being fired at a peer that will never answer. The timeout is validated to be `< message_timeout`. This is an **experimental flood-control limit** (see [Constants and Limits](Constants-and-Limits.md#-experimental-flood-control--resource-bounding-limits)). ### Empty NACK = positive ACK A NACK whose bitmap is all zeros means "all chunks received, stop sending parity". Parsers MUST accept this; the reference implementation doesn't currently emit it (natural completion + the recently-finalized blacklist already handle late parity frames). ### `give_up` flag `flags & 0x01` = the receiver has timed out. Senders SHOULD discard any remaining queued chunks for this message — keep transmitting and you just waste airtime. --- ## End-to-end flow ``` Sender Receiver ────── ──────── build_message(audio) │ ├─ split into N data chunks │ └─ RS-encode P parity chunks │ │ │ ├─ DATA[0] ─────────────────────────────────► │ ├─ DATA[1] ─────X (lost) │ ├─ DATA[2] ─────────────────────────────────► │ pending ├─ PARITY[0] ─────────────────────────────────► │ FEC reconstructs DATA[1] │ │ ✓ Complete │ │ │ --- or, on heavier loss --- │ │ │ ├─ DATA[0] ─────X │ ├─ DATA[1] ─────X │ ├─ DATA[2] ────────────────────────────────► │ ├─ PARITY[0] ─────X │ │ │ quiet 3000 ms │ ◄────────────────────────── NACK [bitmap=0xC0] │ (chunks 0 & 1 missing) ├─ DATA[0] ────────────────────────────────► │ ├─ DATA[1] ────────────────────────────────► │ ✓ Complete ``` --- ## Sender-side state machine The sender side of NACK handling lives in [`OutgoingVoiceRegistry`](https://github.com/voicetastic/voicetastic-core/blob/main/crates/voicetastic-core/src/voice/outgoing.rs) and is driven by [`VoiceSender`](https://github.com/voicetastic/voicetastic-core/blob/main/crates/voicetastic-core/src/voice/sender.rs). [`VoiceSender::send`] registers an entry at burst start and the background NACK-listener task feeds inbound NACKs into `take_retransmit`, which enforces three caps: 1. **Pending-chunk dedup.** Every DATA index is marked *pending* at `register()` time. The burst loop calls `mark_chunk_sent(i)` per frame as it leaves the worker. A NACK arriving while the initial burst is still draining is filtered against `pending_chunks`, so chunks already queued up are not re-enqueued. 2. **Per-message cooldown.** After each retransmit batch, the entry is parked for `pacing × frames.len()`, clamped to `[1 s, 30 s]`. The 30 s ceiling sits comfortably below the receiver's ~1200 s NACK budget so the sender always responds before the receiver gives up. NACK rounds during cooldown are dropped; the receiver re-NACKs after the next quiet window. 3. **Per-message retransmit budget.** `MAX_RETRANSMITS_PER_MESSAGE = 2_400` comfortably exceeds the widened receiver round cap (400 × max 30 s cooldown). Beyond that, the sender drops further NACKs. After the initial burst, the sender lingers for `SendRequest::linger` (default 600 s, see [`DEFAULT_LINGER`](https://github.com/voicetastic/voicetastic-core/blob/main/crates/voicetastic-core/src/voice/sender.rs)) before emitting `SendStatus::Complete` and releasing registry state. A stale NACK arriving after that point finds nothing to retransmit against. The previous value of 60 s was too short for slow modem presets (e.g. LongFast at 900 ms pacing: a 155-frame burst alone takes ~140 s, leaving insufficient linger time for NACK-driven retransmit rounds). The absolute outer envelope is `OutgoingVoiceRegistry::set_retain_ttl` (default 1200 s, covering `max_burst_duration + linger` on all modem presets the receiver can still hear, so a NACK never finds its registry entry expired while the sender is still alive). --- ## Broadcast suppression Receivers MUST NOT emit NACKs for broadcast messages. With multiple listeners on the same channel, every receiver NACK'ing the same missing chunks would saturate the sender's radio, and the sender has no way to pick a single retransmit target — broadcast is point-to-multipoint by definition. The reference receiver short-circuits the NACK emission branch in `tick()` when the in-progress entry's `to` is the broadcast address; the state machine still drives timeouts and partial finalize. Broadcast voice relies on **FEC + partial-on-timeout**: pre-pay the airtime for extra parity shards (the default `voice.fec_mode = auto` picks 50 % for broadcast), and accept that the message either completes via Reed-Solomon recovery or finalises as partial. ## NACK aggressiveness The reference receiver schedules NACK rounds at `nack_window × backoff_base^min(round, 4)`. Both fields are host-configurable on `AssemblerConfig` and are typically set via the `voice.nack_mode` setting: | Mode | `nack_window` base | `backoff_base` | `max_rounds` | |----------------|----------------------|----------------|--------------| | `Off` | (NACK disabled) | `0` | `0` | | `Auto` short/med fast | 1.5 s | `2` | `800` | | `Auto` med slow / LongFast | 3 s | `2` | `400` | | `Auto` long-range / unknown | `pacing × 4` min 4 s | `3` | `200` | | `Conservative` | `pacing × 4` min 4 s | `3` | `200` | | `Aggressive` | 1.5 s | `2` | `800` | `backoff_base = 0` is the disabled signal; the assembler skips both the quiet-window and give-up NACK branches when it sees a zero base. The broadcast short-circuit above is independent — broadcasts get no NACK regardless of the configured `backoff_base`. ## NACK trust model This protocol does not authenticate NACK frames; like DATA / PARITY, any peer with the channel PSK (i.e. anyone Meshtastic lets join the channel) can fabricate a `give_up` NACK and abort an in-flight transmission. This matches Meshtastic's threat model for text traffic and is documented as a non-goal in the spec. Mitigations available to senders: - Treat `give_up` as advisory; if airtime budget allows, retry under a fresh `message_id` after a backoff. - A future revision MAY reintroduce a keyed MAC field if a concrete threat model warrants it. See git history at the v2 line for the previous design. --- ## Tunable knobs | Setting | Default | Effect of increasing | |------------------------------------|---------|--------------------------------------------| | `parity_count` (sender) | 10-50 % | Better loss tolerance, more airtime | | `NACK_WINDOW_MS` | 3000 | Fewer spurious NACKs on jittery links | | `NACK_MAX_ROUNDS` (consecutive) | 400 † | Higher completion rate, longer worst-case (re-derived from `message_timeout / nack_window`) | | `MAX_RETRANSMITS_PER_MESSAGE` | 2_400 ‡ | Sender-side counterpart to NACK budget | | `AssemblerConfig::message_timeout` | 1200 s | Larger messages allowed, more state held | | `AssemblerConfig::completion_memory` | = `message_timeout` | How long a finalized `(from, message_id)` is remembered so late chunks can't resurrect a phantom partial. Must be `>= message_timeout`. | | `AssemblerConfig::dead_sender_timeout` | 120 s ‡ | Silence after which the sender is presumed dead and NACKs are suppressed until `message_timeout`. Must be `< message_timeout`. | | `OutgoingVoiceRegistry::retain_ttl`| 1200 s ‡ | Sender remembers frames longer | | `SendRequest::linger` | 600 s | Sender stays subscribed to NACKs longer | | `partial_play_on_timeout` | `true` | Always emits something on timeout | > **† Derived, not fixed.** `NACK_MAX_ROUNDS` is re-derived from > `message_timeout / nack_window` whenever a host changes either; `400` > is just the default-config seed. > > **‡ Experimental flood-control / resource-bounding limits.** These are > heuristic safety valves, not wire-format values: they cap NACK and > retransmit storms and bound reassembler/registry state so a dead, slow, > or misbehaving peer can't flood the channel or exhaust local memory. > Tuned empirically and may change between releases without a > `PROTOCOL_VERSION` bump. See [Constants and Limits](Constants-and-Limits.md#-experimental-flood-control--resource-bounding-limits). → Continue to [Encryption](Encryption.md).