Files
bitchat/bitchatTests/ChatLiveVoiceCoordinatorTests.swift
T
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

314 lines
16 KiB
Swift

//
// ChatLiveVoiceCoordinatorTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
@MainActor
private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
var nickname = "me"
var selectedPrivateChatPeer: PeerID?
var isViewingPublicMeshTimeline = false
var blockedPeers: Set<PeerID> = []
private(set) var handledPrivateMessages: [BitchatMessage] = []
private(set) var appendedPublicMessages: [BitchatMessage] = []
private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = []
private(set) var upsertedPublicMessages: [BitchatMessage] = []
private(set) var removedMessageIDs: [String] = []
private(set) var talkerUpdates: [String?] = []
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
func resolveNickname(for peerID: PeerID) -> String { "alice" }
func handlePrivateMessage(_ message: BitchatMessage) { handledPrivateMessages.append(message) }
func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) }
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
upsertedMessages.append((message, peerID))
}
func upsertPublicMeshMessage(_ message: BitchatMessage) {
upsertedPublicMessages.append(message)
}
@discardableResult
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
removedMessageIDs.append(messageID)
return nil
}
func removeMessage(withID messageID: String, cleanupFile: Bool) {
removedMessageIDs.append(messageID)
}
func setActivePublicVoiceTalker(_ nickname: String?) {
if talkerUpdates.last ?? nil != nickname {
talkerUpdates.append(nickname)
}
}
func notifyUIChanged() {}
}
@MainActor
struct ChatLiveVoiceCoordinatorTests {
private let peer = PeerID(str: "aaaabbbbcccc0001")
private func makeBurstID(_ fill: UInt8) -> Data {
Data(repeating: fill, count: VoiceBurstPacket.burstIDSize)
}
private func send(_ packet: VoiceBurstPacket, to coordinator: ChatLiveVoiceCoordinator, from peerID: PeerID) {
coordinator.handleVoiceFramePayload(from: peerID, payload: packet.encode(), timestamp: Date())
}
private func incomingFileURL(burstID: Data) -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false
) else { return nil }
return base
.appendingPathComponent("files/voicenotes/incoming", isDirectory: true)
.appendingPathComponent("voice_live_\(burstID.hexEncodedString()).aac")
}
@Test func burstCreatesBubbleAndPersistsFramesInOrder() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let burstID = makeBurstID(0xA1)
defer { incomingFileURL(burstID: burstID).map { try? FileManager.default.removeItem(at: $0) } }
let frame1 = Data(repeating: 0x01, count: 60)
let frame2 = Data(repeating: 0x02, count: 60)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
#expect(context.handledPrivateMessages.count == 1)
let bubble = try #require(context.handledPrivateMessages.first)
#expect(bubble.isPrivate)
#expect(bubble.senderPeerID == peer)
#expect(bubble.content == "[voice] voice_live_\(burstID.hexEncodedString()).aac")
#expect(coordinator.isLiveVoiceMessage(bubble))
// Deliver out of order: seq 2 buffers behind the seq-1 hole, then
// seq 1 releases both in order.
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .frames([frame2]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([frame1]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 3, kind: .end(totalDataPackets: 2, durationMs: 128))), to: coordinator, from: peer)
let url = try #require(incomingFileURL(burstID: burstID))
let written = try Data(contentsOf: url)
var expected = ADTSFramer.frame(frame1)
expected.append(ADTSFramer.frame(frame2))
#expect(written == expected)
// Burst ended: no longer live, bubble republished for re-render.
#expect(!coordinator.isLiveVoiceMessage(bubble))
#expect(context.upsertedMessages.contains { $0.message.id == bubble.id })
#expect(context.removedMessageIDs.isEmpty)
}
@Test func absorbsFinalizedNoteIntoLiveBubble() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let burstID = makeBurstID(0xB2)
let hex = burstID.hexEncodedString()
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 7, count: 50)]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
let bubble = try #require(context.handledPrivateMessages.first)
let note = BitchatMessage(
sender: "alice",
content: "[voice] voice_\(hex).m4a",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "me",
senderPeerID: peer
)
#expect(coordinator.absorbFinalizedVoiceNote(note))
// The note replaced the live bubble in place: same message ID, new
// content, partial capture deleted.
let replacement = try #require(context.upsertedMessages.last)
#expect(replacement.message.id == bubble.id)
#expect(replacement.message.content == note.content)
#expect(replacement.peerID == peer)
let url = try #require(incomingFileURL(burstID: burstID))
#expect(!FileManager.default.fileExists(atPath: url.path))
// Absorption is one-shot.
#expect(!coordinator.absorbFinalizedVoiceNote(note))
}
@Test func absorbIgnoresUnrelatedVoiceNotes() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
// A classic voice note (date-stamped name) and a live-capture name
// must both pass through untouched.
let classic = BitchatMessage(
sender: "alice", content: "[voice] voice_20260708_1201.m4a", timestamp: Date(),
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
)
#expect(!coordinator.absorbFinalizedVoiceNote(classic))
let liveCapture = BitchatMessage(
sender: "alice", content: "[voice] voice_live_aabbccdd00112233.aac", timestamp: Date(),
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
)
#expect(!coordinator.absorbFinalizedVoiceNote(liveCapture))
// Unknown burst ID.
let unknown = BitchatMessage(
sender: "alice", content: "[voice] voice_ffffffffffffffff.m4a", timestamp: Date(),
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
)
#expect(!coordinator.absorbFinalizedVoiceNote(unknown))
}
@Test func canceledBurstRemovesBubbleAndFile() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let burstID = makeBurstID(0xC3)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 9, count: 40)]))), to: coordinator, from: peer)
let bubble = try #require(context.handledPrivateMessages.first)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .canceled)), to: coordinator, from: peer)
#expect(context.removedMessageIDs == [bubble.id])
let url = try #require(incomingFileURL(burstID: burstID))
#expect(!FileManager.default.fileExists(atPath: url.path))
#expect(!coordinator.isLiveVoiceMessage(bubble))
}
@Test func emptyBurstLeavesNoBubble() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let burstID = makeBurstID(0xD4)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
let bubble = try #require(context.handledPrivateMessages.first)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .end(totalDataPackets: 0, durationMs: 0))), to: coordinator, from: peer)
// Nothing audible arrived: the placeholder bubble is withdrawn.
#expect(context.removedMessageIDs == [bubble.id])
}
@Test func ignoresBlockedPeersAndUnknownControlPackets() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
context.blockedPeers = [peer]
send(try #require(VoiceBurstPacket(burstID: makeBurstID(0xE5), seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
#expect(context.handledPrivateMessages.isEmpty)
context.blockedPeers = []
// END/CANCELED for a burst that never started must not create state.
send(try #require(VoiceBurstPacket(burstID: makeBurstID(0xE6), seq: 5, kind: .end(totalDataPackets: 4, durationMs: 256))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: makeBurstID(0xE7), seq: 5, kind: .canceled)), to: coordinator, from: peer)
#expect(context.handledPrivateMessages.isEmpty)
}
@Test func concurrentAssemblyCapDropsExtraBursts() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
var cleanup: [Data] = []
defer {
for burstID in cleanup {
incomingFileURL(burstID: burstID).map { try? FileManager.default.removeItem(at: $0) }
}
}
for i in 0..<TransportConfig.pttMaxConcurrentAssemblies {
let burstID = makeBurstID(UInt8(0x10 + i))
cleanup.append(burstID)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
}
#expect(context.handledPrivateMessages.count == TransportConfig.pttMaxConcurrentAssemblies)
send(try #require(VoiceBurstPacket(burstID: makeBurstID(0xFF), seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
#expect(context.handledPrivateMessages.count == TransportConfig.pttMaxConcurrentAssemblies)
}
@Test func liveVoiceToggleOffDropsInboundFrames() throws {
let previous = PTTSettings.liveVoiceEnabled
PTTSettings.liveVoiceEnabled = false
defer { PTTSettings.liveVoiceEnabled = previous }
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let burstID = makeBurstID(0xE8)
// Off means classic-notes-only: no live bubble, no partial file.
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 5, count: 40)]))), to: coordinator, from: peer)
#expect(context.handledPrivateMessages.isEmpty)
let url = try #require(incomingFileURL(burstID: burstID))
#expect(!FileManager.default.fileExists(atPath: url.path))
}
@Test func publicBurstCreatesMeshBubbleAndTracksTalker() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let burstID = makeBurstID(0x71)
defer { incomingFileURL(burstID: burstID).map { try? FileManager.default.removeItem(at: $0) } }
func sendPublic(_ packet: VoiceBurstPacket) {
coordinator.handlePublicVoiceFramePayload(from: peer, nickname: "bob", payload: packet.encode(), timestamp: Date())
}
sendPublic(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))))
sendPublic(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 3, count: 50)]))))
// Bubble appends straight to the mesh store with the verified
// nickname, and the floor-courtesy indicator names the talker.
#expect(context.handledPrivateMessages.isEmpty)
let bubble = try #require(context.appendedPublicMessages.first)
#expect(!bubble.isPrivate)
#expect(bubble.sender == "bob")
#expect(context.talkerUpdates.last == "bob")
sendPublic(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))))
// Burst over: talker cleared, bubble republished into the mesh store.
#expect(context.talkerUpdates.last == .some(nil))
#expect(context.upsertedPublicMessages.contains { $0.id == bubble.id })
// The broadcast finalized note absorbs into the same bubble.
let note = BitchatMessage(
sender: "bob", content: "[voice] voice_\(burstID.hexEncodedString()).m4a", timestamp: Date(),
isRelay: false, isPrivate: false, senderPeerID: peer
)
#expect(coordinator.absorbFinalizedVoiceNote(note))
let replacement = try #require(context.upsertedPublicMessages.last)
#expect(replacement.id == bubble.id)
#expect(replacement.content == note.content)
#expect(!replacement.isPrivate)
}
@Test func absorbEnforcesScopeBinding() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let burstID = makeBurstID(0x72)
defer { incomingFileURL(burstID: burstID).map { try? FileManager.default.removeItem(at: $0) } }
// A DM burst...
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 4, count: 40)]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
// ...must not be replaced by a *public* note claiming the same burst.
let publicNote = BitchatMessage(
sender: "alice", content: "[voice] voice_\(burstID.hexEncodedString()).m4a", timestamp: Date(),
isRelay: false, isPrivate: false, senderPeerID: peer
)
#expect(!coordinator.absorbFinalizedVoiceNote(publicNote))
}
@Test func burstIDParsingFromFileNames() {
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_00112233445566ff.m4a") == Data(hexString: "00112233445566ff"))
// Uniquified copies keep the leading hex run.
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_00112233445566ff (1).m4a") == Data(hexString: "00112233445566ff"))
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_20260708_120000.m4a") == nil)
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_live_00112233445566ff.aac") == nil)
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "other.m4a") == nil)
}
}