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:

  1. Generates a unique burst ID via VoiceBurstPacket.makeBurstID() that identifies this voice stream
  2. Creates a temporary output URL for the eventual .m4a voice-note file
  3. 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 .aac file
  • Live playback: Streams frames to PTTBurstPlayer for 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:

  1. Signals the capture engine to stop recording
  2. Transmits the END packet to the peer
  3. Returns the URL of the finalized .m4a file
  4. 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
  • PTTLiveVoiceSession orchestrates dual-path capture: real-time packet transmission plus local file storage for fallback delivery
  • VoiceBurstPacket defines a compact binary protocol with explicit START/DATA/END/CANCELED states for reliable stream lifecycle management
  • ChatLiveVoiceCoordinator handles packet assembly, out-of-order buffering, live playback via PTTBurstPlayer, and seamless deduplication when the finalized voice-note arrives
  • The architecture gracefully degrades: if live streaming fails, the complete .m4a file 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:

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 →