# Receiver Guide [← Home](Home.md) How to build a compatible Voicetastic reassembler — protocol-level checklist plus reference-implementation pointers. --- ## Inbound dispatch You'll be receiving Meshtastic `Data` packets. Filter: ```rust if data.portnum != PRIVATE_APP as i32 { return; } // 256 if detect_version(&data.payload) != Some(0x03) { return; } ``` The first-byte version check lets future protocol revisions co-exist on the same port without breaking older clients. --- ## Setup ```rust use voicetastic_core::voice::{AssemblerConfig, VoiceAssembler}; use std::time::Duration; let asm = VoiceAssembler::new(AssemblerConfig { message_timeout: Duration::from_secs(1200), partial_play_on_timeout: true, ..AssemblerConfig::default() }); ``` | Field | Default | Meaning | |-----------------------------|---------------|---------------------------------------------------------------| | `message_timeout` | 1200 s | Hard timeout per in-progress message. Sized so a slow-preset burst can complete its initial send and still service several NACK recovery rounds. | | `partial_play_on_timeout` | `true` | Emit incomplete messages on timeout (vs. discard). | If you change `message_timeout` or `nack_window`, call `config.sync_nack_cap_to_timeout()` afterwards so `max_nack_rounds` is re-derived (`⌈message_timeout / nack_window⌉`) and `completion_memory` stays `>= message_timeout`. --- ## Per-frame ingest ```rust match asm.accept(&from_id, to, channel, &payload) { AssemblyEvent::Pending => {} // accumulating AssemblyEvent::Duplicate => {} // already had this chunk AssemblyEvent::Complete(m) => handle_voice(*m).await?, // done! AssemblyEvent::Nack(info) => route_to_sender(info), // we're hearing the other end's NACK AssemblyEvent::Rejected(e) => warn!(?e, "bad frame"), } ``` `from_id` MUST be the canonical lowercase `!hex8` form (`voicetastic_core::ids::node_num_to_id`). --- ## Periodic tick You MUST drive `tick()` periodically to handle timeouts and emit NACKs: ```rust let mut tick = tokio::time::interval(Duration::from_millis(250)); loop { tokio::select! { _ = tick.tick() => { let out = asm.tick(); for completed in out.finalized { handle_voice(completed).await?; } for nack in out.nacks { let to_node = node_id_to_num(&nack.from)?; svc.send_data( PRIVATE_APP as i32, nack.frame, nack.channel, Some(to_node), /* want_ack */ false, ).await?; } } // ... your inbound recv loop here } } ``` Recommended cadence: **100–250 ms**. Below that you spin needlessly; above that you delay NACK emission past the spec's `NACK_WINDOW_MS = 3000`. **Broadcast messages emit no NACK.** The assembler silently suppresses NACK frames when the in-progress entry's destination is the broadcast address — multiple receivers would otherwise pile retransmits onto the sender with no clear target. Broadcast still gets FEC + timeout-based partial finalize. See [Reliability — Broadcast suppression](Reliability-FEC-and-NACK.md#broadcast-suppression). --- ## Resource bounds The assembler enforces all of these for you: | Bound | Default | What happens at the limit | |------------------------------------|-------------|------------------------------------------------------------------------------------------| | `MAX_IN_PROGRESS_GLOBAL` | 64 | Oldest `started_at` entry evicted + blacklisted. | | `MAX_IN_PROGRESS_PER_SENDER` | 4 | New messages from that sender rejected with `PerSenderCap`. | | `MAX_MESSAGE_BYTES` | 54 825 | Structurally bounded (`u8 × MAX_BODY_SIZE`). | | `BLACKLIST_TTL` | 600 s | After a message completes/times out, late frames for it are silently dropped. | | `BLACKLIST_MAX` | 100 | Oldest entries evicted FIFO. | | `NACK_MAX_ROUNDS` | 400 | After 400 *consecutive* NACK rounds with no new chunks, finalize-or-discard. Resets on every accepted shard. Re-derived from `message_timeout / nack_window`. | | `NACK_WINDOW_MS` | 3000 | Quiet period after the last seen chunk before emitting a NACK. | | `DEAD_SENDER_TIMEOUT` ‡ | 120 s | After this much silence (no data/parity), NACKs are suppressed until `message_timeout`. | | `MAX_VALIDATION_STRIKES` ‡ | 3 | After 3 post-template mismatches, the entry is evicted + blacklisted. | > **‡ Experimental flood-control / resource-bounding limits** - heuristic > safety valves, not wire-format values; may change between releases. See > [Constants and Limits](Constants-and-Limits.md#-experimental-flood-control--resource-bounding-limits). --- ## Rejection rules (silent drops) The assembler returns `AssemblyEvent::Rejected(VoiceError::*)` for: - `version != 0x03` (V2 or future protocol version) - Any reserved bit of `type_flags` set (`0x2F` mask) - `packet_type == 3` (reserved) - `total_data == 0` - `chunk_index >= total_data` (DATA) or `>= parity_count` (PARITY) - `parity_count > 128` - Unknown `codec` - Codec / `total_data` / `stream_seq` mismatch versus the established template - DATA / PARITY body length mismatch versus the established `chunk_size` - Header SHA-256 integrity tag mismatch - `(from, message_id)` is on the recently-completed blacklist - New `message_id` while the sender is at `MAX_IN_PROGRESS_PER_SENDER` See [Error Catalogue](Error-Catalogue.md) for one-line descriptions of each variant. --- ## Handling completed messages ```rust async fn handle_voice(msg: VoiceMessage) { if !msg.is_complete { // Partial — playback may stutter. recovered_via_fec tells you // how many chunks the FEC layer saved. } if msg.received_data == 0 { // Pure-zero playback. Skip unless you really want a silent file. return; } // The audio is raw codec frames. For AMR-NB you'd write: // #!AMR\n ‖ msg.audio // before handing to a player. } ``` The fields you'll usually inspect: | Field | Meaning | |-----------------------|--------------------------------------------------------| | `is_complete` | All `total_data` chunks present (after FEC). | | `received_data` | How many DATA chunks landed (incl. FEC reconstructions).| | `recovered_via_fec` | How many were reconstructed by Reed-Solomon. | | `audio` | Raw codec bytes, no container header. | --- ## Common pitfalls - **Forgetting `tick()`.** Without it, NACKs are never emitted and partially-lost messages stall until `message_timeout`. - **Passing the wrong `from`.** Use `voicetastic_core::ids::node_num_to_id(packet.from)` so the assembler receives the canonical lowercase `!hex8` form. - **Accepting frames from non-`PRIVATE_APP` ports.** Other apps share the same Meshtastic radio; filter by `portnum == 256` *and* the version byte. - **Wrapping the audio in a container header at the wrong layer.** The protocol carries raw codec frames. Wrap to `#!AMR\n` (or whatever) only when writing to disk for an external player. → Continue to [Constants and Limits](Constants-and-Limits.md).