-
Notifications
You must be signed in to change notification settings - Fork 0
Frame Format
Every voice frame is a Meshtastic Data payload sent on
PortNum::PRIVATE_APP (256). The payload is at most 231 bytes (LoRa
MTU) and consists of a fixed 16-byte header followed by a plaintext
body. Confidentiality on the air comes from Meshtastic's channel
encryption wrapping the whole Data payload — see
Encryption.
┌────────── 16 B header ───────────────────────┬──── ≤ 215 B body ────┐
│ version │ type_flags │ fields │ 4 B MAC tag │ payload │
└─────────┴────────────┴─────────┴─────────────┴──────────────────────┘
The header is logically 12 bytes followed by a 4-byte integrity tag
that covers the preceding 12 bytes:
SHA-256(header[0..12])[..4] (unkeyed). The tag catches on-air
bit-flips (AES-CTR is malleable, so this remains useful inside
Meshtastic's channel encryption) and accidental corruption that slips
past LoRa FEC. It is not an authenticator: any peer with the
channel PSK can both read and forge frames.
| Off | Size | Field | Notes |
|---|---|---|---|
| 0 | 1 | version |
0x03. Drop frames where this is anything else. |
| 1 | 1 | type_flags |
bits 6–7 = packet_type, bit 4 = last_in_stream, all other bits reserved (must be 0). |
| 2 | 4 | message_id |
u32 big-endian, non-zero, sender-chosen. |
| 6 | 1 | codec |
See codec table. |
| 7 | 1 | codec_param |
Codec-specific (e.g. AMR-NB bitrate ordinal). |
| 8 | 1 | stream_seq |
u8 per-(from, channel) monotonic counter. |
| 9 | 1 | chunk_index |
u8. Range depends on packet_type. |
| 10 | 1 | total_data |
u8. Number of original DATA chunks. 0 is reserved (rejected). |
| 11 | 1 | parity_count |
u8. Number of FEC parity chunks (≤ 128). 0 = no FEC (default). |
| 12 | 4 | mac_tag |
SHA-256(header[0..12])[..4] (unkeyed integrity tag). |
bit 7 bit 6 bit 5 bit 4 bit 3 bits 2..0
┌──────────────┐ ┌────────┐ ┌──────────────┐ ┌────────┐ ┌──────────┐
│ packet_type │ │reserved│ │last_in_stream│ │reserved│ │ reserved │
└──────────────┘ └────────┘ └──────────────┘ └────────┘ └──────────┘
(2 bits) (=0; V2 (1 bit) (=0; V2 (3 bits, =0)
encrypted) mac_keyed)
| Field | Mask | Values |
|---|---|---|
packet_type |
0xC0 |
0=DATA, 1=PARITY, 2=NACK, 3 reserved |
reserved (V2 encrypted) |
0x20 |
MUST be 0; a V2 frame that sets it is rejected as ReservedFlagSet
|
last_in_stream |
0x10 |
1 on the final frame of a recording session |
reserved (V2 mac_keyed) |
0x08 |
MUST be 0; rejected if set |
| reserved | 0x07 |
MUST be 0; rejected if set |
The codec-frame payload for chunk_index ∈ [0, total_data). All non-final
DATA chunks have body length exactly chunk_size. The final DATA chunk
(chunk_index == total_data − 1) MAY be shorter — the sender strips
zero-padding it added for FEC.
A Reed-Solomon parity shard for chunk_index ∈ [0, parity_count). Always
sized to chunk_size.
A selective-retransmit request. See NACK frames below.
packet_type == 3 is reserved; receivers MUST drop these silently.
codec |
Name |
codec_param meaning |
|---|---|---|
0 |
AMR_NB |
AMR-NB bitrate ordinal (0..=7); see AMR-NB rates |
1 |
OPUS |
bitrate / 1000 (kbps); typical 6..=64 — advisory |
2 |
PCM_S16LE |
sample-rate index: 0 = 8 kHz, 1 = 16 kHz |
3 |
CODEC2 |
Codec2 mode ordinal |
4..=255 |
reserved | receivers MUST drop |
The protocol does not transcode. codec_param is metadata passed
through unchanged; receivers SHOULD interpret it per the column above but
the protocol does not range-check it.
| Ordinal | Mode | kbps | Frame bytes (incl. ToC) |
|---|---|---|---|
| 0 | MR475 | 4.75 | 13 |
| 1 | MR515 | 5.15 | 14 |
| 2 | MR59 | 5.90 | 16 |
| 3 | MR67 | 6.70 | 18 |
| 4 | MR74 | 7.40 | 20 |
| 5 | MR795 | 7.95 | 21 |
| 6 | MR102 | 10.20 | 27 |
| 7 | MR122 | 12.20 | 32 |
Default: MR795 (ordinal 5). AMR-NB frame duration is 20 ms. The AMR
file header #!AMR\n is not carried on the wire — strip on send,
re-prepend on receive when writing files.
The body is always plaintext at this layer:
- DATA — the codec frame bytes for this chunk.
-
PARITY — the Reed-Solomon parity shard, sized to
chunk_size. - NACK — the bitmap structure below.
A NACK carries the standard 16-byte header with these constraints:
packet_type = NACKchunk_index = 0-
message_id,codec,codec_param,stream_seq,total_dataecho the values of the message being NACK'd. -
parity_countMUST be echoed; consumers MAY ignore it (the bitmap is sized solely fromtotal_data).
Body:
Off Size Field Encoding
──────────────────────────────────────────────────────────────────
0 1 nack_version UInt8 = 0x01
1 1 flags UInt8: bit 0 give_up,
bits 1-7 reserved
2 ⌈total_data/8⌉ bitmap big-endian, bit `i` set ⇒
chunk index `i` is missing
Bit ordering. Byte 2 bit 7 (MSB) = chunk 0; byte 2 bit 6 = chunk 1; …; byte 2 bit 0 = chunk 7; byte 3 bit 7 = chunk 8; and so on.
Empty bitmap = positive ACK ("all received, stop sending parity"). The reference implementation does not currently emit this shape — it relies on natural completion plus the per-message blacklist for late frames — but parsers MUST accept it.
give_up = the receiver has timed out; the sender SHOULD discard any
remaining queued chunks for this message.
NACK transport: send via PRIVATE_APP, want_ack=false. NACKs are not
themselves retransmitted — the loss-recovery loop is the retransmission.
A 200-byte AMR-NB recording at MR795, sent on a MEDIUM_FAST channel
(chunk_size = 160), with parity_count = 1:
total_data = ceil(200 / 160) = 2
parity_count = 1
frames = 2 DATA + 1 PARITY = 3 frames
| Frame | Header (hex) | Body |
|---|---|---|
| 0 | 03 00 dd cc bb aa 00 05 07 00 02 01 ab cd ef 34 |
160 B AMR data |
| 1 | 03 00 dd cc bb aa 00 05 07 01 02 01 12 34 56 78 |
40 B AMR data (last, trimmed) |
| 2 | 03 40 dd cc bb aa 00 05 07 00 02 01 9a bc de f0 |
160 B RS parity |
Header field decode (frame 0):
-
03— version (protocol v3, wire byte0x03) -
00—type_flags: DATA, not last_in_stream -
dd cc bb aa— message_id (BE) =0xddccbbaa -
00— codec = AMR_NB -
05— codec_param = MR795 ordinal -
07— stream_seq = 7 -
00— chunk_index = 0 -
02— total_data = 2 -
01— parity_count = 1 -
ab cd ef 34— 4-byte SHA-256 integrity tag
→ Continue to Reliability — FEC and NACK.