# How Push-to-Talk Voice Works Over BitChat’s Mesh Network

> Discover how BitChat's push-to-talk voice functions over its mesh network. Experience low latency walkie-talkie audio streaming on this decentralized platform.

- Repository: [permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
- Tags: internals
- Published: 2026-08-03

---

**BitChat implements a smart push-to-talk (PTT) system that converts a tap-and-hold gesture into live, walkie-talkie-style audio streaming across its decentralized mesh, achieving approximately 470ms mouth-to-ear latency over a single hop.**

Push-to-talk voice communication in BitChat operates over the same **BLE-based mesh network** that carries direct messages and public broadcasts, but with specialized encoding, packetization, and real-time assembly designed for interactive conversation. This article explains the complete flow from microphone tap to speaker output, grounded in the actual `permissionlesstech/bitchat` source code.

## Overview of the Push-to-Talk Pipeline

The PTT system consists of three coordinated stages:

- **Capture & Encoding** — Real-time AAC-LC encoding and burst packetization
- **Transport** — Wire protocol delivery over encrypted DM sessions or signed public mesh broadcasts
- **Assembly & Playback** — Jitter buffering, frame reordering, and synchronized decoding

Each stage is designed to stay within BitChat's strict **~15 KB/s per-link bandwidth budget** while maintaining conversational latency.

## Capture and Encoding on the Sender Side

The sending flow begins with a UI gesture in `ContentComposerView.micButtonView` and proceeds through audio processing layers defined in the design documentation and Swift source files.

### Starting Live Capture

Holding the microphone button triggers `VoiceRecordingViewModel` to enter a parallel "live" state. Instead of buffering for a complete voice note, the system immediately begins streaming frames.

```swift
// AVAudioEngine input tap feeds 16kHz mono PCM
let inputFormat = AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1)!

```

### Frame Encoding with PTTFrameEncoder

Raw PCM passes through `PTTFrameEncoder` — an `AVAudioConverter` configured for **AAC-LC at 16 kHz mono**. Each output frame is approximately **130 bytes** and represents **64 milliseconds** of audio.

```swift
// From the design doc: sender pipeline
let encoder = PTTFrameEncoder(inputFormat: pcmFormat, outputBitRate: 24000)
// Produces ~130B AAC-LC frames every 64ms

```

### VoiceBurstPacketizer: Batching for BLE Efficiency

The `VoiceBurstPacketizer` class in [`VoiceBurstPacket.swift`](https://github.com/permissionlesstech/bitchat/blob/main/VoiceBurstPacket.swift) batches one or two frames into a single `VoiceBurstPacket`, ensuring the total payload stays below `TransportConfig.pttMaxBurstContentBytes` (approximately 210 bytes). This guarantees single-BLE-frame delivery after Noise encryption and padding.

```swift
// VoiceBurstPacketizer.swift lines 69-82
struct VoiceBurstPacketizer {
    let burstID: Data // 8-byte random identifier
    var accumulatedFrames: [Data] = []
    
    mutating func add(_ frame: Data) -> [VoiceBurstPacket] {
        // Batches frames while staying under pttMaxBurstContentBytes
    }
}

```

### Burst Identification and Lifecycle

Each PTT session receives an **8-byte random `burstID`** generated by `VoiceBurstPacket.makeBurstID()`. This identifier uniquely scopes the stream to the `(sender, peer, burst)` triple, enabling parallel conversations and robust reassembly.

When the user releases the button, the sender emits an `END` packet, writes the captured PCM to an `.m4a` file, and hands it to the standard `fileTransfer` pipeline. If the user slides to cancel, a `CANCELED` packet is emitted and temporary files are deleted.

## Wire Protocol and Transport

BitChat's push-to-talk transport uses **two distinct wire formats** depending on whether the conversation is a private direct message or a public mesh broadcast.

### Direct Message Transport: Noise-Encrypted Frames

For DM conversations, PTT uses `NoisePayloadType.voiceFrame = 0x08` with an inner payload of `VoiceBurstPacket`. The packet inherits full **Noise protocol confidentiality and integrity**, flowing through existing encrypted sessions managed by `RelayController`.

```

Noise payload structure:
[encrypted(VoiceBurstPacket)]

```

### Public Mesh Transport: Signed Broadcasts

For public mesh channels, PTT uses `MessageType.voiceFrame = 0x29` as a **signed broadcast**. These packets receive **no padding** to stay under BLE MTU limits and are relayed with the same fragment-style jitter/clamping as other mesh traffic.

Security relies on **pre-announce verification**: receivers validate signatures against the sender's public announce before decoding. Unknown or malformed packets are dropped at the network edge.

### VoiceBurstPacket Binary Layout

The wire format defined in [`VoiceBurstPacket.swift`](https://github.com/permissionlesstech/bitchat/blob/main/VoiceBurstPacket.swift) (lines 25-34):

| Field | Size | Purpose |
|-------|------|---------|
| `burstID` | 8 bytes | Stream identifier |
| `seq` | 2 bytes | Sequence number for ordering |
| `flags` | 1 byte | `START`, `END`, `CANCELED`, or data |
| `payload` | variable | AAC-LC frame data |

```swift
// VoiceBurstPacket.swift
struct VoiceBurstPacket {
    let burstID: Data      // 8 bytes
    let sequence: UInt16   // Big-endian
    let flags: VoiceFlags  // START=0x01, END=0x02, CANCELED=0x04
    let payload: Data      // AAC-LC frames
}

```

## Assembly and Playback on the Receiver Side

The `ChatLiveVoiceCoordinator` class in [`ChatLiveVoiceCoordinator.swift`](https://github.com/permissionlesstech/bitchat/blob/main/ChatLiveVoiceCoordinator.swift) is the central hub for receiving, buffering, and playing live voice bursts.

### Receiving and Dispatching Frames

Two entry points handle inbound traffic:

- `handleVoiceFramePayload` — for DM voice frames
- `handlePublicVoiceFramePayload` — for public mesh broadcasts

Both delegate to `handle(_:from:scope:nickname:timestamp:)` after decoding with `VoiceBurstPacket.decode`.

```swift
// ChatLiveVoiceCoordinator.swift lines 75-87
func handleVoiceFramePayload(from peerID: PeerID, payload: Data, timestamp: Date) {
    handle(
        try VoiceBurstPacket.decode(payload),
        from: peerID,
        scope: .dm(peerID),
        nickname: nil,
        timestamp: timestamp
    )
}

```

### Burst Assembly Lifecycle

A new `Assembly` is instantiated when the first packet arrives (START flag or initial data). The coordinator:

1. Opens a temporary `.aac` file with prefix `voice_live_`
2. Registers a `PTTBurstPlayer` if the user is viewing the conversation and live voice is enabled
3. Accumulates packets, handling out-of-order delivery

```swift
// ChatLiveVoiceCoordinator.swift lines 46-100
final class Assembly {
    let burstID: Data
    let tempFile: URL // voice_live_<uuid>.aac
    var player: PTTBurstPlayer?
    var lastSequence: UInt16 = 0
    // ...
}

```

### Jitter Buffer and Gap Handling

The coordinator maintains a **350ms jitter buffer** before starting playback, smoothing network variance. If a gap exceeds **0.5 seconds** (`gapSkipSeconds`), playback skips ahead rather than waiting indefinitely.

### PTTBurstPlayer: Low-Latency Decoding

`PTTBurstPlayer` receives decoded AAC frames and enqueues them for `AVAudioPlayerNode`. It continues draining the buffer even after the assembly receives `END` and finalizes.

### Finalization and Absorption

When an `END` packet arrives or an idle timeout fires:

1. The temporary file is closed and renamed from `voice_live_` to `voice_` (exempting it from quota cleanup)
2. A chat bubble is published or updated
3. The finalized `.m4a` (sent via normal file transfer) is **absorbed** to replace the live bubble without duplication

```swift
// ChatLiveVoiceCoordinator.swift lines 140-166
func absorbFinalizedVoiceNote(_ message: Message) -> Bool {
    // Returns true if a live assembly was matched and merged
}

```

### Resource Protection

Multiple safety limits prevent abuse and protect mesh capacity (lines 17-22, 55-57):

- Maximum concurrent assemblies per peer
- Per-burst byte caps
- Burst age limits (old assemblies are purged)
- Codec validation (only AAC-LC 16kHz accepted)

## Latency Characteristics and Bandwidth Budget

BitChat's PTT targets conversational interactivity while respecting mesh constraints:

| Stage | Latency |
|-------|---------|
| Frame accumulation | 64 ms |
| Encode & packetize | ~5 ms |
| BLE write & delivery (1 hop) | 30-60 ms |
| Jitter buffer | 350 ms |
| **Total mouth-to-ear (1 hop)** | **~470 ms** |

Each additional hop adds 10-40ms, keeping 2-3 hop paths under **800ms**. The 210-byte burst packets and 64ms frame timing ensure the mesh's **~15 KB/s per-link budget** is never exceeded.

## Security and Privacy Model

Push-to-talk inherits BitChat's existing security architecture:

- **DM conversations**: Full Noise protocol encryption with forward secrecy
- **Public mesh**: Ed25519 signatures verified against announced keys; unknown senders dropped
- **Codec constraints**: Malformed AAC data is rejected before decoding
- **Rate limiting**: Per-peer and global caps prevent flooding

Unknown `burstID` values, invalid sequence numbers, or oversized payloads trigger silent packet drops.

## Summary

- **BitChat's push-to-talk** converts tap-and-hold gestures into live AAC-LC audio streams at ~470ms latency
- **VoiceBurstPacketizer** batches frames into ≤210-byte bursts for single-BLE-frame delivery
- **Two wire protocols**: `NoisePayloadType.voiceFrame` for encrypted DMs, `MessageType.voiceFrame` for signed public broadcasts
- **ChatLiveVoiceCoordinator** manages jitter buffers, assembly lifecycle, and seamless absorption of finalized voice notes
- **Resource caps and codec validation** protect the mesh from abuse while preserving conversational quality

## Frequently Asked Questions

### How does BitChat prevent PTT from congesting the mesh network?

BitChat enforces strict **packet size limits** (~210 bytes per burst), **frame timing** (64ms intervals), and **per-link bandwidth caps** (~15 KB/s). The `VoiceBurstPacketizer` guarantees single-BLE-frame delivery, and `ChatLiveVoiceCoordinator` implements per-peer rate limiting, burst age timeouts, and maximum concurrent assembly limits.

### What happens if a push-to-talk packet is lost in transit?

The receiver's **jitter buffer** tolerates moderate reordering and delay. If a gap exceeds 0.5 seconds, `PTTBurstPlayer` skips ahead rather than stalling. Lost packets are not retransmitted—PTT favors low latency over perfect reliability, similar to traditional walkie-talkie systems.

### Can I disable live voice and use classic voice notes instead?

Yes. A **"live voice" toggle** (default enabled) gates both sending and auto-playback. When disabled, the mic button falls back to the standard voice-note flow: record, release to send, and receive as a downloadable file rather than a real-time stream.

### How does BitChat prevent eavesdropping on public PTT channels?

Public mesh PTT packets are **signed but not encrypted**—anyone can receive and play them. Privacy relies on **channel membership control** and the **ephemeral nature of live streams** (no persistent recording without the sender's finalized voice note). For confidential communication, users should use DM mode with full Noise encryption.