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

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.

// 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.

// 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 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.

// 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 (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
// 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 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.

// 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
// 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
// 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →