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 frameshandlePublicVoiceFramePayload— 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:
- Opens a temporary
.aacfile with prefixvoice_live_ - Registers a
PTTBurstPlayerif the user is viewing the conversation and live voice is enabled - 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:
- The temporary file is closed and renamed from
voice_live_tovoice_(exempting it from quota cleanup) - A chat bubble is published or updated
- 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.voiceFramefor encrypted DMs,MessageType.voiceFramefor 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →