Files
bitchat/docs/PUSH-TO-TALK-DESIGN.md
78a291ab77 Public-mesh push-to-talk: signed live voice bursts in the mesh channel (#1406)
* Live push-to-talk voice for DMs: stream while you talk, voice note as fallback

Holding the mic in a DM now streams AAC frames live over the Noise session
(walkie-talkie style, ~0.5s mouth-to-ear at one hop) while recording the same
audio as a normal voice note. On release the note ships through the existing
fileTransfer pipeline; receivers that heard the live stream absorb it silently
into the same bubble (matched by the burst ID embedded in the file name), so
reliability comes for free and nobody sees duplicates.

Protocol:
- NoisePayloadType.voiceFrame = 0x08 carrying VoiceBurstPacket
  (burstID + seq + START/data/END/CANCELED, length-prefixed AAC frames)
- 210-byte burst-content budget keeps each Noise packet inside the 256-byte
  padding bucket: one BLE frame, never the fragment scheduler
- fire-and-forget: frames are dropped (never queued) without an established
  session; live is only offered when the peer is mesh-reachable

Receive:
- ChatLiveVoiceCoordinator assembles bursts (jitter-ordered, 0.5s gap skip,
  3s idle end, flood/size caps), persists progressively as ADTS .aac so even
  a partial burst is a replayable bubble
- live autoplay only when the conversation is on screen, app active, and the
  new app-info "live voice messages" toggle is on (also gates live sending)
- one-playback-at-a-time via a shared ExclusivePlayback slot

Capture:
- PTTCaptureEngine taps AVAudioEngine, dual-encodes: live AAC frames + the
  finalized .m4a (same 16kHz/mono/16kbps settings as VoiceRecorder)
- VoiceRecordingViewModel now drives a pluggable VoiceCaptureSession; the
  composer HUD shows a pulsing LIVE treatment when streaming

Includes the push-to-talk design doc, 6 new localization keys across all 29
locales, and unit tests for framing, packetizer budget, ADTS output, codec
round-trip, and the assembly/absorb lifecycle. Public-mesh PTT (MessageType
0x29) lands separately on top of this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Public-mesh push-to-talk: signed live voice bursts in the mesh channel

Extends live PTT from DMs to the public mesh timeline. Holding the mic in
the mesh channel now broadcasts the burst live as signed voiceFrame packets
(MessageType 0x29) while the finalized voice note still ships on release —
new clients hear you as you speak and absorb the note silently into the live
bubble; old clients (and late joiners) keep receiving the note exactly as
before, so mixed-version meshes lose nothing.

Wire/relay:
- MessageType.voiceFrame = 0x29: ephemeral signed broadcast, never
  gossip-synced (SyncTypeFlags maps it to no bit), never padded (padding to
  the 512 block would push every ~490-byte signed packet into fragmentation)
- RelayController treats voiceFrame like media fragments: dense-graph TTL
  clamp contains the sustained ~15 pkt/s per-talker stream, tight 8-25 ms
  jitter keeps multi-hop latency inside the receiver's 350 ms jitter buffer
- inbound gate mirrors public messages: broadcast-only, 30 s freshness cap,
  packet signature verified against the claimed sender's announce before any
  audio reaches the UI

App:
- ChatLiveVoiceCoordinator gains burst scopes: public bubbles land in the
  mesh timeline, autoplay only while that timeline is on screen, and the
  finalized-note absorb is scope-bound (a public note can't replace a DM
  burst or vice versa)
- floor courtesy: while someone talks live in the public channel the
  composer mic tints red and pulses, with an accessibility value naming the
  talker ("%@ is speaking", localized in all 29 locales); holding still
  works — a decentralized mesh has no floor arbiter, the tint just
  discourages talk-over

Tests: relay policy (sparse cap + dense clamp), public bubble + talker
indicator lifecycle, note absorption into the mesh store, and scope-binding
rejection; full suite green (1382 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* PTT follow-ups from review + field test: peer-ID normalization, toggle gates inbound, drop-path diagnostics

Codex review fixes (#1403):
- makeVoiceCaptureSession normalizes the selected peer with toShort() before
  the reachability/session checks and binds the send target to that same
  routing ID — a conversation selected under the stable 64-hex Noise key no
  longer silently falls back to a classic note while the short-ID session is
  established
- the live-voice toggle now gates inbound bursts too: off means
  classic-notes-only in both directions (no live bubble, partial file, or
  early notification; the finalized note still arrives), with a test

Field-test diagnostics (first device run: DM frames decrypted but no bubble
appeared, with no log evidence of which guard dropped them):
- coordinator logs undecodable frames (size + hex prefix) and blocked drops
- makeAssembly logs directory/file-handle failures instead of returning nil
  silently
- PTTLiveVoiceSession logs capture start and finish (packet/frame/duration
  counts); PTTCaptureEngine logs engine start success/failure with the input
  format; BLEService.sendVoiceFrame logs no-session drops

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix iPhone live-capture failure: dead input unit (AURemoteIO -10851, 0 Hz)

Field testing showed the phone's live capture failing at mic enable with
AURemoteIO -10851 and an input format of 0 Hz / 2 ch — an input unit bound
to an earlier (playback-only or settling) audio session. The Mac, which has
no session lifecycle, captured fine, which is why public bursts from the Mac
worked while phone-side sends degraded from working (first hold) to sporadic
to dead across holds.

Three layers of defense:
- PTTCaptureEngine recreates its AVAudioEngine on every start(), after the
  session is configured, so the input unit binds to the session that is
  active now; a dead input (0 Hz or 0 channels) is now a distinct, logged
  error instead of a silent setup failure
- PTTLiveVoiceSession retries the capture start once after a 150 ms
  route-settle pause
- VoiceRecordingViewModel falls back to the classic VoiceRecorder within the
  same hold if the live engine still cannot start — a route glitch now costs
  the live stream, never the voice note

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Blue mic when the hold will stream live

The mic button now shows readiness at a glance — and doubles as a build
marker for device testing:
- blue: holding will stream live (DM peer reachable with an established
  Noise session, or the public mesh channel)
- accent (orange in DMs): holding records a classic voice note (no session
  yet, peer unreachable, or live voice toggled off)
- red states unchanged (recording, floor busy)

Refactors capture-backend selection into a single liveVoiceTarget() so the
indicator and makeVoiceCaptureSession can never disagree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Revert the blue live-ready mic to the normal accent color

The build-verification marker did its job; idle mic color goes back to the
accent. The LIVE recording HUD remains the signal for whether a hold is
streaming. Keeps the liveVoiceTarget() refactor so backend selection stays
in one place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Leave a trace on every mic press and every inbound-frame drop

Field testing read "tap does nothing" as breakage: the mic start is async
(permission check + engine spin-up), so releasing before recording begins
has always been a silent cancel — for classic voice notes too. Every press
now logs which backend it chose and, for quick presses, that it released
before recording started.

Also logs the two remaining silent drops: inbound voice frames rejected by
the live-voice toggle (the one unlogged guard left in the receive path) and
the classic-note fallback now includes the toggle state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix mic hold dying instantly in DMs: sheet swipe gesture starved the composer

Field logs showed every DM mic hold ending 3-10 ms after it began, on both
platforms, while public-channel holds worked — the private sheet wraps its
entire content (composer included) in a high-priority swipe-right-to-leave
DragGesture, and a high-priority ancestor drag cancels the mic button's
press-and-hold within milliseconds. Same starvation mechanism as the DM
image-reveal bug (#1402), hitting a drag instead of a tap.

The swipe-to-leave gesture now lives on the message list only, so the
composer's gestures (mic hold, text field, buttons) are out of its reach and
the swipe still works where users actually swipe.

Also stops touching the capture engine when a hold cancels before the engine
ever started: probing inputNode on a never-started engine instantiates its
input unit against whatever session is active and spams benign-but-alarming
AURemoteIO -10851 errors into field logs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reorder app info sheet: usage first, then settings, then reference

New section order: HOW TO USE, then the adjustable bits (appearance, voice,
network), then the reference material (features, privacy, symbols legend).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* App info: flow HOW TO USE into one paragraph; "list" and "person" wording

The six how-to-use bullets now read as a single comma-separated paragraph
(same instruction strings, legacy bullet prefix stripped at render). Two
wording updates across all 29 locales: the people icon opens the "list"
(not "sidebar"), and you tap a "person's" name (not a "peer's") to start
a DM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:23:16 +02:00

163 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Smart Push-to-Talk (PTT) — Design
**Status:** Draft for review — no code yet
**Scope:** Live voice bursts over the BLE mesh, in public chat and Noise DMs, with graceful degradation to the existing voice-note pipeline.
## 1. Goal and core idea
Today, holding the mic button records an AAC `.m4a` and ships it as a `fileTransfer` (0x22) after you release — the receiver hears nothing until the whole file arrives. PTT makes the same gesture *live*: encoded audio frames stream over the mesh while you speak, and nearby receivers hear you with sub-second delay, walkie-talkie style.
The "smart" part is that PTT is not a separate mode the user must choose. It is a **delivery strategy**, picked automatically per conversation:
| Context | Delivery |
|---|---|
| DM, peer connected/reachable on mesh | **Live stream** (Noise-encrypted frames) + finalized voice note for reliability |
| DM, peer only reachable via Nostr | Existing voice-note recording only (no live; media doesn't ride Nostr today) |
| Public mesh chat | **Live broadcast stream** (signed) + finalized voice note, same dedup as DMs |
| Geohash (Nostr) channels | PTT unavailable (matches existing `canSendMediaInCurrentContext` media policy) |
*(Public originally speced as live-only to avoid doubling bandwidth, but dropping the note broadcast would regress mixed-version meshes — old clients that can't decode live bursts would stop receiving public voice entirely — and late joiners/out-of-range peers would get nothing. The note stays; live receivers absorb it silently.)*
One gesture (hold mic), one mental model ("talk"), and the system degrades from live → reliable-note → unavailable based on what the transport can actually do.
**Bandwidth reality check:** the mesh moves ~15 KB/s per link (469 B fragments at 30 ms spacing); our voice codec needs ~2 KB/s. Live voice fits with a wide margin, even relayed.
## 2. What already exists (reused, not rebuilt)
- **Capture UX:** `ContentComposerView.micButtonView` already implements hold-to-record / release-to-send via `DragGesture`, backed by `VoiceRecordingViewModel`'s state machine and `VoiceRecorder` (AAC-LC, 16 kHz mono, 16 kbps). Mic permission string is already in Info.plist.
- **Reliable delivery:** `BitchatFilePacket` TLV + `fileTransfer` (0x22) + fragmentation + transfer progress + `ChatMediaTransferCoordinator`.
- **Playback:** `VoiceNotePlaybackController` + `VoiceNotePlaybackCoordinator` (single active playback), `WaveformView`, `VoiceNoteView`.
- **Transport:** signed public packets, Noise sessions with typed inner payloads, `RelayController` flood control, `MessageDeduplicator`, `MessageRouter.canDeliverPromptly`.
New work is the **streaming path**: a frame encoder/packetizer, a wire format for bursts, a jitter-buffered receiver/player, relay policy, and the live-bubble UI.
## 3. Wire protocol
### 3.1 Burst framing (shared inner format)
A talk burst is a sequence of packets sharing an 8-byte random `burstID`:
```
[burstID: 8][seq: UInt16 BE][flags: 1][payload…]
```
`flags` bits:
- `0x01 START` — payload is a header TLV: codec (1 B enum, 0x01 = AAC-LC/16kHz/mono), frame duration ms, batch size. Sent as seq 0 and re-attached (piggybacked TLV) every ~2 s so mid-burst joiners can sync.
- `0x02 END` — payload: total data-packet count (UInt16), duration ms (UInt32). Lets receivers detect tail loss.
- `0x04 CANCELED` — sender slid-to-cancel; receivers stop playback, discard buffered audio, and drop the bubble.
- `0x00` (data) — payload is N length-prefixed AAC frames: `[len: UInt16 BE][ADTS-less raw AAC frame]…`
Audio math: AAC-LC at 16 kHz has 1024-sample frames = **64 ms/frame ≈ 130 B at 16 kbps**. A greedy packetizer batches frames up to a **210-byte burst-content budget** (`pttMaxBurstContentBytes`), chosen so the Noise ciphertext (content + 1 inner-type byte + 16-byte tag) stays inside `MessagePadding`'s 256-byte bucket — the whole directed packet is 288 bytes and voice **never enters the fragment scheduler** (which caps concurrent transfers at 2 and would starve file sends). At 16 kbps that works out to **1 frame/packet, ~15.6 pkt/s, ~5.3 KB/s wire** for DMs; public bursts (unpadded, planned for phase 2) can batch 3 frames.
### 3.2 Public mesh: new `MessageType.voiceFrame = 0x29`
- Broadcast (no recipient), **signed** like public messages; unsigned or signature-mismatched frames are dropped on receive.
- **No padding** (add to `BLEOutboundPacketPolicy` alongside `fileTransfer`) — padding to the 512 block would push every packet over MTU into fragmentation.
- TTL: `messageTTL` (7) at origin; `RelayController` gets a `voiceFrame` case mirroring the fragment policy — dense clamp to 5, jitter 825 ms — so live audio floods like fragments, not like announces. Per-hop cost is ~1040 ms; 3 hops stays comfortably inside the jitter buffer.
- Dedup: existing `MessageDeduplicator` (timestamp ms + seq make each packet unique).
- 0x29 is the next free code after `nostrCarrier = 0x28`; unknown types are ignored by older clients (iOS and Android), so rollout is compatible — old clients simply don't hear live bursts.
### 3.3 DM: new `NoisePayloadType.voiceFrame = 0x08`
- Inner payload = the same burst framing, wrapped in `noiseEncrypted` (0x11) directed packets — wire-indistinguishable from other DM traffic by size/type (0x04/0x05 are reserved per the comment in `BitchatProtocol.swift`; 0x08 is the first free slot after `groupKeyUpdate = 0x07`).
- Directed encrypted packets are already always-relayed by `RelayController`, so multi-hop DMs work unchanged.
- Requires an established session; PTT-live is only offered when `noiseService.hasEstablishedSession` (otherwise first hold triggers the normal handshake + falls back to voice-note for that burst).
- Fire-and-forget: **no delivery acks, no retransmit** for frames. Late audio is worthless; reliability comes from the finalized note (§5).
### 3.4 Known traffic-analysis tradeoff
A steady ~8 pkt/s cadence for the duration of a burst is a timing fingerprint even under Noise (observers can infer "someone is speaking to someone", not content). This is inherent to live voice; the doc records it as accepted for v1. Block padding is skipped for voiceFrame (size classes would leak little beyond what cadence already does).
## 4. Sender pipeline
`VoiceRecorder` (AVAudioRecorder → file) can't stream, so PTT capture uses a parallel path in a new `PTTStreamEncoder` actor:
```
AVAudioEngine input tap (native format)
→ PTTInputResampler → 16 kHz mono PCM
→ PTTFrameEncoder (AVAudioConverter) → AAC-LC frames → packetizer → BLEService.sendVoiceFrame
→ the same PCM is simultaneously written to an AVAudioFile .m4a (identical settings
to VoiceRecorder), so finalization needs no remux step
```
- On release: emit `END`, then hand the finalized `.m4a` to the **existing** `sendVoiceNote` flow. The note's `fileName` embeds the burst ID (`voice_<burstID hex>.m4a`) — that links note↔burst with **zero changes** to `BitchatFilePacket`/Android interop.
- On slide-to-cancel: stop tap, emit `CANCELED`, delete local file, skip finalization. UX note: unlike voice notes, live audio already played on the far side cannot be unsent — the recording UI must make "you are live" unmistakable (see §7).
- Caps: max burst 120 s (matching the voice-note recorder's cap), exactly one outbound burst at a time.
- Audio session: reuse `VoiceRecorder`'s `.playAndRecord` config; add `.duckOthers`.
## 5. Receiver pipeline
New `VoiceBurstAssembler` (keyed by sender + burstID) feeding a `PTTBurstPlayer`:
- **Jitter buffer:** start playback after 350 ms of buffered audio or 500 ms wall-clock, whichever first. Frames decode via `AVAudioConverter``AVAudioPlayerNode`.
- **Loss handling:** gap in seq → insert silence for the missing frames (64 ms each) and keep going. No PLC in v1; at these frame sizes brief dropouts are acceptable.
- **Burst end:** on `END`, or 3 s with no frames (talker walked out of range).
- **Persistence:** frames append to an incoming ADTS `.aac` file (already an allowed `MimeType`), so every burst becomes a replayable voice-note bubble containing whatever was captured — even a partial one.
- **Dedup with the finalized note:** when a `fileTransfer` arrives whose fileName carries a burstID we already assembled (DM or public), it silently *replaces* the partial file behind the existing bubble (no new message row, no second notification). Receivers that heard everything live just get a lossless copy.
- **Resource caps:** ≤ 8 concurrent assemblies, ≤ 256 KB per burst (60 s × 2 KB/s + slack), 30 s stale cleanup, and drop inbound frames beyond ~2× realtime per sender (spam/flood guard).
## 6. Playback policy — when does it actually make sound?
Auto-playing strangers' audio is the fastest way to make this feature hated. Rules:
1. **Live autoplay** only when *all* hold: app foregrounded, the burst's conversation is the one currently on screen, and the **"live voice messages"** toggle is on (app-level, in the app-info sheet, default on; per-conversation overrides are a v2 refinement). The same toggle gates live *sending* — off means voice behaves exactly like classic notes in both directions.
2. Otherwise the burst appears as a **live bubble**: pulsing waveform + `LIVE` badge + sender name; tapping it joins playback at the live edge. When the burst ends it becomes a normal voice-note bubble.
3. **One voice at a time:** route through `VoiceNotePlaybackCoordinator`. If two people talk simultaneously, the first burst holds the floor; the second shows as a tappable live bubble. No mixing in v1.
4. Notifications: a live burst in a non-focused DM fires the normal message notification once (at START), not per-frame.
**Floor courtesy (public mesh):** while someone else's burst is live in the current channel, the mic button tints "busy" with the talker's name. Holding it still works — a decentralized mesh has no floor arbiter, and rejecting sends would desync under partitions — but the UI discourages talk-over. Hard floor control (token passing) is explicitly out of scope.
## 7. UX
- **Same gesture:** hold mic = talk. When the live path is active, the recording HUD shows a red pulsing **LIVE** treatment (vs. the current neutral recording UI) so the sender knows audio is leaving in real time, not on release. When live isn't available (Nostr-only peer, no session yet), the HUD looks like today's and behavior is unchanged.
- Slide-to-cancel keeps working in both modes, with the §4 caveat surfaced simply: cancel stops and discards; it can't unplay what was heard.
- VoiceOver: mirror the existing `accessibilityAction` toggle-record pattern on the mic button.
- Foreground-only in v1: no `audio` UIBackgroundMode (App Store review + battery implications). If the app backgrounds mid-burst, capture stops cleanly with END. Background listen/talk is a v2 candidate.
## 8. Latency budget (1 hop, DM)
| Stage | Cost |
|---|---|
| Frame accumulation (1 × 64 ms) | 64 ms |
| Encode + packetize | ~5 ms |
| BLE write + delivery | 3060 ms |
| Jitter buffer | 350 ms |
| **Mouth-to-ear** | **~470 ms** |
Each relay hop adds ~1040 ms jitter + radio time. 23 hops stays under ~800 ms — solidly in walkie-talkie territory (commercial PoC apps run 300 ms1 s+).
## 9. Security & privacy summary
- Public frames signed with the existing packet signature; verified against the sender's announce before decode. Unsigned → dropped.
- DM frames ride inside Noise; content confidentiality/integrity as any DM.
- Codec input is validated by frame-length prefixes and total-size caps before touching `AVAudioConverter` (malformed frames dropped, assembly aborted over cap).
- Timing fingerprint accepted per §3.4. No PTT in geohash channels (would put voice on public Nostr relays).
- Mic capture only while the button is held; recording state is always visible.
## 10. New components & touch points
| Piece | Where |
|---|---|
| `PTTStreamEncoder` (tap → AAC → packets) | `bitchat/Features/voice/` |
| `PTTBurstPlayer` (jitter buffer → decode → engine) | `bitchat/Features/voice/` |
| `VoiceBurstFramer` / `VoiceBurstAssembler` (wire encode/decode, caps) | `bitchat/Protocols/` + `bitchat/Services/PTT/` |
| `MessageType.voiceFrame = 0x29` | `localPackages/BitFoundation/.../MessageType.swift` + `BLEReceivePipeline` / `BLEService.handleReceivedPacket` |
| `NoisePayloadType.voiceFrame = 0x08` | `BitchatProtocol.swift` + `ChatTransportEventCoordinator` dispatch |
| Relay policy case | `RelayController` (fragment-like clamp/jitter) |
| No-padding rule | `BLEOutboundPacketPolicy` |
| Pacing/cap constants | `TransportConfig` |
| Live bubble + LIVE HUD + busy mic tint + settings toggle | `VoiceNoteView`/`MediaMessageView`, `ContentComposerView`, conversation settings |
| Delivery-mode selection | `ChatViewModel` / `ChatMediaTransferCoordinator` (reuse `isPeerConnected` / `canDeliverPromptly` / `hasEstablishedSession`) |
## 11. Phasing
1. **Phase 1 — DM live (highest value, lowest blast radius):** encoder, framer, Noise inner type, assembler/player, live bubble, finalize-as-note dedup. Single-hop DMs are the dominant real-world case.
2. **Phase 2 — public mesh:** `voiceFrame` 0x29, signing/verification, relay policy, floor-courtesy UI, autoplay defaults.
3. **Phase 3 (v2 candidates):** background audio mode, mid-burst repair requests (piggyback on REQUEST_SYNC), talk-over mixing, AAC-ELD low-delay profile, dedicated walkie-mode screen, media-over-Nostr (unlocks live-ish PTT for internet peers), Android protocol spec sync.
## 12. Testing
- Unit: framer round-trip (START/data/END/CANCELED, seq gaps, oversize frames), assembler caps + stale cleanup + burstID note dedup, packetizer batch sizing vs. MTU (assert no fragmentation), relay-policy clamps.
- Integration: two-simulator loopback via the existing mesh test harness; loss injection (drop every Nth frame) → verify silence-fill + partial-file persistence.
- Device: two-phone live DM (latency measurement vs. §8 budget), three-phone relay chain, talk-over behavior, cancel semantics, mic-permission-denied path.