How Push-to-Talk Voice Messaging Works in BitChat: A Deep Dive into Live Voice Streaming
BitChat implements push-to-talk (PTT) voice messaging as a live audio streaming system that transmits AAC-encoded frames in real-time while the user holds the record button, then seamlessly transitions the burst into a standard voice-note file.
BitChat's push-to-talk functionality is distinct from traditional voice messaging. Instead of recording a complete file before transmission, it streams compressed audio packets to a peer the moment capture begins. This article examines the complete architecture—from microphone capture through packet transmission to live playback—based on the open-source implementation in permissionlesstech/bitchat.
PTT Voice Messaging Architecture Overview
The system operates through three coordinated stages that run in parallel during a live voice session:
| Stage | Component | Core Responsibility |
|---|---|---|
| Capture | PTTCaptureEngine |
Records microphone input, encodes to AAC frames, and triggers downstream processing |
| Packetization & Transmission | VoiceBurstPacketizer |
Groups frames into wire-format packets with START/DATA/END semantics |
| Reception & Assembly | ChatLiveVoiceCoordinator |
Decodes inbound packets, reassembles bursts, and manages the live playback stream |
Each stage is implemented in dedicated Swift files under bitchat/Features/voice/ and bitchat/ViewModels/.
Live Capture: PTTCaptureEngine and PTTLiveVoiceSession
When a user presses and holds the microphone button, the UI instantiates a PTTLiveVoiceSession that conforms to the VoiceCaptureSession protocol defined in VoiceCaptureSession.swift.
Session Initialization
// Start a live PTT session when the user presses the mic button
let liveSession = PTTLiveVoiceSession(
sendPacket: { data in meshService.sendVoiceBurst(data) }
)
await liveSession.start() // begins capture & streaming
The start() method performs three critical operations:
- Generates a unique burst ID via
VoiceBurstPacket.makeBurstID()that identifies this voice stream - Creates a temporary output URL for the eventual
.m4avoice-note file - Initializes
PTTCaptureEngine.start()to begin microphone capture
Frame-Level Processing
The engine's audio tap invokes capture.onFrames for each batch of AAC frames. The session maintains a StreamState machine that guarantees exactly one START packet is transmitted, followed by data packets as frames become available:
// Capture engine delivers AAC frames (called on capture queue)
engine.onFrames = { frames in
// Each frame is packetised and sent via the closure above
// (handled automatically inside PTTLiveVoiceSession)
}
Simultaneously, the same AAC data is written to disk. This dual-path design ensures that if the live stream fails, the completed voice-note can still be delivered through the standard message pipeline.
Packet Format: VoiceBurstPacket Structure
The wire protocol is defined in VoiceBurstPacket.swift. Packets are compact binary structures designed to fit within a single BLE frame after Noise encryption overhead.
Packet Types and Flags
| Flag | Value | Purpose |
|---|---|---|
| START | 0x01 |
Advertises codec (aacLC16kMono); sent once at stream beginning |
| DATA | 0x00 |
Carries 1-8 AAC frames with length-prefixed payload |
| END | 0x02 |
Signals completion with metadata: total packets and duration |
| CANCELED | 0x04 |
Empty payload; instructs receiver to discard the burst |
The START packet payload contains only the codec identifier. DATA packets use the format [len][AAC-frame]… to support variable frame sizes. END packets include [totalDataPackets][durationMs] to assist the receiver in validating completeness.
Packetization Limits
The VoiceBurstPacketizer.add() method respects TransportConfig.pttMaxBurstContentBytes, ensuring that encoded packets never exceed the underlying transport capacity. This is critical for BLE mesh networks where MTU constraints are strict.
Transmission: BLE/Noise Integration
The sendPacket closure provided to PTTLiveVoiceSession bridges the voice layer to the transport layer. In practice, this routes through BLEService to queue packets for the peer:
// Packet is handed to this closure inside PTTLiveVoiceSession
sendPacket: { data in meshService.sendVoiceBurst(data) }
Packets are encrypted via the Noise protocol (for DMs) or signed for public mesh broadcast. The small packet size enables low-latency streaming—frames arrive at the recipient typically within 50-100ms of capture, depending on network conditions.
Reception and Assembly: ChatLiveVoiceCoordinator
Inbound voice traffic is handled by ChatLiveVoiceCoordinator.swift, registered for both private direct messages (NoisePayloadType.voiceFrame) and public mesh messages (MessageType.voiceFrame).
Frame Handling Pipeline
// Receiving a voice frame in the coordinator
func handlePublicVoiceFramePayload(
from peerID: PeerID,
nickname: String,
payload: Data,
timestamp: Date
) {
// Decode packet, assemble burst, write to temporary file, play live
chatLiveVoiceCoordinator.handlePublicVoiceFramePayload(
from: peerID,
nickname: nickname,
payload: payload,
timestamp: timestamp
)
}
The coordinator performs several functions:
- Packet decoding: Validates flags and extracts AAC frames via
VoiceBurstPacket.decode - Burst tracking: Maintains state per burst ID, buffering out-of-order packets with a 0.5-second gap timeout (
gapSkipSeconds) - Progressive file writing: Appends decoded AAC data to a temporary
.aacfile - Live playback: Streams frames to
PTTBurstPlayerfor real-time audio output
Deduplication with Finalized Voice Notes
When the END packet arrives, the coordinator creates a BitchatMessage representing the live burst. However, the sender simultaneously finalizes the .m4a file captured during the session. This file—named voice_<burstID>.m4a—is sent through the standard voice-note pipeline.
The coordinator detects matching burst IDs and silently absorbs the finalized file, preventing duplicate bubbles in the UI. This merge logic ensures users see a single coherent voice message regardless of how it arrived.
Playback During Reception
PTTBurstPlayer.swift manages audio output for live streams. Unlike standard voice-note playback, it operates on streaming buffers and handles partial data gracefully, starting playback as soon as sufficient frames are buffered.
Session Termination and Fallback
When the user releases the record button, the session concludes:
// Finish the session when the user releases the button
if let noteURL = await liveSession.finish() {
// `noteURL` is the finalized .m4a file – send it like a regular voice note
viewModel.sendVoiceNote(fileURL: noteURL)
}
The finish() method:
- Signals the capture engine to stop recording
- Transmits the END packet to the peer
- Returns the URL of the finalized
.m4afile - Allows the caller to send the voice-note through normal channels as reliability fallback
If the session is interrupted—app backgrounding, Bluetooth disconnection, or explicit cancellation—a CANCELED packet notifies the receiver to discard the incomplete burst.
User Experience Integration
The UI layer coordinates with voice state through setActivePublicVoiceTalker, which displays the current speaker during live transmission. This indicator activates immediately on START packet receipt, before any audio plays, providing sub-second feedback that a peer is speaking.
PTT functionality is gated by user preference in PTTSettings.swift, which exposes a toggle to enable or disable live voice streaming. When disabled, the hold-to-record gesture falls back to traditional voice-note capture with deferred transmission.
Key Source Files
| File | Path | Purpose |
|---|---|---|
VoiceCaptureSession.swift |
bitchat/Features/voice/ |
Protocol abstraction and session implementations |
PTTCaptureEngine.swift |
bitchat/Features/voice/ |
Core audio capture and AAC encoding |
VoiceBurstPacket.swift |
bitchat/Protocols/ |
Wire-format packet definitions and serialization |
ChatLiveVoiceCoordinator.swift |
bitchat/ViewModels/ |
Inbound packet handling and UI integration |
PTTBurstPlayer.swift |
bitchat/Features/voice/ |
Real-time playback of streaming voice bursts |
PTTSettings.swift |
bitchat/Features/voice/ |
User-configurable PTT enable/disable |
Summary
- BitChat's push-to-talk voice messaging streams AAC-encoded audio frames live to peers, eliminating the delay of traditional record-then-send workflows
PTTLiveVoiceSessionorchestrates dual-path capture: real-time packet transmission plus local file storage for fallback deliveryVoiceBurstPacketdefines a compact binary protocol with explicit START/DATA/END/CANCELED states for reliable stream lifecycle managementChatLiveVoiceCoordinatorhandles packet assembly, out-of-order buffering, live playback viaPTTBurstPlayer, and seamless deduplication when the finalized voice-note arrives- The architecture gracefully degrades: if live streaming fails, the complete
.m4afile delivers through standard message channels
Frequently Asked Questions
What codec does BitChat use for push-to-talk voice?
BitChat encodes push-to-talk audio using AAC-LC at 16kHz mono (aacLC16kMono). This codec is advertised in the START packet and provides efficient compression suitable for low-bandwidth BLE mesh networks while maintaining intelligible speech quality.
How does BitChat prevent duplicate voice messages in the chat UI?
The coordinator uses the burst ID to correlate live streams with finalized voice-notes. When voice_<burstID>.m4a arrives through the standard pipeline, ChatLiveVoiceCoordinator recognizes the matching ID from the prior live session and suppresses the duplicate bubble, preserving only the live message that was already displayed.
Can push-to-talk work without an internet connection?
Yes. BitChat's PTT operates over BLE mesh and direct peer-to-peer Noise connections, requiring no internet infrastructure. Packets route through the local mesh network constrained only by TransportConfig.pttMaxBurstContentBytes to ensure they fit within encrypted BLE frames.
What happens if a push-to-talk session is interrupted?
If capture fails or the user cancels, the session emits a CANCELED packet (flags = 0x04) with empty payload. Receivers discard the incomplete burst. The sender may alternatively send the partially-captured .m4a file through normal voice-note channels if sufficient audio was recorded.
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 →