diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml
index 4ab77bccf..ca4e36c81 100644
--- a/.github/workflows/ios-testflight.yml
+++ b/.github/workflows/ios-testflight.yml
@@ -15,7 +15,53 @@ on:
type: string
jobs:
+ audio-v2-release-gate:
+ if: github.event_name == 'push' || inputs.build_id == ''
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ defaults:
+ run:
+ working-directory: ./backend
+
+ steps:
+ - name: Setup repo
+ uses: actions/checkout@v4
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ - name: Setup uv
+ uses: astral-sh/setup-uv@v4
+ with:
+ version: latest
+
+ - name: Install Opus runtime
+ run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libopus0
+
+ - name: Install backend test dependencies
+ run: uv sync --locked --group test
+
+ - name: Verify Audio V2 phone path
+ run: >-
+ uv run --group test pytest
+ tests/test_audio_v2_websocket_entrypoint.py
+ tests/test_audio_protocol_v2.py
+ tests/test_audio_v2_ingress.py
+ tests/test_audio_v2_streams.py
+ tests/test_audio_persistence_lifecycle.py
+ tests/test_audio_durability.py
+ -q
+
build-and-submit:
+ needs: audio-v2-release-gate
+ if: >-
+ ${{
+ always() &&
+ (needs.audio-v2-release-gate.result == 'success' ||
+ needs.audio-v2-release-gate.result == 'skipped')
+ }}
runs-on: macos-26
timeout-minutes: 120
defaults:
@@ -51,10 +97,11 @@ jobs:
run: |
npm run test:wearable-activation
npm run typecheck
- npm run test:durable-audio-spool
+ npm run test:phone-audio-diagnostics
npm run test:push-notifications
npm run check:theme
npx --no-install expo-modules-autolinking verify --platform ios --verbose
+ swift test --package-path modules/chronicle-duplex-audio/ios
- name: Write and validate ASC API key
env:
diff --git a/app/app.json b/app/app.json
index a95ff120d..e96794115 100644
--- a/app/app.json
+++ b/app/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "chronicle",
"slug": "friend-lite-app",
- "version": "1.14.0",
+ "version": "1.15.0",
"scheme": "chronicle",
"orientation": "portrait",
"icon": "./assets/icon.png",
diff --git a/app/app/index.tsx b/app/app/index.tsx
index dfc256ddc..586a439c5 100644
--- a/app/app/index.tsx
+++ b/app/app/index.tsx
@@ -38,7 +38,7 @@ export default function App() {
// Bluetooth
const { bleManager, bluetoothState, permissionGranted, requestBluetoothPermission, isPermissionsLoading } = useBluetoothManager();
- // Settings (must be before audioStreamer so the token refresh callback can reference it)
+ // Settings
const settings = useSharedAppSettings();
// Live backend reachability (Connection Doctor), re-probed on pull-to-refresh.
@@ -46,26 +46,16 @@ export default function App() {
const [refreshing, setRefreshing] = useState(false);
// Audio
- const audioStreamer = useAudioStreamer({
- autoReconnectEnabled: settings.autoReconnectEnabled,
- onTokenRefreshed: (newToken) => {
- // Update app-level auth state when auto-re-login refreshes the token
- if (settings.currentUserEmail) {
- settings.handleAuthStatusChange(true, settings.currentUserEmail, newToken);
- }
- },
- });
+ const audioStreamer = useAudioStreamer();
const phoneAudioRecorder = usePhoneAudioRecorder();
const { isListeningAudio: isOmiAudioListenerActive, audioPacketsReceived, startAudioListener: originalStartAudioListener, stopAudioListener: originalStopAudioListener, isRetrying: isAudioListenerRetrying, retryAttempts: audioListenerRetryAttempts } = useAudioListener(omiConnection, () => !!deviceConnection.connectedDeviceId);
// Refs for disconnect cleanup
const isOmiAudioListenerActiveRef = useRef(isOmiAudioListenerActive);
- const isAudioStreamingRef = useRef(audioStreamer.isStreaming);
// Track if audio pipeline was active before BLE disconnect (for auto-restart on reconnect)
const wasStreamingBeforeDisconnectRef = useRef(false);
useEffect(() => { isOmiAudioListenerActiveRef.current = isOmiAudioListenerActive; }, [isOmiAudioListenerActive]);
- useEffect(() => { isAudioStreamingRef.current = audioStreamer.isStreaming; }, [audioStreamer.isStreaming]);
// Refs to break the declaration-order cycle:
// onDeviceConnect/onDeviceDisconnect need orchestrator + autoReconnect,
@@ -99,22 +89,13 @@ export default function App() {
}, [omiConnection]);
const onDeviceDisconnect = useCallback(async () => {
- // Remember if audio was active so we can auto-restart on reconnect
- if (isOmiAudioListenerActiveRef.current || isAudioStreamingRef.current) {
+ // BLE disconnect only owns the wearable pipeline. Phone capture is independent.
+ if (isOmiAudioListenerActiveRef.current) {
wasStreamingBeforeDisconnectRef.current = true;
+ await originalStopAudioListener();
+ await audioStreamer.stopStreaming();
}
-
- // Stop audio listener (BLE is gone, can't read audio)
- if (isOmiAudioListenerActiveRef.current) await originalStopAudioListener();
-
- // Keep WebSocket alive — it will reconnect or idle until BLE comes back.
- // Only stop WebSocket for phone audio mode (no BLE needed there).
- if (phoneAudioRecorder.isRecording) {
- audioStreamer.stopStreaming();
- await phoneAudioRecorder.stopRecording();
- orchestratorRef.current?.setIsPhoneAudioMode(false);
- }
- }, [originalStopAudioListener, audioStreamer.stopStreaming, phoneAudioRecorder.stopRecording, phoneAudioRecorder.isRecording]);
+ }, [originalStopAudioListener, audioStreamer.stopStreaming]);
const deviceConnection = useDeviceConnection(omiConnection, bleManager, onDeviceDisconnect, onDeviceConnect);
diff --git a/app/app/settings.tsx b/app/app/settings.tsx
index 1e07ab9ab..fe123ea6c 100644
--- a/app/app/settings.tsx
+++ b/app/app/settings.tsx
@@ -4,6 +4,7 @@ import AuthSection from '@/components/AuthSection';
import BackendStatus from '@/components/BackendStatus';
import NetworkOverview from '@/components/NetworkOverview';
import NotificationsSection from '@/components/NotificationsSection';
+import PhoneAudioDiagnosticsSection from '@/components/PhoneAudioDiagnosticsSection';
import SystemAdminControls from '@/components/SystemAdminControls';
import { Screen, SectionLabel } from '@/components/ui';
import { useSharedAppSettings } from '@/contexts/AppSettingsContext';
@@ -30,6 +31,12 @@ export default function SettingsScreen() {
authenticated={settings.isAuthenticated}
/>
+ Diagnostics
+
+
Administration
diff --git a/app/modules/chronicle-duplex-audio/android/src/main/java/com/chronicle/duplexaudio/ChronicleDuplexAudioModule.kt b/app/modules/chronicle-duplex-audio/android/src/main/java/com/chronicle/duplexaudio/ChronicleDuplexAudioModule.kt
index a764e08c2..f3d0b99e5 100644
--- a/app/modules/chronicle-duplex-audio/android/src/main/java/com/chronicle/duplexaudio/ChronicleDuplexAudioModule.kt
+++ b/app/modules/chronicle-duplex-audio/android/src/main/java/com/chronicle/duplexaudio/ChronicleDuplexAudioModule.kt
@@ -219,6 +219,7 @@ class ChronicleDuplexAudioModule : Module() {
"sampleRate" to 16_000,
"channels" to 1,
"frameDurationMs" to durationMs,
+ "audioLevel" to DuplexAudioPolicy.audioLevel(frame, count),
"opusBase64" to Base64.encodeToString(packet, Base64.NO_WRAP),
),
)
diff --git a/app/modules/chronicle-duplex-audio/android/src/main/java/com/chronicle/duplexaudio/DuplexAudioPolicy.kt b/app/modules/chronicle-duplex-audio/android/src/main/java/com/chronicle/duplexaudio/DuplexAudioPolicy.kt
index 7593a98c7..de98eb498 100644
--- a/app/modules/chronicle-duplex-audio/android/src/main/java/com/chronicle/duplexaudio/DuplexAudioPolicy.kt
+++ b/app/modules/chronicle-duplex-audio/android/src/main/java/com/chronicle/duplexaudio/DuplexAudioPolicy.kt
@@ -20,6 +20,21 @@ internal object DuplexAudioPolicy {
): Boolean = current != null &&
(responseId == "*" || responseId == current.id) &&
cancellationGeneration >= current.generation
+
+ fun audioLevel(pcm: ByteArray, byteCount: Int): Double {
+ val boundedBytes = minOf(byteCount, pcm.size)
+ val usableBytes = boundedBytes - (boundedBytes % 2)
+ if (usableBytes <= 0) return 0.0
+ var sumOfSquares = 0.0
+ var index = 0
+ while (index < usableBytes) {
+ val sample = ((pcm[index].toInt() and 0xff) or (pcm[index + 1].toInt() shl 8)).toShort()
+ val normalized = sample.toDouble() / 32_768.0
+ sumOfSquares += normalized * normalized
+ index += 2
+ }
+ return kotlin.math.min(1.0, kotlin.math.sqrt(sumOfSquares / (usableBytes / 2).toDouble()))
+ }
}
internal data class EpochResponse(
diff --git a/app/modules/chronicle-duplex-audio/android/src/test/java/com/chronicle/duplexaudio/DuplexAudioPolicyTest.kt b/app/modules/chronicle-duplex-audio/android/src/test/java/com/chronicle/duplexaudio/DuplexAudioPolicyTest.kt
index c67657eb2..d1875a6fe 100644
--- a/app/modules/chronicle-duplex-audio/android/src/test/java/com/chronicle/duplexaudio/DuplexAudioPolicyTest.kt
+++ b/app/modules/chronicle-duplex-audio/android/src/test/java/com/chronicle/duplexaudio/DuplexAudioPolicyTest.kt
@@ -51,4 +51,14 @@ class DuplexAudioPolicyTest {
gate.schedule(EpochResponse("two", 1, 4))
}
}
+
+ @Test fun audioMeterReportsSilenceAndNormalizedPeak() {
+ assertEquals(0.0, DuplexAudioPolicy.audioLevel(ByteArray(640), 640), 0.001)
+ val halfScale = ByteArray(640)
+ for (index in halfScale.indices step 2) {
+ halfScale[index] = 0
+ halfScale[index + 1] = 64
+ }
+ assertEquals(0.5, DuplexAudioPolicy.audioLevel(halfScale, 640), 0.001)
+ }
}
diff --git a/app/modules/chronicle-duplex-audio/index.ts b/app/modules/chronicle-duplex-audio/index.ts
index 7419c0da0..2bb290922 100644
--- a/app/modules/chronicle-duplex-audio/index.ts
+++ b/app/modules/chronicle-duplex-audio/index.ts
@@ -9,6 +9,11 @@ import type { VoiceCapabilities } from '../../src/protocol/audioCapabilities';
export interface StartVoiceSessionOptions {
captureEpoch: number;
+ diagnosticProfile?:
+ | 'production'
+ | 'voice_processing_hold'
+ | 'plain_capture_hold'
+ | 'system_tap_format_hold';
}
export interface NativeOpusFrame {
@@ -18,9 +23,29 @@ export interface NativeOpusFrame {
sampleRate: 16000;
channels: 1;
frameDurationMs: number;
+ audioLevel: number;
opusBase64: string;
}
+export interface NativeCaptureDiagnostic {
+ captureEpoch: number;
+ stage:
+ | 'tap_received'
+ | 'pcm_converted'
+ | 'pcm_conversion_failed'
+ | 'pcm_empty'
+ | 'opus_encoded'
+ | 'opus_encode_failed'
+ | 'voice_processing_fallback'
+ | 'capture_failed'
+ | 'system_change'
+ | 'watchdog_evaluated';
+ monotonicTimestampMs: number;
+ frameCount?: number;
+ byteCount?: number;
+ detail?: string;
+}
+
export interface NativeResponse {
responseId: string;
generation: number;
@@ -48,8 +73,32 @@ export interface NativeStopResult {
failureCode: 'far_field_restore_failed' | 'permission_denied' | 'engine_unavailable' | null;
}
+export interface NativeVoiceSessionDiagnostics {
+ diagnosticProfile: NonNullable;
+ captureEpoch: number;
+ engineRunning: boolean;
+ sessionRunning: boolean;
+ tapInstalled: boolean;
+ tapFrameCount: number;
+ convertedFrameCount: number;
+ opusPacketCount: number;
+ opusByteCount: number;
+ peakAudioLevel: number;
+ systemChangeCount: number;
+ lastSystemChangeReason: string;
+ watchdogEvaluationCount: number;
+ voiceProcessingEnabled: boolean;
+ audioSessionCategory: string;
+ audioSessionMode: string;
+ audioSessionSampleRate: number;
+ audioSessionIOBufferDurationMs: number;
+ inputFormat: string;
+ outputFormat: string;
+}
+
type ChronicleDuplexAudioNative = NativeModule & {
startVoiceSession(options: StartVoiceSessionOptions): Promise;
+ getVoiceSessionDiagnostics(): Promise;
scheduleResponse(response: NativeResponse): Promise;
cancelResponse(responseId: string, generation: number): Promise;
stopVoiceSession(): Promise;
@@ -57,6 +106,10 @@ type ChronicleDuplexAudioNative = NativeModule & {
eventName: 'onOpusFrame',
listener: (event: NativeOpusFrame) => void
): EventSubscription;
+ addListener(
+ eventName: 'onCaptureDiagnostic',
+ listener: (event: NativeCaptureDiagnostic) => void
+ ): EventSubscription;
addListener(
eventName: 'onPlaybackState',
listener: (event: NativePlaybackState) => void
@@ -94,6 +147,16 @@ export function addOpusFrameListener(
return requireNative().addListener('onOpusFrame', listener);
}
+export function getVoiceSessionDiagnostics(): Promise {
+ return requireNative().getVoiceSessionDiagnostics();
+}
+
+export function addCaptureDiagnosticListener(
+ listener: (event: NativeCaptureDiagnostic) => void
+): EventSubscription {
+ return requireNative().addListener('onCaptureDiagnostic', listener);
+}
+
export function addPlaybackStateListener(
listener: (event: NativePlaybackState) => void
): EventSubscription {
diff --git a/app/modules/chronicle-duplex-audio/ios/ChronicleDuplexAudioModule.swift b/app/modules/chronicle-duplex-audio/ios/ChronicleDuplexAudioModule.swift
index 221c0060d..976afbdc3 100644
--- a/app/modules/chronicle-duplex-audio/ios/ChronicleDuplexAudioModule.swift
+++ b/app/modules/chronicle-duplex-audio/ios/ChronicleDuplexAudioModule.swift
@@ -5,9 +5,24 @@ public final class ChronicleDuplexAudioModule: Module {
private let engine = AVAudioEngine()
private let player = AVAudioPlayerNode()
private let controlQueue = DispatchQueue(label: "chronicle.duplex.audio")
+ private let captureDiagnosticLock = NSLock()
+ private let captureMetricsLock = NSLock()
private var converter: AVAudioConverter?
- private var opusConverter: AVAudioConverter?
+ private var opusEncoder: ChronicleOpusPacketEncoder?
+ private var pcmPacketizer: ChroniclePcm16Packetizer?
+ private var emittedCaptureDiagnosticStages = Set()
private var captureEpoch = 0
+ private var tapFrameCount = 0
+ private var convertedFrameCount = 0
+ private var opusPacketCount = 0
+ private var opusByteCount = 0
+ private var peakAudioLevel = 0.0
+ private var systemChangeCount = 0
+ private var lastSystemChangeReason = "none"
+ private var watchdogEvaluationCount = 0
+ private var captureWatchdogGeneration = 0
+ private var diagnosticProfile = DuplexDiagnosticProfile.production
+ private var voiceProcessingFallbackForced = false
private var voiceProcessingEnabled = false
private var captureSuppressed = false
private var currentResponse: (id: String, generation: Int)?
@@ -21,7 +36,7 @@ public final class ChronicleDuplexAudioModule: Module {
public func definition() -> ModuleDefinition {
Name("ChronicleDuplexAudio")
- Events("onOpusFrame", "onPlaybackState", "onRouteChange")
+ Events("onOpusFrame", "onCaptureDiagnostic", "onPlaybackState", "onRouteChange")
OnCreate { [weak self] in
self?.installObservers()
@@ -38,15 +53,25 @@ public final class ChronicleDuplexAudioModule: Module {
guard let epoch = options["captureEpoch"] as? Int, epoch >= 0 else {
throw Exception(name: "invalid_capture_epoch", description: "captureEpoch must be non-negative")
}
+ let profileName = options["diagnosticProfile"] as? String ?? DuplexDiagnosticProfile.production.rawValue
+ guard let profile = DuplexDiagnosticProfile(rawValue: profileName) else {
+ throw Exception(name: "invalid_diagnostic_profile", description: "Unknown diagnosticProfile")
+ }
guard await self.requestRecordPermission() else {
throw Exception(name: "permission_denied", description: "Microphone permission denied")
}
return try await self.onControlQueue {
- try self.startEngine(captureEpoch: epoch)
+ try self.startEngine(captureEpoch: epoch, diagnosticProfile: profile)
return self.capabilities()
}
}
+ AsyncFunction("getVoiceSessionDiagnostics") { () async -> [String: Any] in
+ await self.onControlQueueValue {
+ self.voiceSessionDiagnostics()
+ }
+ }
+
AsyncFunction("scheduleResponse") { (response: [String: Any]) async throws in
try await self.onControlQueue {
try self.schedule(response: response)
@@ -73,7 +98,9 @@ public final class ChronicleDuplexAudioModule: Module {
AsyncFunction("stopVoiceSession") { () async -> [String: Any?] in
let restored = await self.onControlQueueValue {
- self.tearDownEngine(deactivateSession: true)
+ let restored = self.tearDownEngine(deactivateSession: true)
+ self.voiceProcessingFallbackForced = false
+ return restored
}
return [
"restorationSucceeded": restored,
@@ -121,9 +148,16 @@ public final class ChronicleDuplexAudioModule: Module {
}
}
- private func startEngine(captureEpoch: Int) throws {
+ private func startEngine(
+ captureEpoch: Int,
+ diagnosticProfile: DuplexDiagnosticProfile
+ ) throws {
tearDownEngine(deactivateSession: false)
self.captureEpoch = captureEpoch
+ self.diagnosticProfile = diagnosticProfile
+ captureDiagnosticLock.lock()
+ emittedCaptureDiagnosticStages.removeAll()
+ captureDiagnosticLock.unlock()
let session = AVAudioSession.sharedInstance()
if !sessionConfigured {
@@ -145,48 +179,71 @@ public final class ChronicleDuplexAudioModule: Module {
engine.connect(player, to: engine.mainMixerNode, format: outputFormat)
let input = engine.inputNode
- do {
- try input.setVoiceProcessingEnabled(true)
- voiceProcessingEnabled = input.isVoiceProcessingEnabled
- } catch {
+ if let forcedVoiceProcessing = diagnosticProfile.forcedVoiceProcessing {
+ if forcedVoiceProcessing {
+ do {
+ try input.setVoiceProcessingEnabled(true)
+ voiceProcessingEnabled = input.isVoiceProcessingEnabled
+ } catch {
+ voiceProcessingEnabled = false
+ emitCaptureDiagnostic(
+ stage: "capture_failed",
+ detail: "voice processing could not be enabled: \(error)"
+ )
+ }
+ } else {
+ try? input.setVoiceProcessingEnabled(false)
+ voiceProcessingEnabled = false
+ }
+ } else if voiceProcessingFallbackForced {
+ try? input.setVoiceProcessingEnabled(false)
voiceProcessingEnabled = false
+ } else {
+ do {
+ try input.setVoiceProcessingEnabled(true)
+ voiceProcessingEnabled = input.isVoiceProcessingEnabled
+ } catch {
+ voiceProcessingEnabled = false
+ }
}
- let inputFormat = input.outputFormat(forBus: 0)
- guard let targetFormat = AVAudioFormat(
- commonFormat: .pcmFormatInt16,
- sampleRate: 16_000,
- channels: 1,
- interleaved: true
- ), let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else {
- throw Exception(name: "engine_unavailable", description: "Cannot create the 16 kHz PCM converter")
+ // iOS input taps must be installed with the hardware input format. After
+ // VoiceProcessingIO is enabled, outputFormat can produce a running engine
+ // whose input tap never receives a buffer.
+ let inputFormat = input.inputFormat(forBus: 0)
+ let opusEncoder: ChronicleOpusPacketEncoder
+ do {
+ opusEncoder = try ChronicleOpusPacketEncoder()
+ } catch {
+ throw Exception(name: "engine_unavailable", description: "Cannot create the raw Opus encoder: \(error)")
}
- guard let opusFormat = AVAudioFormat(settings: [
- AVFormatIDKey: kAudioFormatOpus,
- AVSampleRateKey: 16_000,
- AVNumberOfChannelsKey: 1,
- AVEncoderBitRateKey: 24_000,
- ]), let opusConverter = AVAudioConverter(from: targetFormat, to: opusFormat) else {
- throw Exception(name: "engine_unavailable", description: "Cannot create the raw Opus encoder")
+ guard let converter = AVAudioConverter(from: inputFormat, to: opusEncoder.inputFormat) else {
+ throw Exception(name: "engine_unavailable", description: "Cannot create the 16 kHz PCM converter")
}
- opusConverter.bitRate = 24_000
self.converter = converter
- self.opusConverter = opusConverter
+ self.opusEncoder = opusEncoder
+ self.pcmPacketizer = ChroniclePcm16Packetizer()
+ resetCaptureMetrics()
let inputFrameCount = AVAudioFrameCount(round(inputFormat.sampleRate * 0.02))
- input.installTap(onBus: 0, bufferSize: inputFrameCount, format: inputFormat) { [weak self] buffer, _ in
+ let tapFormat: AVAudioFormat? = diagnosticProfile.usesSystemTapFormat ? nil : inputFormat
+ input.installTap(onBus: 0, bufferSize: inputFrameCount, format: tapFormat) { [weak self] buffer, _ in
+ self?.observeTapFrame()
+ self?.emitCaptureDiagnostic(stage: "tap_received")
self?.emitOpus(buffer)
}
tapInstalled = true
engine.prepare()
try engine.start()
sessionRunning = true
+ scheduleCaptureWatchdog()
}
private func emitOpus(_ input: AVAudioPCMBuffer) {
guard !captureSuppressed,
engine.isRunning,
let converter,
- let opusConverter else { return }
+ let opusEncoder,
+ let pcmPacketizer else { return }
let capacity = ChronicleDuplexResampler.outputCapacity(
inputFrames: input.frameLength,
inputRate: input.format.sampleRate
@@ -205,40 +262,183 @@ public final class ChronicleDuplexAudioModule: Module {
state.pointee = .haveData
return input
}
- guard status != .error,
- conversionError == nil,
- output.frameLength > 0 else { return }
- let compressed = AVAudioCompressedBuffer(
- format: opusConverter.outputFormat,
- packetCapacity: 1,
- maximumPacketSize: 1_275
- )
- var opusSupplied = false
- var opusError: NSError?
- let opusStatus = opusConverter.convert(to: compressed, error: &opusError) { _, state in
- if opusSupplied {
- state.pointee = .noDataNow
- return nil
+ guard status != .error, conversionError == nil else {
+ emitCaptureDiagnostic(
+ stage: "pcm_conversion_failed",
+ detail: conversionError?.localizedDescription ?? "converter_status_error"
+ )
+ return
+ }
+ guard output.frameLength > 0 else {
+ emitCaptureDiagnostic(stage: "pcm_empty")
+ return
+ }
+ emitCaptureDiagnostic(stage: "pcm_converted", frameCount: Int(output.frameLength))
+ observeConvertedFrames(Int(output.frameLength))
+ guard let samples = output.int16ChannelData?[0] else {
+ emitCaptureDiagnostic(stage: "pcm_conversion_failed", detail: "16 kHz PCM samples unavailable")
+ return
+ }
+ let packets = pcmPacketizer.append(samples: samples, count: Int(output.frameLength))
+ let durationMs = 20.0
+ let batchEndWallMs = Date().timeIntervalSince1970 * 1_000
+ let batchEndMonotonicMs = ProcessInfo.processInfo.systemUptime * 1_000
+ for (index, packet) in packets.enumerated() {
+ let data: Data
+ do {
+ data = try opusEncoder.encode(samples: packet)
+ } catch {
+ emitCaptureDiagnostic(stage: "opus_encode_failed", detail: String(describing: error))
+ return
}
- opusSupplied = true
- state.pointee = .haveData
- return output
- }
- guard opusStatus != .error,
- opusError == nil,
- compressed.packetCount == 1,
- compressed.byteLength > 0 else { return }
- let data = Data(bytes: compressed.data, count: Int(compressed.byteLength))
- let durationMs = Double(output.frameLength) / 16_000 * 1_000
- sendEvent("onOpusFrame", [
+ emitCaptureDiagnostic(stage: "opus_encoded", frameCount: packet.count, byteCount: data.count)
+ let packetOffsetMs = Double(packets.count - index) * durationMs
+ let audioLevel = packet.withUnsafeBufferPointer {
+ ChronicleAudioMeter.level(samples: $0.baseAddress!, count: $0.count)
+ }
+ observeEncodedPacket(byteCount: data.count, audioLevel: audioLevel)
+ sendEvent("onOpusFrame", [
+ "captureEpoch": captureEpoch,
+ "capturedAtMs": batchEndWallMs - packetOffsetMs,
+ "monotonicTimestampMs": batchEndMonotonicMs - packetOffsetMs,
+ "sampleRate": 16_000,
+ "channels": 1,
+ "frameDurationMs": durationMs,
+ "audioLevel": audioLevel,
+ "opusBase64": data.base64EncodedString(),
+ ])
+ }
+ }
+
+ private func resetCaptureMetrics() {
+ captureMetricsLock.lock()
+ tapFrameCount = 0
+ convertedFrameCount = 0
+ opusPacketCount = 0
+ opusByteCount = 0
+ peakAudioLevel = 0
+ systemChangeCount = 0
+ lastSystemChangeReason = "none"
+ watchdogEvaluationCount = 0
+ captureMetricsLock.unlock()
+ }
+
+ private func observeTapFrame() {
+ captureMetricsLock.lock()
+ tapFrameCount += 1
+ captureMetricsLock.unlock()
+ }
+
+ private func capturedTapFrameCount() -> Int {
+ captureMetricsLock.lock()
+ let count = tapFrameCount
+ captureMetricsLock.unlock()
+ return count
+ }
+
+ private func observeConvertedFrames(_ count: Int) {
+ captureMetricsLock.lock()
+ convertedFrameCount += count
+ captureMetricsLock.unlock()
+ }
+
+ private func observeEncodedPacket(byteCount: Int, audioLevel: Double) {
+ captureMetricsLock.lock()
+ opusPacketCount += 1
+ opusByteCount += byteCount
+ peakAudioLevel = max(peakAudioLevel, audioLevel)
+ captureMetricsLock.unlock()
+ }
+
+ private func observeSystemChange(_ reason: String) {
+ captureMetricsLock.lock()
+ systemChangeCount += 1
+ lastSystemChangeReason = reason
+ captureMetricsLock.unlock()
+ }
+
+ private func observeWatchdogEvaluation() {
+ captureMetricsLock.lock()
+ watchdogEvaluationCount += 1
+ captureMetricsLock.unlock()
+ }
+
+ private func scheduleCaptureWatchdog() {
+ captureWatchdogGeneration += 1
+ let generation = captureWatchdogGeneration
+ let epoch = captureEpoch
+ controlQueue.asyncAfter(deadline: .now() + 1.5) { [weak self] in
+ guard let self,
+ self.sessionRunning,
+ self.captureEpoch == epoch,
+ self.captureWatchdogGeneration == generation else { return }
+ let tapCount = self.capturedTapFrameCount()
+ self.observeWatchdogEvaluation()
+ let action = DuplexCaptureWatchdog.recoveryAction(
+ tapFrameCount: tapCount,
+ voiceProcessingEnabled: self.voiceProcessingEnabled
+ )
+ self.emitCaptureDiagnostic(
+ stage: "watchdog_evaluated",
+ detail: "profile=\(self.diagnosticProfile.rawValue) taps=\(tapCount) action=\(String(describing: action))"
+ )
+ if self.diagnosticProfile != .production {
+ if tapCount == 0 {
+ self.emitCaptureDiagnostic(
+ stage: "capture_failed",
+ detail: "diagnostic profile produced no input tap after 1500ms"
+ )
+ }
+ return
+ }
+ switch action {
+ case .none:
+ return
+ case .disableVoiceProcessing:
+ self.voiceProcessingFallbackForced = true
+ self.emitCaptureDiagnostic(
+ stage: "voice_processing_fallback",
+ detail: "no input tap after 1500ms taps=\(tapCount)"
+ )
+ self.tearDownEngine(deactivateSession: false)
+ let payload: [String: Any] = [
+ "captureEpoch": epoch,
+ "reason": "effect_failed",
+ "capabilities": self.capabilities(),
+ ]
+ DispatchQueue.main.async { [weak self] in
+ self?.sendEvent("onRouteChange", payload)
+ }
+ case .reportFailure:
+ self.emitCaptureDiagnostic(
+ stage: "capture_failed",
+ detail: "no input tap after voice-processing fallback taps=\(tapCount)"
+ )
+ }
+ }
+ }
+
+ private func emitCaptureDiagnostic(
+ stage: String,
+ frameCount: Int? = nil,
+ byteCount: Int? = nil,
+ detail: String? = nil
+ ) {
+ captureDiagnosticLock.lock()
+ let inserted = emittedCaptureDiagnosticStages.insert(stage).inserted
+ captureDiagnosticLock.unlock()
+ guard inserted else { return }
+ var payload: [String: Any] = [
"captureEpoch": captureEpoch,
- "capturedAtMs": Date().timeIntervalSince1970 * 1_000 - durationMs,
- "monotonicTimestampMs": ProcessInfo.processInfo.systemUptime * 1_000 - durationMs,
- "sampleRate": 16_000,
- "channels": 1,
- "frameDurationMs": durationMs,
- "opusBase64": data.base64EncodedString(),
- ])
+ "stage": stage,
+ "monotonicTimestampMs": ProcessInfo.processInfo.systemUptime * 1_000,
+ ]
+ if let frameCount { payload["frameCount"] = frameCount }
+ if let byteCount { payload["byteCount"] = byteCount }
+ if let detail { payload["detail"] = String(detail.prefix(240)) }
+ DispatchQueue.main.async { [weak self] in
+ self?.sendEvent("onCaptureDiagnostic", payload)
+ }
}
private func schedule(response: [String: Any]) throws {
@@ -385,6 +585,52 @@ public final class ChronicleDuplexAudioModule: Module {
]
}
+ private func voiceSessionDiagnostics() -> [String: Any] {
+ captureMetricsLock.lock()
+ let metrics: [String: Any] = [
+ "tapFrameCount": tapFrameCount,
+ "convertedFrameCount": convertedFrameCount,
+ "opusPacketCount": opusPacketCount,
+ "opusByteCount": opusByteCount,
+ "peakAudioLevel": peakAudioLevel,
+ "systemChangeCount": systemChangeCount,
+ "lastSystemChangeReason": lastSystemChangeReason,
+ "watchdogEvaluationCount": watchdogEvaluationCount,
+ ]
+ captureMetricsLock.unlock()
+
+ let session = AVAudioSession.sharedInstance()
+ let inputFormat = engine.inputNode.inputFormat(forBus: 0)
+ let outputFormat = engine.outputNode.outputFormat(forBus: 0)
+ return metrics.merging([
+ "diagnosticProfile": diagnosticProfile.rawValue,
+ "captureEpoch": captureEpoch,
+ "engineRunning": engine.isRunning,
+ "sessionRunning": sessionRunning,
+ "tapInstalled": tapInstalled,
+ "voiceProcessingEnabled": voiceProcessingEnabled,
+ "audioSessionCategory": session.category.rawValue,
+ "audioSessionMode": session.mode.rawValue,
+ "audioSessionSampleRate": session.sampleRate,
+ "audioSessionIOBufferDurationMs": session.ioBufferDuration * 1_000,
+ "inputFormat": formatSummary(inputFormat),
+ "outputFormat": formatSummary(outputFormat),
+ ]) { _, newest in newest }
+ }
+
+ private func formatSummary(_ format: AVAudioFormat) -> String {
+ let commonFormat: String
+ switch format.commonFormat {
+ case .pcmFormatFloat32: commonFormat = "float32"
+ case .pcmFormatFloat64: commonFormat = "float64"
+ case .pcmFormatInt16: commonFormat = "int16"
+ case .pcmFormatInt32: commonFormat = "int32"
+ case .otherFormat: commonFormat = "other"
+ @unknown default: commonFormat = "unknown"
+ }
+ return "\(Int(format.sampleRate))Hz/\(format.channelCount)ch/\(commonFormat)/\(format.isInterleaved ? "interleaved" : "noninterleaved")"
+ }
+
private func effect(requested: Bool, available: Bool, enabled: Bool) -> [String: Bool] {
["requested": requested, "available": available, "enabled": enabled]
}
@@ -413,6 +659,7 @@ public final class ChronicleDuplexAudioModule: Module {
@discardableResult
private func tearDownEngine(deactivateSession: Bool) -> Bool {
var restored = true
+ captureWatchdogGeneration += 1
cancelCurrent(errorCode: nil)
sessionRunning = false
if tapInstalled {
@@ -423,7 +670,8 @@ public final class ChronicleDuplexAudioModule: Module {
engine.stop()
if player.engine != nil { engine.detach(player) }
converter = nil
- opusConverter = nil
+ opusEncoder = nil
+ pcmPacketizer = nil
voiceProcessingEnabled = false
captureSuppressed = false
if deactivateSession {
@@ -473,8 +721,27 @@ public final class ChronicleDuplexAudioModule: Module {
private func handleSystemChange(reason: String, errorCode: String) {
controlQueue.async { [weak self] in
- guard let self, self.sessionRunning else { return }
+ guard let self else { return }
+ self.observeSystemChange(reason)
+ let held = DuplexSystemChangePolicy.shouldHoldEngine(
+ reason: reason,
+ sessionRunning: self.sessionRunning,
+ diagnosticProfile: self.diagnosticProfile
+ )
+ self.emitCaptureDiagnostic(
+ stage: "system_change",
+ detail: "reason=\(reason) session_running=\(self.sessionRunning) held=\(held) engine_running=\(self.engine.isRunning)"
+ )
+ guard self.sessionRunning else { return }
let changedCapabilities = self.capabilities()
+ if held {
+ self.sendEvent("onRouteChange", [
+ "captureEpoch": self.captureEpoch,
+ "reason": reason,
+ "capabilities": changedCapabilities,
+ ])
+ return
+ }
self.cancelCurrent(errorCode: errorCode)
self.tearDownEngine(deactivateSession: false)
self.sendEvent("onRouteChange", [
diff --git a/app/modules/chronicle-duplex-audio/ios/DuplexAudioState.swift b/app/modules/chronicle-duplex-audio/ios/DuplexAudioState.swift
index 66ee2c519..467640f31 100644
--- a/app/modules/chronicle-duplex-audio/ios/DuplexAudioState.swift
+++ b/app/modules/chronicle-duplex-audio/ios/DuplexAudioState.swift
@@ -5,6 +5,13 @@ enum DuplexAudioStateError: Error {
case responseAlreadyScheduled
}
+enum ChronicleOpusEncoderError: Error {
+ case formatUnavailable
+ case converterUnavailable
+ case conversionFailed(String)
+ case packetUnavailable
+}
+
struct DuplexResponseBinding: Equatable {
let id: String
let generation: Int
@@ -70,3 +77,181 @@ enum ChronicleDuplexResampler {
AVAudioFrameCount(max(1, ceil(Double(inputFrames) * outputRate / inputRate)))
}
}
+
+enum ChronicleAudioMeter {
+ static func level(samples: UnsafePointer, count: Int) -> Double {
+ guard count > 0 else { return 0 }
+ var sumOfSquares = 0.0
+ for index in 0.. DuplexCaptureRecoveryAction {
+ guard tapFrameCount == 0 else { return .none }
+ return voiceProcessingEnabled ? .disableVoiceProcessing : .reportFailure
+ }
+}
+
+enum DuplexSystemChangePolicy {
+ static func shouldHoldEngine(
+ reason: String,
+ sessionRunning: Bool,
+ diagnosticProfile: DuplexDiagnosticProfile
+ ) -> Bool {
+ guard sessionRunning else { return false }
+ if diagnosticProfile.holdsEngineOnSystemChange { return true }
+
+ // AVAudioSession emits route-change notifications while the initial
+ // play-and-record route is settling. Tearing down here leaves the engine
+ // looking successfully started but prevents its input tap from ever
+ // delivering a frame. A later, real route change is still forwarded to JS,
+ // whose bound capture lifecycle performs the restart.
+ return reason == "route_changed"
+ }
+}
+
+enum DuplexDiagnosticProfile: String, CaseIterable {
+ case production
+ case voiceProcessingHold = "voice_processing_hold"
+ case plainCaptureHold = "plain_capture_hold"
+ case systemTapFormatHold = "system_tap_format_hold"
+
+ var forcedVoiceProcessing: Bool? {
+ switch self {
+ case .production: return nil
+ case .voiceProcessingHold, .systemTapFormatHold: return true
+ case .plainCaptureHold: return false
+ }
+ }
+
+ var usesSystemTapFormat: Bool {
+ self == .systemTapFormatHold
+ }
+
+ var holdsEngineOnSystemChange: Bool {
+ self != .production
+ }
+}
+
+final class ChroniclePcm16Packetizer {
+ private(set) var pendingSampleCount = 0
+ private var pending: [Int16] = []
+
+ func append(samples: UnsafePointer, count: Int) -> [[Int16]] {
+ guard count > 0 else { return [] }
+ pending.append(contentsOf: UnsafeBufferPointer(start: samples, count: count))
+ var packets: [[Int16]] = []
+ let packetSize = Int(ChronicleOpusPacketEncoder.framesPerPacket)
+ let packetCount = pending.count / packetSize
+ for packetIndex in 0.. Data {
+ guard input.format == inputFormat,
+ input.frameLength == Self.framesPerPacket else {
+ throw ChronicleOpusEncoderError.formatUnavailable
+ }
+ let maximumPacketSize = max(1, converter.maximumOutputPacketSize)
+ let compressed = AVAudioCompressedBuffer(
+ format: outputFormat,
+ packetCapacity: 1,
+ maximumPacketSize: maximumPacketSize
+ )
+ var supplied = false
+ var conversionError: NSError?
+ let status = converter.convert(to: compressed, error: &conversionError) { _, state in
+ if supplied {
+ state.pointee = .noDataNow
+ return nil
+ }
+ supplied = true
+ state.pointee = .haveData
+ return input
+ }
+ if status == .error || conversionError != nil {
+ throw ChronicleOpusEncoderError.conversionFailed(
+ conversionError?.localizedDescription ?? "converter_status_error"
+ )
+ }
+ guard compressed.packetCount == 1, compressed.byteLength > 0 else {
+ throw ChronicleOpusEncoderError.packetUnavailable
+ }
+ return Data(bytes: compressed.data, count: Int(compressed.byteLength))
+ }
+
+ func encode(samples: [Int16]) throws -> Data {
+ guard samples.count == Int(Self.framesPerPacket),
+ let input = AVAudioPCMBuffer(
+ pcmFormat: inputFormat,
+ frameCapacity: Self.framesPerPacket
+ ),
+ let target = input.int16ChannelData?[0] else {
+ throw ChronicleOpusEncoderError.formatUnavailable
+ }
+ input.frameLength = Self.framesPerPacket
+ samples.withUnsafeBufferPointer { source in
+ target.update(from: source.baseAddress!, count: source.count)
+ }
+ return try encode(input)
+ }
+}
diff --git a/app/modules/chronicle-duplex-audio/ios/Tests/DuplexAudioStateTests.swift b/app/modules/chronicle-duplex-audio/ios/Tests/DuplexAudioStateTests.swift
index 1b024f16c..29472beea 100644
--- a/app/modules/chronicle-duplex-audio/ios/Tests/DuplexAudioStateTests.swift
+++ b/app/modules/chronicle-duplex-audio/ios/Tests/DuplexAudioStateTests.swift
@@ -72,6 +72,172 @@ final class DuplexAudioStateTests: XCTestCase {
)
}
+ func testAudioMeterReportsSilenceAndNormalizedPeak() {
+ let silence = [Int16](repeating: 0, count: 320)
+ let halfScale = [Int16](repeating: 16_384, count: 320)
+ silence.withUnsafeBufferPointer { samples in
+ XCTAssertEqual(ChronicleAudioMeter.level(samples: samples.baseAddress!, count: samples.count), 0)
+ }
+ halfScale.withUnsafeBufferPointer { samples in
+ XCTAssertEqual(
+ ChronicleAudioMeter.level(samples: samples.baseAddress!, count: samples.count),
+ 0.5,
+ accuracy: 0.001
+ )
+ }
+ }
+
+ func testCaptureWatchdogFallsBackWhenVoiceProcessingTapIsSilent() {
+ XCTAssertEqual(
+ DuplexCaptureWatchdog.recoveryAction(
+ tapFrameCount: 0,
+ voiceProcessingEnabled: true
+ ),
+ .disableVoiceProcessing
+ )
+ }
+
+ func testCaptureWatchdogLeavesDeliveringTapAlone() {
+ XCTAssertEqual(
+ DuplexCaptureWatchdog.recoveryAction(
+ tapFrameCount: 1,
+ voiceProcessingEnabled: true
+ ),
+ .none
+ )
+ }
+
+ func testCaptureWatchdogReportsFailureAfterFallbackTapIsSilent() {
+ XCTAssertEqual(
+ DuplexCaptureWatchdog.recoveryAction(
+ tapFrameCount: 0,
+ voiceProcessingEnabled: false
+ ),
+ .reportFailure
+ )
+ }
+
+ func testDiagnosticMatrixChangesOneCaptureVariableAtATime() {
+ XCTAssertNil(DuplexDiagnosticProfile.production.forcedVoiceProcessing)
+ XCTAssertFalse(DuplexDiagnosticProfile.production.holdsEngineOnSystemChange)
+
+ XCTAssertEqual(DuplexDiagnosticProfile.voiceProcessingHold.forcedVoiceProcessing, true)
+ XCTAssertTrue(DuplexDiagnosticProfile.voiceProcessingHold.holdsEngineOnSystemChange)
+ XCTAssertFalse(DuplexDiagnosticProfile.voiceProcessingHold.usesSystemTapFormat)
+
+ XCTAssertEqual(DuplexDiagnosticProfile.plainCaptureHold.forcedVoiceProcessing, false)
+ XCTAssertTrue(DuplexDiagnosticProfile.plainCaptureHold.holdsEngineOnSystemChange)
+ XCTAssertFalse(DuplexDiagnosticProfile.plainCaptureHold.usesSystemTapFormat)
+
+ XCTAssertEqual(DuplexDiagnosticProfile.systemTapFormatHold.forcedVoiceProcessing, true)
+ XCTAssertTrue(DuplexDiagnosticProfile.systemTapFormatHold.holdsEngineOnSystemChange)
+ XCTAssertTrue(DuplexDiagnosticProfile.systemTapFormatHold.usesSystemTapFormat)
+ }
+
+ func testProductionKeepsCapturingThroughInitialRouteSettlement() {
+ XCTAssertTrue(
+ DuplexSystemChangePolicy.shouldHoldEngine(
+ reason: "route_changed",
+ sessionRunning: true,
+ diagnosticProfile: .production
+ )
+ )
+ }
+
+ func testProductionStillRestartsForEngineResetAndInterruption() {
+ for reason in ["engine_reset", "interruption"] {
+ XCTAssertFalse(
+ DuplexSystemChangePolicy.shouldHoldEngine(
+ reason: reason,
+ sessionRunning: true,
+ diagnosticProfile: .production
+ )
+ )
+ }
+ }
+
+ func testDiagnosticHoldProfilesCanObserveEverySystemChange() {
+ XCTAssertTrue(
+ DuplexSystemChangePolicy.shouldHoldEngine(
+ reason: "engine_reset",
+ sessionRunning: true,
+ diagnosticProfile: .voiceProcessingHold
+ )
+ )
+ XCTAssertFalse(
+ DuplexSystemChangePolicy.shouldHoldEngine(
+ reason: "route_changed",
+ sessionRunning: false,
+ diagnosticProfile: .voiceProcessingHold
+ )
+ )
+ }
+
+ func testPacketizerSplitsLargePcmBuffersIntoTwentyMillisecondPackets() {
+ let packetizer = ChroniclePcm16Packetizer()
+ let samples = Array(0..<1_600).map(Int16.init)
+ let packets = samples.withUnsafeBufferPointer {
+ packetizer.append(samples: $0.baseAddress!, count: $0.count)
+ }
+
+ XCTAssertEqual(packets.count, 5)
+ XCTAssertTrue(packets.allSatisfy { $0.count == 320 })
+ XCTAssertEqual(packets[0].first, 0)
+ XCTAssertEqual(packets[4].last, 1_599)
+ XCTAssertEqual(packetizer.pendingSampleCount, 0)
+ }
+
+ func testPacketizerCarriesPartialPcmAcrossTapCallbacks() {
+ let packetizer = ChroniclePcm16Packetizer()
+ let first = [Int16](repeating: 1, count: 100)
+ let second = [Int16](repeating: 2, count: 220)
+
+ let initialPackets = first.withUnsafeBufferPointer {
+ packetizer.append(samples: $0.baseAddress!, count: $0.count)
+ }
+ let completedPackets = second.withUnsafeBufferPointer {
+ packetizer.append(samples: $0.baseAddress!, count: $0.count)
+ }
+
+ XCTAssertTrue(initialPackets.isEmpty)
+ XCTAssertEqual(completedPackets.count, 1)
+ XCTAssertEqual(Array(completedPackets[0].prefix(100)), [Int16](repeating: 1, count: 100))
+ XCTAssertEqual(Array(completedPackets[0].suffix(220)), [Int16](repeating: 2, count: 220))
+ XCTAssertEqual(packetizer.pendingSampleCount, 0)
+ }
+
+ func testOpusEncoderProducesOnePacketForTwentyMillisecondsOfPcm() throws {
+ let encoder = try ChronicleOpusPacketEncoder()
+ let buffer = try XCTUnwrap(
+ AVAudioPCMBuffer(
+ pcmFormat: encoder.inputFormat,
+ frameCapacity: ChronicleOpusPacketEncoder.framesPerPacket
+ )
+ )
+ buffer.frameLength = ChronicleOpusPacketEncoder.framesPerPacket
+ let samples = try XCTUnwrap(buffer.int16ChannelData)[0]
+ for index in 0.. mocks[request] ?? originalRequire(request);
+ loaded._compile(compiled.outputText, sourcePath);
+ return loaded.exports;
+}
+
+const application = {
+ applicationId: 'com.chronicle.app',
+ applicationName: 'Chronicle',
+ nativeApplicationVersion: '1.15.0',
+ nativeBuildVersion: '79',
+};
+const constants = {
+ expoConfig: { version: 'wrong-config-version' },
+ executionEnvironment: 'standalone',
+};
+const updates = {
+ isEmbeddedLaunch: true,
+ updateId: 'update-123',
+ channel: 'testflight',
+ runtimeVersion: '1.15.0',
+ createdAt: new Date('2026-09-06T18:25:35Z'),
+};
+
+async function testLogHeader() {
+ let stored = '';
+ const loggerPath = path.join(__dirname, '../src/utils/logger.ts');
+ const logger = loadTypeScript(loggerPath, {
+ 'expo-application': application,
+ 'expo-constants': { __esModule: true, default: constants },
+ 'expo-updates': updates,
+ 'react-native': { Platform: { OS: 'ios', Version: '26.6.1' } },
+ 'expo-file-system/legacy': {
+ documentDirectory: 'memory://',
+ getInfoAsync: async () => ({ exists: stored.length > 0, isDirectory: false, size: stored.length }),
+ makeDirectoryAsync: async () => undefined,
+ readAsStringAsync: async () => stored,
+ writeAsStringAsync: async (_path, value) => { stored = value; },
+ deleteAsync: async () => { stored = ''; },
+ moveAsync: async () => undefined,
+ },
+ });
+
+ await logger.initLogger();
+ const text = await logger.readLog();
+ assert.match(text, /appVersion=1\.15\.0/);
+ assert.match(text, /nativeBuildVersion=79/);
+ assert.match(text, /applicationId=com\.chronicle\.app/);
+ assert.match(text, /applicationName=Chronicle/);
+ assert.match(text, /executionEnvironment=standalone/);
+ assert.match(text, /updateId=update-123/);
+ assert.match(text, /channel=testflight/);
+ assert.match(text, /runtimeVersion=1\.15\.0/);
+ assert.match(text, /sessionId=[a-z0-9]{8}/);
+ assert.doesNotMatch(text, /Version=unknown/);
+}
+
+async function testUploadHeaders() {
+ let request;
+ const clientPath = path.join(__dirname, '../src/services/clientDiagnostics.ts');
+ const client = loadTypeScript(clientPath, {
+ 'expo-application': application,
+ 'expo-constants': { __esModule: true, default: constants },
+ 'react-native': { Platform: { OS: 'ios', Version: '26.6.1' } },
+ './auth': {
+ deriveBaseUrl: () => 'https://chronicle.example',
+ fetchAuthed: async (url, init) => {
+ request = { url, init };
+ return {
+ ok: true,
+ json: async () => ({ app_version: '1.15.0', build_version: '79' }),
+ };
+ },
+ },
+ '../utils/storage': {
+ getLastConnectedDeviceId: async () => null,
+ getWebSocketUrl: async () => 'wss://chronicle.example/ws/audio',
+ },
+ });
+
+ await client.uploadClientDiagnostic('diagnostic body');
+ assert.equal(request.init.headers['X-Chronicle-App-Version'], '1.15.0');
+ assert.equal(request.init.headers['X-Chronicle-Build-Version'], '79');
+ assert.notEqual(request.init.headers['X-Chronicle-Build-Version'], 'unknown');
+}
+
+Promise.all([testLogHeader(), testUploadHeaders()])
+ .then(() => console.log('client diagnostic metadata tests passed'))
+ .catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+ });
diff --git a/app/scripts/test-durable-audio-spool.cjs b/app/scripts/test-durable-audio-spool.cjs
deleted file mode 100644
index 2e9c60455..000000000
--- a/app/scripts/test-durable-audio-spool.cjs
+++ /dev/null
@@ -1,96 +0,0 @@
-const assert = require('node:assert/strict');
-const fs = require('node:fs');
-const Module = require('node:module');
-const path = require('node:path');
-const ts = require('typescript');
-
-function loadTypeScript(sourcePath, mocks) {
- const source = fs.readFileSync(sourcePath, 'utf8');
- const compiled = ts.transpileModule(source, {
- compilerOptions: {
- esModuleInterop: true,
- module: ts.ModuleKind.CommonJS,
- target: ts.ScriptTarget.ES2020,
- strict: true,
- },
- fileName: sourcePath,
- });
- const loaded = new Module(sourcePath, module);
- loaded.filename = sourcePath;
- loaded.paths = Module._nodeModulePaths(path.dirname(sourcePath));
- const originalRequire = loaded.require.bind(loaded);
- loaded.require = (request) => mocks[request] ?? originalRequire(request);
- loaded._compile(compiled.outputText, sourcePath);
- return loaded.exports;
-}
-
-const values = new Map();
-const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
-const storage = {
- async getItem(key) {
- return values.get(key) ?? null;
- },
- async setItem(key, value) {
- // Make a lower ACK finish last. Concurrent read-modify-write logic regresses
- // the durable watermark to 3 even though sequence 10 was already accepted.
- await wait(value === '10' ? 5 : 20);
- values.set(key, value);
- },
- async removeItem(key) {
- values.delete(key);
- },
-};
-
-class MockDirectory {
- constructor() {
- this.exists = true;
- }
-
- list() {
- return [];
- }
-}
-
-class MockFile {
- constructor(_directory, name) {
- this.name = name;
- this.exists = false;
- }
-}
-
-const sourcePath = path.join(__dirname, '../src/services/durableAudioSpool.ts');
-const { DurableAudioSpool } = loadTypeScript(sourcePath, {
- '@react-native-async-storage/async-storage': storage,
- 'expo-file-system': {
- Directory: MockDirectory,
- File: MockFile,
- Paths: { document: '/documents' },
- },
-});
-
-(async () => {
- const spool = new DurableAudioSpool();
- spool.active = { file: { name: 'segment.spool' } };
- const packet = (sequence) => ({
- fileName: 'segment.spool',
- segmentId: 'segment',
- sequence,
- capturedAtMs: 1_770_000_000_000 + sequence,
- payload: new Uint8Array([sequence]),
- });
-
- await Promise.all([
- spool.acknowledge(packet(10)),
- spool.acknowledge(packet(3)),
- ]);
-
- assert.equal(
- values.get('chronicle.audioSpool.ack.segment.spool'),
- '10',
- 'out-of-order concurrent ACKs must never lower the durable watermark',
- );
- console.log('durable audio spool tests passed');
-})().catch((error) => {
- console.error(error);
- process.exitCode = 1;
-});
diff --git a/app/scripts/test-phone-audio-diagnostics.cjs b/app/scripts/test-phone-audio-diagnostics.cjs
new file mode 100644
index 000000000..6bfe86eb0
--- /dev/null
+++ b/app/scripts/test-phone-audio-diagnostics.cjs
@@ -0,0 +1,397 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const Module = require('node:module');
+const path = require('node:path');
+const ts = require('typescript');
+
+function loadTypeScript(sourcePath, mocks) {
+ const source = fs.readFileSync(sourcePath, 'utf8');
+ const compiled = ts.transpileModule(source, {
+ compilerOptions: {
+ esModuleInterop: true,
+ module: ts.ModuleKind.CommonJS,
+ target: ts.ScriptTarget.ES2020,
+ strict: true,
+ },
+ fileName: sourcePath,
+ });
+ const loaded = new Module(sourcePath, module);
+ loaded.filename = sourcePath;
+ loaded.paths = Module._nodeModulePaths(path.dirname(sourcePath));
+ const originalRequire = loaded.require.bind(loaded);
+ loaded.require = (request) => mocks[request] ?? originalRequire(request);
+ loaded._compile(compiled.outputText, sourcePath);
+ return loaded.exports;
+}
+
+const writes = [];
+const socketSourcePath = path.join(__dirname, '../src/protocol/audioV2Socket.ts');
+const serverControls = {
+ hello: { event: { case: 'hello', value: {} } },
+ captureStopped: { event: { case: 'captureStopped', value: {} } },
+ captureStarted: {
+ event: {
+ case: 'captureStarted',
+ value: {
+ binding: {
+ captureSessionId: { value: 'capture-1' },
+ voiceSessionId: { value: '' },
+ captureEpoch: 0,
+ },
+ },
+ },
+ },
+};
+const { AudioV2Socket, createClientEventIdValue } = loadTypeScript(socketSourcePath, {
+ '@bufbuild/protobuf': { create: (_schema, value) => value },
+ '@bufbuild/protobuf/wkt': {},
+ './audioV2': {
+ AudioCodec: { OPUS: 1 },
+ AudioSpecSchema: {},
+ CaptureBindingSchema: {},
+ CaptureMediaPacketSchema: {},
+ CaptureSourceIdSchema: {},
+ ClientHelloSchema: {},
+ ClientControlSchema: {},
+ DataPurpose: { NORMAL_CAPTURE: 1 },
+ DeliveryClass: { UNSPECIFIED: 0, LIVE: 1 },
+ EventIdSchema: {},
+ HeartbeatSchema: {},
+ MediaEnvelopeSchema: {},
+ PlaybackAcknowledgementSchema: {},
+ ProcessingProfile: { SOURCE_NATIVE: 2 },
+ ResponseIdSchema: {},
+ StartCaptureSchema: {},
+ StopCaptureSchema: {},
+ StopReason: { USER_REQUESTED: 1 },
+ VoiceReadySchema: {},
+ decodeMediaEnvelope: value => value,
+ decodeServerControl: value => serverControls[value],
+ encodeClientControl: value => value,
+ encodeMediaEnvelope: value => value,
+ timestampFromUnixMs: value => value,
+ },
+});
+const fallbackEventId = createClientEventIdValue(null);
+const nextFallbackEventId = createClientEventIdValue(null);
+assert.match(fallbackEventId, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
+assert.notEqual(fallbackEventId, nextFallbackEventId, 'fallback event IDs must remain unique');
+assert.equal(
+ createClientEventIdValue({ randomUUID: () => 'native-random-uuid' }),
+ 'native-random-uuid',
+ 'native randomUUID should be used when the runtime provides it',
+);
+
+global.WebSocket = { OPEN: 1 };
+
+class FakeWebSocket {
+ constructor() {
+ this.readyState = 0;
+ this.sent = [];
+ }
+
+ open() {
+ this.readyState = WebSocket.OPEN;
+ this.onopen();
+ }
+
+ receive(value) {
+ this.onmessage({ data: value });
+ }
+
+ send(value) {
+ this.sent.push(value);
+ }
+
+ close() {}
+}
+
+async function declaredUplinkDuration(frameDurationMs) {
+ const transport = new FakeWebSocket();
+ const socket = new AudioV2Socket({
+ url: 'wss://chronicle.invalid/ws/audio',
+ bearerToken: 'token',
+ sourceId: 'source',
+ displayName: 'source',
+ deviceKind: 4,
+ uplinkFrameDurationMs: frameDurationMs,
+ webSocketFactory: () => transport,
+ });
+ const connecting = socket.connect();
+ transport.open();
+ transport.receive('hello');
+ await connecting;
+ const starting = socket.beginCapture({
+ captureEpoch: 0,
+ processingProfile: 2,
+ deliveryClass: 1,
+ });
+ transport.receive('captureStarted');
+ await starting;
+ let stopped = false;
+ const stopping = socket.stopCapture().then(() => { stopped = true; });
+ await Promise.resolve();
+ assert.equal(stopped, false, 'stop must await the server acknowledgement');
+ assert.equal(transport.sent.at(-1).event.case, 'stopCapture');
+ transport.receive('captureStopped');
+ await stopping;
+ assert.equal(socket.activeBinding, null, 'acknowledged stop must release the capture');
+ socket.close();
+ return transport.sent.map(control => (
+ control.event.value.audioSpec ?? control.event.value.supportedUplink?.[0]
+ )).filter(Boolean).map(spec => spec.frameDuration.nanos);
+}
+
+const sourcePath = path.join(__dirname, '../src/services/phoneAudioDiagnostics.ts');
+const { PhoneAudioDiagnostics } = loadTypeScript(sourcePath, {
+ '@/utils/logger': {
+ logInfo: (tag, message) => writes.push({ level: 'info', tag, message }),
+ logWarn: (tag, message) => writes.push({ level: 'warn', tag, message }),
+ logError: (tag, message) => writes.push({ level: 'error', tag, message }),
+ },
+});
+
+const diagnostics = new PhoneAudioDiagnostics(() => 1_000);
+diagnostics.beginAttempt();
+diagnostics.listenerInstalled(1);
+diagnostics.engineStarted(1, {
+ mode: 'duplex_full',
+ input_route: 'built_in_mic',
+ output_route: 'speakerphone',
+ native_sample_rate: 48_000,
+});
+diagnostics.nativeStage({ captureEpoch: 1, stage: 'tap_received', monotonicTimestampMs: 100 });
+diagnostics.nativeStage({ captureEpoch: 1, stage: 'pcm_converted', monotonicTimestampMs: 101, frameCount: 320 });
+diagnostics.nativeStage({ captureEpoch: 1, stage: 'opus_encoded', monotonicTimestampMs: 102, frameCount: 320, byteCount: 42 });
+diagnostics.nativeFrame({ captureEpoch: 1, opusBytes: 42, audioLevel: 0.25 });
+diagnostics.nativeFrame({ captureEpoch: 1, opusBytes: 43, audioLevel: 0.5 });
+diagnostics.audioLevelActive(0.5);
+diagnostics.socketConnecting();
+diagnostics.socketStage('transport_open');
+diagnostics.socketStage('client_hello_sent');
+diagnostics.socketStage('transport_error', 'wss://chronicle/ws/audio?token=secret-value');
+diagnostics.socketOpen();
+diagnostics.captureStarted('capture-secret-id');
+diagnostics.frameSent(44);
+diagnostics.packetAccepted(0);
+diagnostics.timeout('meter_stalled');
+diagnostics.failure('connect', 'wss://chronicle/ws/audio?token=secret-value');
+
+assert.deepEqual(
+ writes.map(({ level, tag }) => [level, tag]),
+ [
+ ['info', 'PhoneAudio'],
+ ['info', 'PhoneAudio'],
+ ['info', 'PhoneAudio'],
+ ['info', 'PhoneAudio'],
+ ['info', 'PhoneAudio'],
+ ['info', 'PhoneAudio'],
+ ['info', 'PhoneAudio'],
+ ['info', 'PhoneAudio'],
+ ['info', 'PhoneAudio'],
+ ['info', 'PhoneAudio'],
+ ['info', 'PhoneAudio'],
+ ['warn', 'PhoneAudio'],
+ ['info', 'PhoneAudio'],
+ ['info', 'PhoneAudio'],
+ ['info', 'PhoneAudio'],
+ ['info', 'PhoneAudio'],
+ ['warn', 'PhoneAudio'],
+ ['error', 'PhoneAudio'],
+ ],
+ 'each lifecycle boundary must be exported once while repeated frames become counters',
+);
+const text = writes.map(({ message }) => message).join('\n');
+assert.match(text, /button_pressed attempt=1/);
+assert.match(text, /native_first_frame.*opus_bytes=42.*audio_level=0\.250/);
+assert.match(text, /native_tap_received.*capture_epoch=1/);
+assert.match(text, /native_pcm_converted.*frames=320/);
+assert.match(text, /native_opus_encoded.*bytes=42/);
+assert.match(text, /audio_level_active.*audio_level=0\.500/);
+assert.match(text, /websocket_transport_open/);
+assert.match(text, /websocket_client_hello_sent/);
+assert.match(text, /websocket_transport_error.*token=/);
+assert.match(text, /first_frame_sent.*opus_bytes=44/);
+assert.match(text, /first_packet_accepted.*sequence=0/);
+assert.match(
+ text,
+ /meter_stalled.*native_frames=2.*sent_frames=1.*acked_packets=1.*last_audio_level=0\.500/,
+);
+assert.doesNotMatch(text, /capture-secret-id/, 'server-issued identifiers must be abbreviated');
+assert.doesNotMatch(text, /secret-value/, 'credentials must be redacted from exported diagnostics');
+
+const integrationSources = {
+ recorder: fs.readFileSync(path.join(__dirname, '../src/hooks/usePhoneAudioRecorder.ts'), 'utf8'),
+ streamer: fs.readFileSync(path.join(__dirname, '../src/hooks/useAudioStreamer.ts'), 'utf8'),
+ orchestrator: fs.readFileSync(path.join(__dirname, '../src/hooks/useAudioStreamingOrchestrator.ts'), 'utf8'),
+ ios: fs.readFileSync(path.join(__dirname, '../modules/chronicle-duplex-audio/ios/ChronicleDuplexAudioModule.swift'), 'utf8'),
+ android: fs.readFileSync(path.join(__dirname, '../modules/chronicle-duplex-audio/android/src/main/java/com/chronicle/duplexaudio/ChronicleDuplexAudioModule.kt'), 'utf8'),
+};
+assert.match(integrationSources.recorder, /setAudioLevel\(/, 'native levels must drive the UI meter');
+assert.match(integrationSources.recorder, /native_frame_timeout/, 'a silent native engine must surface a diagnostic');
+assert.match(integrationSources.streamer, /packetAccepted\(sequence\)/, 'backend acknowledgements must be logged');
+assert.match(
+ integrationSources.streamer,
+ /onDiagnostic: event =>/,
+ 'production phone streaming must log each WebSocket handshake phase',
+);
+assert.doesNotMatch(
+ integrationSources.streamer,
+ /autoReconnectEnabled|NetInfo/,
+ 'audio transport must expose failure instead of running a second reconnect state machine',
+);
+assert.match(integrationSources.orchestrator, /beginAttempt\(\)/, 'the phone button must open a diagnostic attempt');
+assert.match(integrationSources.ios, /"audioLevel": audioLevel/, 'iOS must emit PCM audio levels');
+assert.match(integrationSources.ios, /"onCaptureDiagnostic"/, 'iOS must expose the native capture stages');
+assert.match(
+ integrationSources.ios,
+ /let inputFormat = input\.inputFormat\(forBus: 0\)/,
+ 'the iOS tap must use the hardware input format after voice processing is configured',
+);
+assert.doesNotMatch(
+ integrationSources.ios,
+ /let inputFormat = input\.outputFormat\(forBus: 0\)/,
+ 'the voice-processed output format can create a running iOS engine whose input tap stays silent',
+);
+assert.match(
+ integrationSources.ios,
+ /scheduleCaptureWatchdog\(\)/,
+ 'a running iOS engine must recover if its input tap never delivers a frame',
+);
+assert.match(
+ integrationSources.ios,
+ /DuplexSystemChangePolicy\.shouldHoldEngine/,
+ 'iOS must survive the initial route-settlement notification that precedes mic frames',
+);
+assert.match(
+ integrationSources.ios,
+ /ChroniclePcm16Packetizer/,
+ 'iOS must split hardware-sized PCM buffers into fixed 20 ms Opus packets',
+);
+assert.match(integrationSources.android, /"audioLevel" to DuplexAudioPolicy\.audioLevel/, 'Android must emit PCM audio levels');
+
+const orchestratorPath = path.join(__dirname, '../src/hooks/useAudioStreamingOrchestrator.ts');
+const noDiagnostics = new Proxy({}, { get: () => () => {} });
+const { useAudioStreamingOrchestrator } = loadTypeScript(orchestratorPath, {
+ react: {
+ useCallback: (callback) => callback,
+ useState: (value) => [value, () => {}],
+ },
+ 'react-native': { Alert: { alert: () => {} } },
+ 'friend-lite-react-native': { BleAudioCodec: { OPUS: 'opus' } },
+ '../services/phoneAudioDiagnostics': { phoneAudioDiagnostics: noDiagnostics },
+});
+
+(async () => {
+ assert.deepEqual(await declaredUplinkDuration(20), [20_000_000, 20_000_000]);
+ assert.deepEqual(await declaredUplinkDuration(60), [60_000_000, 60_000_000]);
+ const queuedFrames = [];
+ const starts = [];
+ const frame = { captureEpoch: 1, capturedAtMs: 1_780_000_000_000, opus: new Uint8Array([1, 2, 3]) };
+ const orchestrator = useAudioStreamingOrchestrator({
+ omiConnection: { isConnected: () => false },
+ deviceConnection: { connectedDeviceId: null },
+ audioStreamer: {
+ isStreaming: false,
+ startStreaming: async (url, source) => starts.push({ url, source }),
+ stopStreaming: async () => {},
+ sendFrame: (source, value) => queuedFrames.push({ source, value }),
+ },
+ phoneAudioRecorder: {
+ isRecording: false,
+ startRecording: async (onData) => {
+ await onData(frame);
+ return {
+ captureEpoch: 1,
+ capabilities: {
+ mode: 'duplex_full',
+ input_route: 'built_in_mic',
+ output_route: 'speakerphone',
+ native_sample_rate: 48_000,
+ aec: { requested: true, available: true, enabled: true },
+ noise_suppression: { requested: true, available: true, enabled: true },
+ },
+ stopCapture: async () => {},
+ };
+ },
+ stopRecording: async () => {},
+ },
+ originalStartAudioListener: async () => {},
+ originalStopAudioListener: async () => {},
+ settings: {
+ webSocketUrl: 'https://chronicle.invalid',
+ jwtToken: 'token',
+ isAuthenticated: true,
+ },
+ });
+
+ await orchestrator.handleTogglePhoneAudio();
+ assert.deepEqual(
+ queuedFrames,
+ [{ source: 'phone', value: frame }],
+ 'phone frames must enter the one source-tagged queue',
+ );
+ assert.equal(starts[0].url, 'wss://chronicle.invalid/ws/audio');
+ assert.equal(starts[0].source.kind, 'phone');
+ assert.equal(new URL(starts[0].url).search, '', 'audio credentials must never enter the URL');
+
+ let listenerStarts = 0;
+ let wearableSocketStarts = 0;
+ const nonOpus = useAudioStreamingOrchestrator({
+ omiConnection: {
+ isConnected: () => true,
+ getAudioCodec: async () => 'pcm8',
+ },
+ deviceConnection: { connectedDeviceId: 'neo-1' },
+ audioStreamer: {
+ isStreaming: false,
+ startStreaming: async () => { wearableSocketStarts += 1; },
+ stopStreaming: async () => {},
+ sendFrame: () => {},
+ },
+ phoneAudioRecorder: {
+ isRecording: false,
+ startRecording: async () => { throw new Error('not used'); },
+ stopRecording: async () => {},
+ },
+ originalStartAudioListener: async () => { listenerStarts += 1; },
+ originalStopAudioListener: async () => {},
+ settings: { webSocketUrl: 'https://chronicle.invalid' },
+ });
+ await nonOpus.handleStartAudioListeningAndStreaming();
+ assert.equal(listenerStarts, 0, 'a non-Opus wearable must fail before capture starts');
+ assert.equal(wearableSocketStarts, 0, 'a non-Opus wearable must not open Audio V2');
+
+ const wearableFrames = [];
+ const wearableStarts = [];
+ const opusWearable = useAudioStreamingOrchestrator({
+ omiConnection: {
+ isConnected: () => true,
+ getAudioCodec: async () => 'opus',
+ },
+ deviceConnection: { connectedDeviceId: 'neo-1' },
+ audioStreamer: {
+ isStreaming: false,
+ startStreaming: async (url, source) => wearableStarts.push({ url, source }),
+ stopStreaming: async () => {},
+ sendFrame: (source, value) => wearableFrames.push({ source, value }),
+ },
+ phoneAudioRecorder: {
+ isRecording: false,
+ startRecording: async () => { throw new Error('not used'); },
+ stopRecording: async () => {},
+ },
+ originalStartAudioListener: async onData => onData(new Uint8Array([4, 5, 6])),
+ originalStopAudioListener: async () => {},
+ settings: { webSocketUrl: 'https://chronicle.invalid' },
+ });
+ await opusWearable.handleStartAudioListeningAndStreaming();
+ assert.equal(wearableStarts[0].source.kind, 'wearable');
+ assert.equal(wearableStarts[0].source.sourceId, 'neo-1');
+ assert.equal(wearableFrames[0].source, 'wearable');
+ assert.equal(wearableFrames[0].value.frameDurationMs, 60);
+ console.log('phone audio diagnostics tests passed');
+})().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/app/scripts/test-phone-audio-self-test.cjs b/app/scripts/test-phone-audio-self-test.cjs
new file mode 100644
index 000000000..1332851ab
--- /dev/null
+++ b/app/scripts/test-phone-audio-self-test.cjs
@@ -0,0 +1,221 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const Module = require('node:module');
+const path = require('node:path');
+const ts = require('typescript');
+
+function loadTypeScript(sourcePath, mocks) {
+ const source = fs.readFileSync(sourcePath, 'utf8');
+ const compiled = ts.transpileModule(source, {
+ compilerOptions: {
+ esModuleInterop: true,
+ module: ts.ModuleKind.CommonJS,
+ target: ts.ScriptTarget.ES2020,
+ strict: true,
+ },
+ fileName: sourcePath,
+ });
+ const loaded = new Module(sourcePath, module);
+ loaded.filename = sourcePath;
+ loaded.paths = Module._nodeModulePaths(path.dirname(sourcePath));
+ const originalRequire = loaded.require.bind(loaded);
+ loaded.require = request => mocks[request] ?? originalRequire(request);
+ loaded._compile(compiled.outputText, sourcePath);
+ return loaded.exports;
+}
+
+const writes = [];
+const sourcePath = path.join(__dirname, '../src/services/phoneAudioSelfTest.ts');
+const { runPhoneAudioDiagnosticSuite } = loadTypeScript(sourcePath, {
+ '../../modules/chronicle-duplex-audio': {},
+ '../protocol/audioV2': {
+ DataPurpose: { ANNOTATION: 2 },
+ DeliveryClass: { RECOVERED: 2 },
+ DeviceKind: { IOS_PHONE: 1 },
+ ProcessingProfile: { SOURCE_NATIVE: 2 },
+ },
+ '../protocol/audioV2Socket': { AudioV2Socket: class {} },
+ '@/utils/logger': {
+ logInfo: (tag, message) => writes.push({ level: 'info', tag, message }),
+ logWarn: (tag, message) => writes.push({ level: 'warn', tag, message }),
+ logError: (tag, message) => writes.push({ level: 'error', tag, message }),
+ },
+ 'react-native-base64': {
+ decode: value => Buffer.from(value, 'base64').toString('binary'),
+ },
+ 'react-native': { Platform: { OS: 'ios' } },
+});
+
+const profiles = [];
+let frameListener = () => undefined;
+let nativeListener = () => undefined;
+let routeListener = () => undefined;
+let stopCalls = 0;
+let sentPackets = 0;
+let closed = false;
+const progress = [];
+
+const dependencies = {
+ now: (() => {
+ let value = 1_000_000;
+ return () => value += 25;
+ })(),
+ sleep: async () => undefined,
+ addOpusFrameListener(listener) {
+ frameListener = listener;
+ return { remove: () => { frameListener = () => undefined; } };
+ },
+ addCaptureDiagnosticListener(listener) {
+ nativeListener = listener;
+ return { remove: () => { nativeListener = () => undefined; } };
+ },
+ addRouteChangeListener(listener) {
+ routeListener = listener;
+ return { remove: () => { routeListener = () => undefined; } };
+ },
+ async startVoiceSession(options) {
+ profiles.push(options.diagnosticProfile);
+ nativeListener({
+ captureEpoch: options.captureEpoch,
+ stage: 'system_change',
+ monotonicTimestampMs: 123,
+ detail: 'engine_reset ignored=true',
+ });
+ routeListener({
+ captureEpoch: options.captureEpoch,
+ reason: 'engine_reset',
+ capabilities: {
+ mode: 'duplex_full',
+ input_route: 'built_in_mic',
+ output_route: 'speakerphone',
+ native_sample_rate: 48_000,
+ aec: { requested: true, available: true, enabled: true },
+ noise_suppression: { requested: true, available: true, enabled: true },
+ fallback_reason: null,
+ },
+ });
+ if (options.diagnosticProfile === 'voice_processing_hold') {
+ for (let index = 0; index < 25; index += 1) {
+ frameListener({
+ captureEpoch: options.captureEpoch,
+ capturedAtMs: 1_700_000_000_000 + (index * 20),
+ monotonicTimestampMs: 1000 + (index * 20),
+ sampleRate: 16_000,
+ channels: 1,
+ frameDurationMs: 20,
+ audioLevel: 0.25,
+ opusBase64: Buffer.from([0xf8, 0xff, 0xfe]).toString('base64'),
+ });
+ }
+ }
+ return {
+ mode: 'duplex_full',
+ input_route: 'built_in_mic',
+ output_route: 'speakerphone',
+ native_sample_rate: 48_000,
+ aec: { requested: true, available: true, enabled: true },
+ noise_suppression: { requested: true, available: true, enabled: true },
+ fallback_reason: null,
+ };
+ },
+ async getVoiceSessionDiagnostics() {
+ const profile = profiles.at(-1);
+ const frames = profile === 'voice_processing_hold' ? 25 : 0;
+ return {
+ diagnosticProfile: profile,
+ captureEpoch: profiles.length,
+ engineRunning: frames > 0,
+ sessionRunning: frames > 0,
+ tapInstalled: true,
+ tapFrameCount: frames,
+ convertedFrameCount: frames * 320,
+ opusPacketCount: frames,
+ opusByteCount: frames * 3,
+ peakAudioLevel: frames ? 0.25 : 0,
+ systemChangeCount: 1,
+ lastSystemChangeReason: 'engine_reset',
+ watchdogEvaluationCount: 1,
+ voiceProcessingEnabled: profile !== 'plain_capture_hold',
+ audioSessionCategory: 'AVAudioSessionCategoryPlayAndRecord',
+ audioSessionMode: 'AVAudioSessionModeVoiceChat',
+ audioSessionSampleRate: 48_000,
+ audioSessionIOBufferDurationMs: 20,
+ inputFormat: '48000Hz/1ch/float32/noninterleaved',
+ outputFormat: '48000Hz/2ch/float32/noninterleaved',
+ };
+ },
+ async stopVoiceSession() {
+ stopCalls += 1;
+ return { restorationSucceeded: true, failureCode: null };
+ },
+ createSocket(options) {
+ return {
+ async connect() {
+ options.onDiagnostic?.({ stage: 'transport_open' });
+ options.onDiagnostic?.({ stage: 'client_hello_sent' });
+ options.onDiagnostic?.({ stage: 'server_hello_received' });
+ },
+ async beginCapture() {
+ return {
+ captureSessionId: { value: 'capture-diagnostic-full-id' },
+ captureEpoch: 0n,
+ };
+ },
+ sendPacket(packet) {
+ sentPackets += 1;
+ options.onPacketAccepted?.(packet.sequence);
+ },
+ async stopCapture() {},
+ close() { closed = true; },
+ };
+ },
+};
+
+(async () => {
+ const result = await runPhoneAudioDiagnosticSuite({
+ backendUrl: 'wss://chronicle.example/ws/audio?token=must-not-log',
+ jwtToken: 'jwt-must-not-log',
+ onProgress: value => progress.push(value),
+ }, dependencies);
+
+ assert.deepEqual(profiles, [
+ 'production',
+ 'voice_processing_hold',
+ 'plain_capture_hold',
+ 'system_tap_format_hold',
+ ], 'the bounded matrix must distinguish reset handling, VoiceProcessingIO, and tap format');
+ assert.equal(stopCalls, 4, 'every native probe must restore the audio session');
+ assert.equal(sentPackets, 25, 'the backend probe must send a bounded half-second payload');
+ assert.equal(closed, true, 'the backend diagnostic socket must always close');
+ assert.equal(result.nativeProbes.filter(probe => probe.status === 'pass').length, 1);
+ assert.equal(result.networkProbe.status, 'pass');
+ assert.equal(result.networkProbe.captureSessionId, 'capture-diagnostic-full-id');
+ assert.equal(result.status, 'pass');
+ assert.ok(progress.some(value => value.phase === 'native'));
+ assert.ok(progress.some(value => value.phase === 'network'));
+ assert.equal(progress.at(-1).phase, 'complete');
+
+ const logText = writes.map(({ tag, message }) => `${tag} ${message}`).join('\n');
+ assert.match(logText, /profile=production status=fail/);
+ assert.match(logText, /profile=voice_processing_hold status=pass/);
+ assert.match(logText, /capture_session_id=capture-diagnostic-full-id/);
+ assert.match(logText, /payload_source=native_mic packets_sent=25 packets_acked=25/);
+ assert.match(logText, /event=suite_complete status=pass/);
+ assert.doesNotMatch(logText, /jwt-must-not-log|must-not-log/, 'diagnostics must never log credentials');
+ assert.doesNotMatch(logText, /opusBase64/, 'diagnostics must never log captured audio payloads');
+
+ const settingsSource = fs.readFileSync(path.join(__dirname, '../app/settings.tsx'), 'utf8');
+ const sectionSource = fs.readFileSync(
+ path.join(__dirname, '../src/components/PhoneAudioDiagnosticsSection.tsx'),
+ 'utf8',
+ );
+ assert.match(settingsSource, / {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/app/scripts/test-phone-audio-streaming.cjs b/app/scripts/test-phone-audio-streaming.cjs
new file mode 100644
index 000000000..23b911adb
--- /dev/null
+++ b/app/scripts/test-phone-audio-streaming.cjs
@@ -0,0 +1,223 @@
+const assert = require('node:assert/strict');
+const Module = require('node:module');
+const path = require('node:path');
+const ts = require('typescript');
+const fs = require('node:fs');
+
+function loadTypeScript(sourcePath, mocks) {
+ const source = fs.readFileSync(sourcePath, 'utf8');
+ const compiled = ts.transpileModule(source, {
+ compilerOptions: {
+ esModuleInterop: true,
+ module: ts.ModuleKind.CommonJS,
+ target: ts.ScriptTarget.ES2020,
+ strict: true,
+ },
+ fileName: sourcePath,
+ });
+ const loaded = new Module(sourcePath, module);
+ loaded.filename = sourcePath;
+ loaded.paths = Module._nodeModulePaths(path.dirname(sourcePath));
+ const originalRequire = loaded.require.bind(loaded);
+ loaded.require = (request) => mocks[request] ?? originalRequire(request);
+ loaded._compile(compiled.outputText, sourcePath);
+ return loaded.exports;
+}
+
+const ProcessingProfile = {
+ SOURCE_NATIVE: 1,
+ DUPLEX_AEC: 2,
+ DUPLEX_ISOLATED: 3,
+ HALF_DUPLEX: 4,
+};
+const DeliveryClass = { LIVE: 1, RECOVERED: 2 };
+const beginCaptureCalls = [];
+const sentPackets = [];
+const socketBearerTokens = [];
+const socketFrameDurations = [];
+const diagnostics = [];
+let backendStops = 0;
+let socketCloses = 0;
+let duringBackendStop = () => {};
+
+class MockAudioV2Socket {
+ constructor(options) {
+ this.options = options;
+ this.activeBinding = null;
+ socketBearerTokens.push(options.bearerToken);
+ socketFrameDurations.push(options.uplinkFrameDurationMs);
+ }
+
+ async connect() {}
+
+ async beginCapture(options) {
+ beginCaptureCalls.push(options);
+ this.activeBinding = {
+ captureSessionId: { value: `capture-${beginCaptureCalls.length}` },
+ voiceSessionId: { value: options.deliveryClass === DeliveryClass.LIVE ? 'voice-live' : '' },
+ captureEpoch: options.captureEpoch,
+ };
+ return this.activeBinding;
+ }
+
+ sendPacket(packet) {
+ sentPackets.push(packet);
+ this.options.onPacketAccepted(packet.sequence);
+ }
+
+ async stopCapture() {
+ backendStops += 1;
+ await duringBackendStop();
+ this.activeBinding = null;
+ }
+
+ voiceReady() {}
+ heartbeat() {}
+ acknowledgePlayback() {}
+ close() {
+ socketCloses += 1;
+ this.activeBinding = null;
+ }
+}
+
+const noDiagnostics = new Proxy({
+ frameSent: bytes => diagnostics.push(['sent', bytes]),
+ packetAccepted: sequence => diagnostics.push(['accepted', sequence]),
+}, { get: (target, property) => target[property] ?? (() => {}) });
+
+const sourcePath = path.join(__dirname, '../src/hooks/useAudioStreamer.ts');
+const { useAudioStreamer } = loadTypeScript(sourcePath, {
+ '@bufbuild/protobuf': { create: (_schema, value) => value },
+ react: {
+ useCallback: callback => callback,
+ useRef: value => ({ current: value }),
+ useState: value => [value, () => {}],
+ },
+ 'react-native': { Platform: { OS: 'ios' } },
+ 'react-native-base64': { encode: value => value },
+ '../../modules/chronicle-duplex-audio': {
+ addPlaybackStateListener: () => ({ remove() {} }),
+ cancelResponse: async () => {},
+ scheduleResponse: async () => {},
+ },
+ '../protocol/audioV2': {
+ CaptureCapabilitiesSchema: {},
+ DataPurpose: { NORMAL_CAPTURE: 1 },
+ DeliveryClass,
+ DeviceKind: { IOS_PHONE: 1, ANDROID_PHONE: 2, OMI: 3 },
+ DuplexMode: { FULL: 1, ISOLATED: 2, HALF: 3 },
+ EffectStatusSchema: {},
+ InputRoute: { BUILT_IN_MIC: 1, BLUETOOTH_HFP: 2, WIRED_MIC: 3, USB: 4, REMOTE: 5 },
+ OutputRoute: { SPEAKERPHONE: 1, EARPIECE: 2, HEADPHONES: 3, BLUETOOTH_HFP: 4, USB: 5, REMOTE: 6 },
+ PlaybackState: { STARTED: 1, DONE: 2, CANCELLED: 3, FAILED: 4 },
+ ProcessingProfile,
+ },
+ '../protocol/audioV2Socket': { AudioV2Socket: MockAudioV2Socket },
+ '../services/auth': { getValidToken: async () => 'fresh-token' },
+ '../services/phoneAudioDiagnostics': { phoneAudioDiagnostics: noDiagnostics },
+});
+
+(async () => {
+ let nativeStops = 0;
+ const phoneVoice = {
+ captureEpoch: 7,
+ capabilities: {
+ mode: 'duplex_full',
+ input_route: 'built_in_mic',
+ output_route: 'speakerphone',
+ native_sample_rate: 48_000,
+ aec: { requested: true, available: true, enabled: true },
+ noise_suppression: { requested: true, available: true, enabled: true },
+ },
+ stopCapture: async () => { nativeStops += 1; },
+ };
+ const streamer = useAudioStreamer();
+
+ await streamer.startStreaming(
+ 'wss://chronicle.invalid/ws/audio',
+ { kind: 'phone', ...phoneVoice },
+ );
+
+ assert.deepEqual(socketBearerTokens, ['fresh-token'], 'audio must use the managed token source');
+ assert.deepEqual(socketFrameDurations, [20], 'phone capture must declare 20 ms Opus');
+ assert.equal(beginCaptureCalls.length, 1, 'one button press must create exactly one backend capture');
+ assert.equal(beginCaptureCalls[0].deliveryClass, DeliveryClass.LIVE);
+ assert.equal(beginCaptureCalls[0].captureEpoch, 7);
+ assert.equal(beginCaptureCalls[0].processingProfile, ProcessingProfile.DUPLEX_AEC);
+ assert.equal(beginCaptureCalls[0].recoveryBatchId, undefined, 'the clean path has no recovery capture');
+
+ const now = performance.now();
+ streamer.sendFrame('phone', {
+ captureEpoch: 6,
+ capturedAtMs: 1_780_000_000_000,
+ monotonicTimestampMs: now,
+ frameDurationMs: 20,
+ opus: new Uint8Array([9]),
+ });
+ streamer.sendFrame('phone', {
+ captureEpoch: 7,
+ capturedAtMs: 1_780_000_000_020,
+ monotonicTimestampMs: now + 20,
+ frameDurationMs: 20,
+ opus: new Uint8Array([1, 2, 3]),
+ });
+ assert.equal(sentPackets.length, 1, 'only the active native epoch may send');
+ assert.equal(sentPackets[0].sequence, 0);
+ assert.equal(sentPackets[0].capturedAtMs, 1_780_000_000_020);
+ assert.deepEqual(Array.from(sentPackets[0].opus), [1, 2, 3]);
+ assert.deepEqual(diagnostics, [['sent', 3], ['accepted', 0]]);
+
+ duringBackendStop = async () => {
+ const before = sentPackets.length;
+ streamer.sendFrame('phone', {
+ captureEpoch: 7,
+ capturedAtMs: 1_780_000_000_040,
+ monotonicTimestampMs: now + 40,
+ opus: new Uint8Array([4]),
+ });
+ assert.equal(sentPackets.length, before, 'queued native callbacks must not send after stop');
+ assert.equal(nativeStops, 1, 'microphone must stop before waiting for the backend');
+ };
+ await streamer.stopStreaming();
+ duringBackendStop = () => {};
+ assert.equal(backendStops, 1, 'the capture has one stop owner');
+ assert.equal(nativeStops, 1, 'stopping the stream also stops the native phone session');
+ assert.equal(socketCloses, 1);
+
+ const wearableStreamer = useAudioStreamer();
+ await wearableStreamer.startStreaming(
+ 'wss://chronicle.invalid/ws/audio',
+ { kind: 'wearable', sourceId: 'neo-1' },
+ );
+ assert.deepEqual(socketFrameDurations, [20, 60], 'wearable capture must declare 60 ms Opus');
+ assert.equal(beginCaptureCalls.length, 2, 'wearable also uses one live capture');
+ assert.equal(beginCaptureCalls[1].deliveryClass, DeliveryClass.LIVE);
+ await wearableStreamer.stopStreaming();
+
+ for (const failureAt of ['native', 'backend']) {
+ const failingStreamer = useAudioStreamer();
+ const expected = new Error(`${failureAt} stop failed`);
+ await failingStreamer.startStreaming('wss://chronicle.invalid/ws/audio', {
+ kind: 'phone',
+ ...phoneVoice,
+ stopCapture: async () => {
+ if (failureAt === 'native') throw expected;
+ },
+ });
+ const closesBefore = socketCloses;
+ duringBackendStop = async () => { throw expected; };
+ await assert.rejects(failingStreamer.stopStreaming(), error => error === expected);
+ assert.equal(socketCloses, closesBefore + 1, 'stop failure must still close the transport');
+ const packetsBefore = sentPackets.length;
+ failingStreamer.sendFrame('phone', {
+ captureEpoch: 7, capturedAtMs: 1_780_000_000_060,
+ monotonicTimestampMs: now + 60, opus: new Uint8Array([5]),
+ });
+ assert.equal(sentPackets.length, packetsBefore, 'failed stop must leave capture inactive');
+ }
+
+ console.log('phone audio streaming tests passed');
+})().catch(error => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/app/scripts/test-push-notifications.cjs b/app/scripts/test-push-notifications.cjs
index 2b2520b39..224eb7edb 100644
--- a/app/scripts/test-push-notifications.cjs
+++ b/app/scripts/test-push-notifications.cjs
@@ -31,13 +31,19 @@ const requests = [];
const opened = [];
const alerts = [];
let pushTokenListener = null;
+let nativeTokenFetches = 0;
const notifications = {
PermissionStatus: { DENIED: 'denied' },
AndroidImportance: { HIGH: 4, DEFAULT: 3 },
getPermissionsAsync: async () => permission,
requestPermissionsAsync: async () => permission,
- getExpoPushTokenAsync: async ({ projectId }) => {
+ getExpoPushTokenAsync: async ({ projectId, devicePushToken }) => {
assert.equal(projectId, 'project-one');
+ // Expo requests a native token if none is supplied; iOS emits the listener
+ // again when that request completes. Bound the buggy loop in this fixture.
+ if (!devicePushToken && pushTokenListener && ++nativeTokenFetches < 5) {
+ pushTokenListener({ type: 'ios', data: 'rotated' });
+ }
return { data: 'ExpoPushToken[abcdefghijklmnopqrstuvwxyz]' };
},
setNotificationHandler() {},
@@ -100,8 +106,9 @@ const push = loadTypeScript(sourcePath, {
await push.refreshPushRegistration('wss://chronicle/ws/audio');
assert.equal(requests.length, 2, 'authenticated launch refreshes the registration');
const stopTokenListener = push.listenForPushTokenChanges('wss://chronicle/ws/audio');
- pushTokenListener({ type: 'expo', data: 'rotated' });
+ pushTokenListener({ type: 'ios', data: 'rotated' });
await new Promise(resolve => setImmediate(resolve));
+ assert.equal(nativeTokenFetches, 0, 'rotation must use the supplied native token without requesting another');
assert.equal(requests.length, 3, 'native token changes refresh the Expo registration');
stopTokenListener();
diff --git a/app/src/components/PhoneAudioButton.tsx b/app/src/components/PhoneAudioButton.tsx
index fb92565a8..da4127689 100644
--- a/app/src/components/PhoneAudioButton.tsx
+++ b/app/src/components/PhoneAudioButton.tsx
@@ -89,7 +89,7 @@ const PhoneAudioButton: React.FC = ({
)}
- {error && !isRecording && (
+ {error && (
{error}
)}
diff --git a/app/src/components/PhoneAudioDiagnosticsSection.tsx b/app/src/components/PhoneAudioDiagnosticsSection.tsx
new file mode 100644
index 000000000..b8d2cfbe3
--- /dev/null
+++ b/app/src/components/PhoneAudioDiagnosticsSection.tsx
@@ -0,0 +1,135 @@
+import { useRouter } from 'expo-router';
+import React, { useState } from 'react';
+import { Alert, Platform, StyleSheet, View } from 'react-native';
+
+import { Body, Button, ButtonRow, Caption, Card, CardWell, Mono } from '@/components/ui';
+import {
+ runPhoneAudioDiagnosticSuite,
+ type PhoneAudioDiagnosticProgress,
+ type PhoneAudioDiagnosticRunResult,
+} from '@/services/phoneAudioSelfTest';
+import { useTheme, type Theme } from '@/theme';
+
+interface PhoneAudioDiagnosticsSectionProps {
+ backendUrl: string;
+ jwtToken: string | null;
+}
+
+function progressLabel(progress: PhoneAudioDiagnosticProgress | null): string {
+ if (!progress) return 'Ready';
+ if (progress.phase === 'native') {
+ return `${progress.label} (${progress.current}/${progress.total})`;
+ }
+ return progress.label;
+}
+
+export default function PhoneAudioDiagnosticsSection({
+ backendUrl,
+ jwtToken,
+}: PhoneAudioDiagnosticsSectionProps) {
+ const router = useRouter();
+ const t = useTheme();
+ const s = createStyles(t);
+ const [running, setRunning] = useState(false);
+ const [progress, setProgress] = useState(null);
+ const [result, setResult] = useState(null);
+
+ const run = async () => {
+ setRunning(true);
+ setResult(null);
+ try {
+ const next = await runPhoneAudioDiagnosticSuite({
+ backendUrl,
+ jwtToken,
+ onProgress: setProgress,
+ });
+ setResult(next);
+ const nativePassed = next.nativeProbes.filter(probe => probe.status === 'pass').length;
+ Alert.alert(
+ next.status === 'pass' ? 'Audio checks complete' : 'Audio issue captured',
+ `Native profiles: ${nativePassed}/${next.nativeProbes.length}. Backend: ${next.networkProbe.status}. The full trace was appended to Device Log.`,
+ );
+ } catch (cause) {
+ Alert.alert('Audio check failed', String(cause));
+ } finally {
+ setRunning(false);
+ }
+ };
+
+ if (Platform.OS !== 'ios') {
+ return (
+
+ The exhaustive native audio matrix is currently available in the iOS TestFlight app.
+
+ );
+ }
+
+ const nativePassed = result?.nativeProbes.filter(probe => probe.status === 'pass').length ?? 0;
+ return (
+
+
+ Runs four bounded microphone configurations, records engine/tap/PCM/Opus counters,
+ then performs an authenticated Audio V2 capture with 25 packet acknowledgements.
+
+
+ Takes about 12 seconds. Stop any active phone stream first. If a microphone probe
+ succeeds, up to 0.5 seconds of its audio is sent as a diagnostic annotation; otherwise
+ the backend probe uses synthetic silence. Every result is appended to Device Log.
+
+
+
+ {running ? progressLabel(progress) : result ? `Run ${result.runId}` : 'Ready to run'}
+ {result && (
+
+ Overall: {result.status}
+ Native: {nativePassed}/{result.nativeProbes.length} profiles
+ Backend: {result.networkProbe.status}
+ Packets: {result.networkProbe.packetsAcked}/{result.networkProbe.packetsSent} acked
+ {result.networkProbe.captureSessionId && (
+ Capture: {result.networkProbe.captureSessionId}
+ )}
+
+ )}
+
+
+
+ {running ? 'Running audio checks…' : 'Run full audio check'}
+
+
+ router.push('/diagnostics')}
+ >
+ Open Device Log
+
+
+
+ );
+}
+
+const createStyles = (t: Theme) => StyleSheet.create({
+ note: {
+ marginTop: t.space[2],
+ marginBottom: t.space[3],
+ },
+ statusWell: {
+ marginBottom: t.space[3],
+ },
+ resultRows: {
+ gap: t.space[1],
+ marginTop: t.space[2],
+ },
+ logActions: {
+ marginTop: t.space[2],
+ },
+});
diff --git a/app/src/hooks/useAppSettings.ts b/app/src/hooks/useAppSettings.ts
index aed7379d2..8e6c311b5 100644
--- a/app/src/hooks/useAppSettings.ts
+++ b/app/src/hooks/useAppSettings.ts
@@ -11,6 +11,7 @@ import {
} from '../utils/storage';
import { recoverBackendUrl } from '../services/serviceManager';
import { httpUrlToWebSocketUrl } from '../utils/urlConversion';
+import { onTokenRefreshed } from '../services/auth';
const DEFAULT_WS_URL = 'ws://localhost:8000/ws/audio';
@@ -35,6 +36,8 @@ export const useAppSettings = (): AppSettings => {
const [jwtToken, setJwtToken] = useState(null);
const [autoReconnectEnabled, setAutoReconnectEnabled] = useState(true);
+ useEffect(() => onTokenRefreshed(setJwtToken), []);
+
useEffect(() => {
const loadSettings = async () => {
const storedWsUrl = await getWebSocketUrl();
diff --git a/app/src/hooks/useAudioStreamer.ts b/app/src/hooks/useAudioStreamer.ts
index 2299c0b1b..0f136f766 100644
--- a/app/src/hooks/useAudioStreamer.ts
+++ b/app/src/hooks/useAudioStreamer.ts
@@ -1,13 +1,11 @@
import { create } from '@bufbuild/protobuf';
-import NetInfo from '@react-native-community/netinfo';
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useRef, useState } from 'react';
import { Platform } from 'react-native';
// @ts-ignore - no type declarations available
import base64 from 'react-native-base64';
import {
addPlaybackStateListener,
- addRouteChangeListener,
cancelResponse,
scheduleResponse,
} from '../../modules/chronicle-duplex-audio';
@@ -27,57 +25,43 @@ import {
import { AudioV2Socket } from '../protocol/audioV2Socket';
import type { CapturedOpusFrame } from '../protocol/capturedOpusFrame';
import type { VoiceCapabilities } from '../protocol/audioCapabilities';
-import { refreshToken } from '../services/auth';
-import { durableAudioSpool, type SpoolPacket } from '../services/durableAudioSpool';
+import type { PhoneCaptureSession } from './usePhoneAudioRecorder';
+import { getValidToken } from '../services/auth';
+import { phoneAudioDiagnostics } from '../services/phoneAudioDiagnostics';
-interface UseAudioStreamerOptions {
- onTokenRefreshed?: (token: string) => void;
- autoReconnectEnabled?: boolean;
-}
-
-export interface StreamStartConfig {
- phoneVoice?: {
- captureEpoch: number;
- capabilities: VoiceCapabilities;
- restartCapture: () => Promise>;
- stopCapture: () => Promise;
- };
-}
+export type AudioStreamSource =
+ | {
+ kind: 'wearable';
+ sourceId: string;
+ }
+ | ({ kind: 'phone' } & PhoneCaptureSession);
interface UseAudioStreamer {
isStreaming: boolean;
isConnecting: boolean;
error: string | null;
phonePlaybackState: 'started' | 'done' | 'cancelled' | 'failed' | null;
- startStreaming: (url: string, config?: StreamStartConfig) => Promise;
- getWebSocketReadyState: () => number | undefined;
+ startStreaming: (url: string, source: AudioStreamSource) => Promise;
stopStreaming: () => Promise;
- sendDurableAudio: (audioBytes: Uint8Array) => void;
- sendInteractiveFrame: (frame: CapturedOpusFrame) => void;
+ sendFrame: (source: AudioStreamSource['kind'], frame: CapturedOpusFrame) => void;
}
const HEARTBEAT_MS = 25_000;
-const RECONNECT_BASE_MS = 3_000;
-const RECONNECT_MAX_MS = 30_000;
-function recoveryBatchId(): string {
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
-}
-
-function socketOptions(urlText: string, phone: boolean) {
- const url = new URL(urlText);
- const bearerToken = url.searchParams.get('token') ?? '';
- const displayName = url.searchParams.get('device_name') ?? (phone ? 'phone-mic' : 'omi');
- url.search = '';
- url.pathname = '/ws/audio';
+function socketSource(source: AudioStreamSource) {
+ if (source.kind === 'wearable') {
+ return {
+ sourceId: source.sourceId,
+ displayName: 'wearable',
+ deviceKind: DeviceKind.OMI,
+ uplinkFrameDurationMs: 60 as const,
+ };
+ }
return {
- url: url.toString(),
- bearerToken,
- sourceId: displayName,
- displayName,
- deviceKind: phone
- ? (Platform.OS === 'ios' ? DeviceKind.IOS_PHONE : DeviceKind.ANDROID_PHONE)
- : DeviceKind.OMI,
+ sourceId: 'phone-mic',
+ displayName: 'phone-mic',
+ deviceKind: Platform.OS === 'ios' ? DeviceKind.IOS_PHONE : DeviceKind.ANDROID_PHONE,
+ uplinkFrameDurationMs: 20 as const,
};
}
@@ -121,24 +105,17 @@ function processingProfile(capabilities: VoiceCapabilities): ProcessingProfile {
return ProcessingProfile.HALF_DUPLEX;
}
-export const useAudioStreamer = (options?: UseAudioStreamerOptions): UseAudioStreamer => {
+export const useAudioStreamer = (): UseAudioStreamer => {
const [isStreaming, setIsStreaming] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState(null);
const [phonePlaybackState, setPhonePlaybackState] = useState(null);
const socketRef = useRef(null);
- const configRef = useRef(undefined);
- const urlRef = useRef('');
+ const sourceRef = useRef(null);
const stoppedRef = useRef(false);
- const reconnectRef = useRef | null>(null);
const heartbeatRef = useRef | null>(null);
- const reconnectAttemptRef = useRef(0);
const liveSequenceRef = useRef(0);
const liveStartedAtRef = useRef(0);
- const acceptedRef = useRef(new Map());
- const acceptedWaitersRef = useRef(new Map void>());
- const connectingRef = useRef | null>(null);
- const deliveryModeRef = useRef<'idle' | 'recovering' | 'live'>('idle');
const playbackRef = useRef<{
responseId: string;
generation: number;
@@ -146,7 +123,6 @@ export const useAudioStreamer = (options?: UseAudioStreamerOptions): UseAudioStr
nextSequence: number;
} | null>(null);
const playbackSubscriptionRef = useRef<{ remove: () => void } | null>(null);
- const routeSubscriptionRef = useRef<{ remove: () => void } | null>(null);
const encodeBase64 = useCallback((bytes: Uint8Array) => {
let binary = '';
@@ -157,85 +133,26 @@ export const useAudioStreamer = (options?: UseAudioStreamerOptions): UseAudioStr
}, []);
const packetAccepted = useCallback((sequence: number) => {
- const packet = acceptedRef.current.get(sequence);
- if (packet) {
- acceptedRef.current.delete(sequence);
- durableAudioSpool.acknowledge(packet).catch(cause => {
- setError(cause instanceof Error ? cause.message : 'Could not retire audio spool packet');
- });
- }
- acceptedWaitersRef.current.get(sequence)?.();
- acceptedWaitersRef.current.delete(sequence);
- }, []);
-
- const sendSpoolPacket = useCallback((packet: SpoolPacket, sequence: number) => {
- const socket = socketRef.current;
- if (!socket?.activeBinding) return false;
- acceptedRef.current.set(sequence, packet);
- socket.sendPacket({
- sequence,
- capturedAtMs: packet.capturedAtMs,
- monotonicOffsetUs: Math.max(
- 0,
- Math.round((packet.capturedAtMs - liveStartedAtRef.current) * 1000)
- ),
- opus: packet.payload,
- });
- return true;
- }, []);
-
- const drainRecovery = useCallback(async (
- socket: AudioV2Socket,
- captureEpoch: number,
- ) => {
- let recoverySequence = 0;
- while (true) {
- const packets = await durableAudioSpool.pendingPackets();
- if (!packets.length) return;
- await socket.beginCapture({
- captureEpoch,
- processingProfile: ProcessingProfile.SOURCE_NATIVE,
- dataPurpose: DataPurpose.NORMAL_CAPTURE,
- deliveryClass: DeliveryClass.RECOVERED,
- recoveryBatchId: recoveryBatchId(),
- });
- const acknowledgements = packets.map(packet => {
- const sequence = recoverySequence++;
- return new Promise(resolve => {
- acceptedWaitersRef.current.set(sequence, resolve);
- acceptedRef.current.set(sequence, packet);
- socket.sendPacket({
- sequence,
- capturedAtMs: packet.capturedAtMs,
- monotonicOffsetUs: 0,
- opus: packet.payload,
- });
- });
- });
- await Promise.all(acknowledgements);
- await socket.stopCapture();
- }
+ phoneAudioDiagnostics.packetAccepted(sequence);
}, []);
const stopStreaming = useCallback(async () => {
stoppedRef.current = true;
- deliveryModeRef.current = 'idle';
- if (reconnectRef.current) clearTimeout(reconnectRef.current);
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
- reconnectRef.current = null;
heartbeatRef.current = null;
+ const socket = socketRef.current;
try {
- await socketRef.current?.stopCapture();
+ if (sourceRef.current?.kind === 'phone') {
+ await sourceRef.current.stopCapture();
+ }
+ await socket?.stopCapture();
} finally {
- socketRef.current?.close();
+ socket?.close();
socketRef.current = null;
playbackSubscriptionRef.current?.remove();
playbackSubscriptionRef.current = null;
- routeSubscriptionRef.current?.remove();
- routeSubscriptionRef.current = null;
playbackRef.current = null;
- await configRef.current?.phoneVoice?.stopCapture();
- durableAudioSpool.close();
+ sourceRef.current = null;
setIsStreaming(false);
setIsConnecting(false);
}
@@ -243,28 +160,20 @@ export const useAudioStreamer = (options?: UseAudioStreamerOptions): UseAudioStr
const startStreaming = useCallback(async (
url: string,
- config?: StreamStartConfig,
+ source: AudioStreamSource,
): Promise => {
- if (connectingRef.current) return connectingRef.current;
- const operation = (async () => {
- stoppedRef.current = false;
- urlRef.current = url;
- configRef.current = config ?? configRef.current;
- setIsConnecting(true);
- setError(null);
- const phoneVoice = configRef.current?.phoneVoice;
- let parsed = socketOptions(url, Boolean(phoneVoice));
- if (!parsed.bearerToken) {
- const token = await refreshToken();
- if (!token) throw new Error('Audio authentication expired');
- options?.onTokenRefreshed?.(token);
- const refreshed = new URL(url);
- refreshed.searchParams.set('token', token);
- urlRef.current = refreshed.toString();
- parsed = socketOptions(urlRef.current, Boolean(phoneVoice));
- }
+ stoppedRef.current = false;
+ sourceRef.current = source;
+ setIsConnecting(true);
+ setError(null);
+ try {
+ const phoneVoice = source.kind === 'phone' ? source : null;
+ const token = await getValidToken();
+ if (!token) throw new Error('Audio authentication expired');
const socket = new AudioV2Socket({
- ...parsed,
+ url,
+ bearerToken: token,
+ ...socketSource(source),
onPacketAccepted: packetAccepted,
onControl: control => {
if (control.event.case === 'playbackOffer') {
@@ -315,35 +224,24 @@ export const useAudioStreamer = (options?: UseAudioStreamerOptions): UseAudioStr
}
},
onClosed: () => {
+ if (phoneVoice) phoneAudioDiagnostics.socketClosed(stoppedRef.current);
setIsStreaming(false);
- if (
- !stoppedRef.current &&
- (options?.autoReconnectEnabled ?? true) &&
- !reconnectRef.current
- ) {
- const delay = Math.min(
- RECONNECT_MAX_MS,
- RECONNECT_BASE_MS * (2 ** reconnectAttemptRef.current++)
- );
- reconnectRef.current = setTimeout(() => {
- reconnectRef.current = null;
- startStreaming(urlRef.current).catch(cause => {
- setError(cause instanceof Error ? cause.message : 'Audio reconnect failed');
- });
- }, delay);
- }
+ if (!stoppedRef.current) setError('Audio connection closed');
+ },
+ onDiagnostic: event => {
+ if (phoneVoice) phoneAudioDiagnostics.socketStage(event.stage, event.detail);
},
});
socketRef.current = socket;
+ if (phoneVoice) phoneAudioDiagnostics.socketConnecting();
await socket.connect();
- deliveryModeRef.current = 'recovering';
- await drainRecovery(socket, phoneVoice?.captureEpoch ?? 0);
- liveStartedAtRef.current = Date.now();
+ if (phoneVoice) phoneAudioDiagnostics.socketOpen();
+ liveStartedAtRef.current = performance.now();
liveSequenceRef.current = 0;
const capabilities = phoneVoice
? typedCapabilities(phoneVoice.capabilities)
: undefined;
- await socket.beginCapture({
+ const binding = await socket.beginCapture({
captureEpoch: phoneVoice?.captureEpoch ?? 0,
processingProfile: phoneVoice
? processingProfile(phoneVoice.capabilities)
@@ -352,7 +250,9 @@ export const useAudioStreamer = (options?: UseAudioStreamerOptions): UseAudioStr
deliveryClass: DeliveryClass.LIVE,
capabilities,
});
- deliveryModeRef.current = 'live';
+ if (phoneVoice) {
+ phoneAudioDiagnostics.captureStarted(binding.captureSessionId?.value ?? '');
+ }
if (capabilities) socket.voiceReady(capabilities);
if (phoneVoice) {
playbackSubscriptionRef.current?.remove();
@@ -372,36 +272,15 @@ export const useAudioStreamer = (options?: UseAudioStreamerOptions): UseAudioStr
event.monotonicTimestampMs,
);
});
- routeSubscriptionRef.current?.remove();
- routeSubscriptionRef.current = addRouteChangeListener(event => {
- if (event.captureEpoch !== phoneVoice.captureEpoch) return;
- stoppedRef.current = true;
- if (heartbeatRef.current) clearInterval(heartbeatRef.current);
- heartbeatRef.current = null;
- socket.stopCapture()
- .catch(() => undefined)
- .then(() => {
- socket.close();
- return phoneVoice.restartCapture();
- })
- .then(restarted => startStreaming(urlRef.current, { phoneVoice: restarted }))
- .catch(cause => {
- setError(cause instanceof Error ? cause.message : 'Audio route restart failed');
- });
- });
}
- reconnectAttemptRef.current = 0;
heartbeatRef.current = setInterval(
() => socket.heartbeat(performance.now()),
HEARTBEAT_MS,
);
setIsConnecting(false);
setIsStreaming(true);
- })();
- connectingRef.current = operation;
- try {
- await operation;
} catch (cause) {
+ if (source.kind === 'phone') phoneAudioDiagnostics.failure('websocket_start', cause);
setIsConnecting(false);
setIsStreaming(false);
const message = cause instanceof Error ? cause.message : 'Audio V2 connection failed';
@@ -410,49 +289,32 @@ export const useAudioStreamer = (options?: UseAudioStreamerOptions): UseAudioStr
socketRef.current = null;
playbackSubscriptionRef.current?.remove();
playbackSubscriptionRef.current = null;
- routeSubscriptionRef.current?.remove();
- routeSubscriptionRef.current = null;
- deliveryModeRef.current = 'idle';
throw cause;
- } finally {
- connectingRef.current = null;
}
- }, [drainRecovery, encodeBase64, options, packetAccepted]);
+ }, [encodeBase64, packetAccepted]);
- const enqueueLive = useCallback((opus: Uint8Array, capturedAtMs: number) => {
- if (!opus.length) return;
- const packet = durableAudioSpool.append(opus, capturedAtMs);
- if (deliveryModeRef.current === 'live') {
- sendSpoolPacket(packet, liveSequenceRef.current++);
+ const sendFrame = useCallback((
+ source: AudioStreamSource['kind'],
+ frame: CapturedOpusFrame,
+ ) => {
+ if (stoppedRef.current || !frame.opus.length) return;
+ const activeSource = sourceRef.current;
+ const socket = socketRef.current;
+ if (!socket?.activeBinding || activeSource?.kind !== source) return;
+ if (source === 'phone') {
+ if (activeSource.kind !== 'phone' || frame.captureEpoch !== activeSource.captureEpoch) return;
+ phoneAudioDiagnostics.frameSent(frame.opus.length);
}
- }, [sendSpoolPacket]);
-
- const sendDurableAudio = useCallback((audioBytes: Uint8Array) => {
- enqueueLive(audioBytes, Date.now());
- }, [enqueueLive]);
-
- const sendInteractiveFrame = useCallback((frame: CapturedOpusFrame) => {
- enqueueLive(frame.opus, frame.capturedAtMs);
- }, [enqueueLive]);
-
- useEffect(() => {
- const unsubscribe = NetInfo.addEventListener(state => {
- if (
- state.isConnected &&
- state.isInternetReachable &&
- !stoppedRef.current &&
- socketRef.current?.readyState !== WebSocket.OPEN &&
- urlRef.current
- ) {
- startStreaming(urlRef.current).catch(() => undefined);
- }
+ socket.sendPacket({
+ sequence: liveSequenceRef.current++,
+ capturedAtMs: frame.capturedAtMs,
+ monotonicOffsetUs: Math.max(
+ 0,
+ Math.round((frame.monotonicTimestampMs - liveStartedAtRef.current) * 1000),
+ ),
+ opus: frame.opus,
});
- return () => {
- unsubscribe();
- stoppedRef.current = true;
- socketRef.current?.close();
- };
- }, [startStreaming]);
+ }, []);
return {
isStreaming,
@@ -460,9 +322,7 @@ export const useAudioStreamer = (options?: UseAudioStreamerOptions): UseAudioStr
error,
phonePlaybackState,
startStreaming,
- getWebSocketReadyState: () => socketRef.current?.readyState,
stopStreaming,
- sendDurableAudio,
- sendInteractiveFrame,
+ sendFrame,
};
};
diff --git a/app/src/hooks/useAudioStreamingOrchestrator.ts b/app/src/hooks/useAudioStreamingOrchestrator.ts
index 8166cda7b..5dc24aa5a 100644
--- a/app/src/hooks/useAudioStreamingOrchestrator.ts
+++ b/app/src/hooks/useAudioStreamingOrchestrator.ts
@@ -1,10 +1,11 @@
-import { useState, useCallback } from 'react';
+import { useCallback } from 'react';
import { Alert } from 'react-native';
-import { OmiConnection } from 'friend-lite-react-native';
-import { AppSettings } from './useAppSettings';
-import type { StreamStartConfig } from './useAudioStreamer';
+import { BleAudioCodec, OmiConnection } from 'friend-lite-react-native';
+import type { AppSettings } from './useAppSettings';
+import type { AudioStreamSource } from './useAudioStreamer';
import type { PhoneCaptureSession } from './usePhoneAudioRecorder';
import type { CapturedOpusFrame } from '../protocol/capturedOpusFrame';
+import { phoneAudioDiagnostics } from '../services/phoneAudioDiagnostics';
interface OrchestratorParams {
omiConnection: OmiConnection;
@@ -12,28 +13,23 @@ interface OrchestratorParams {
connectedDeviceId: string | null;
};
audioStreamer: {
- isStreaming: boolean;
- startStreaming: (url: string, config?: StreamStartConfig) => Promise;
+ startStreaming: (url: string, source: AudioStreamSource) => Promise;
stopStreaming: () => Promise;
- sendDurableAudio: (audioBytes: Uint8Array) => void;
- sendInteractiveFrame: (frame: CapturedOpusFrame) => void;
- getWebSocketReadyState: () => number | undefined;
+ sendFrame: (source: 'phone' | 'wearable', frame: CapturedOpusFrame) => void;
};
phoneAudioRecorder: {
isRecording: boolean;
startRecording: (
onData: (frame: CapturedOpusFrame) => Promise
) => Promise;
- stopRecording: () => Promise;
};
originalStartAudioListener: (onAudioData: (bytes: Uint8Array) => void) => Promise;
originalStopAudioListener: () => Promise;
- settings: AppSettings;
+ settings: Pick;
}
export interface AudioOrchestrator {
isPhoneAudioMode: boolean;
- setIsPhoneAudioMode: (mode: boolean) => void;
handleStartAudioListeningAndStreaming: () => Promise;
handleStopAudioListeningAndStreaming: () => Promise;
handleTogglePhoneAudio: () => Promise;
@@ -48,47 +44,14 @@ export const useAudioStreamingOrchestrator = ({
originalStopAudioListener,
settings,
}: OrchestratorParams): AudioOrchestrator => {
- const [isPhoneAudioMode, setIsPhoneAudioMode] = useState(false);
-
- const buildWebSocketUrl = useCallback((baseUrl: string): string => {
+ const buildAudioWebSocketUrl = useCallback((baseUrl: string): string => {
let url = baseUrl.trim();
url = url.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:');
const parsed = new URL(url);
parsed.pathname = '/ws/audio';
parsed.search = '';
- url = parsed.toString();
-
- const isAdvanced = settings.jwtToken && settings.isAuthenticated;
- if (isAdvanced) {
- const params = new URLSearchParams();
- params.append('token', settings.jwtToken!);
- const deviceName = settings.userId?.trim() || 'phone';
- params.append('device_name', deviceName);
- const separator = url.includes('?') ? '&' : '?';
- url = `${url}${separator}${params.toString()}`;
- }
- return url;
- }, [settings.jwtToken, settings.isAuthenticated, settings.userId]);
-
- const buildPhoneWebSocketUrl = useCallback((baseUrl: string): string => {
- let url = baseUrl.trim();
- url = url.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:');
- const parsed = new URL(url);
- parsed.pathname = '/ws/audio';
- parsed.search = '';
- url = parsed.toString();
-
- const isAdvanced = settings.jwtToken && settings.isAuthenticated;
- if (isAdvanced) {
- const params = new URLSearchParams();
- params.append('token', settings.jwtToken!);
- const deviceName = settings.userId?.trim() || 'phone-mic';
- params.append('device_name', deviceName);
- const separator = url.includes('?') ? '&' : '?';
- url = `${url}${separator}${params.toString()}`;
- }
- return url;
- }, [settings.jwtToken, settings.isAuthenticated, settings.userId]);
+ return parsed.toString();
+ }, []);
const handleStartAudioListeningAndStreaming = useCallback(async () => {
if (!settings.webSocketUrl?.trim()) {
@@ -101,22 +64,30 @@ export const useAudioStreamingOrchestrator = ({
}
try {
- const finalUrl = buildWebSocketUrl(settings.webSocketUrl);
+ const codec = await omiConnection.getAudioCodec();
+ if (codec !== BleAudioCodec.OPUS) {
+ throw new Error(`Wearable must stream Opus; device reported ${codec}`);
+ }
+ const sourceId = deviceConnection.connectedDeviceId;
+ const finalUrl = buildAudioWebSocketUrl(settings.webSocketUrl);
await originalStartAudioListener(async (audioBytes) => {
if (audioBytes.length > 0) {
- audioStreamer.sendDurableAudio(audioBytes);
+ audioStreamer.sendFrame('wearable', {
+ captureEpoch: 0,
+ capturedAtMs: Date.now(),
+ monotonicTimestampMs: performance.now(),
+ frameDurationMs: 60,
+ opus: audioBytes,
+ });
}
});
- // BLE capture is independent of network availability. The durable spool above
- // keeps receiving while this connection attempt fails or reconnects.
- audioStreamer.startStreaming(finalUrl).catch((error) => {
- console.warn('[AudioOrchestrator] Initial WebSocket connection failed; buffering locally:', error);
- });
+ await audioStreamer.startStreaming(finalUrl, { kind: 'wearable', sourceId });
} catch (error) {
Alert.alert('Error', 'Could not start audio listening or streaming.');
- if (audioStreamer.isStreaming) audioStreamer.stopStreaming();
+ await originalStopAudioListener();
+ await audioStreamer.stopStreaming();
}
- }, [originalStartAudioListener, audioStreamer, settings.webSocketUrl, omiConnection, deviceConnection.connectedDeviceId, buildWebSocketUrl]);
+ }, [originalStartAudioListener, audioStreamer, settings.webSocketUrl, omiConnection, deviceConnection.connectedDeviceId, buildAudioWebSocketUrl]);
const handleStopAudioListeningAndStreaming = useCallback(async () => {
await originalStopAudioListener();
@@ -130,40 +101,35 @@ export const useAudioStreamingOrchestrator = ({
}
try {
- const finalUrl = buildPhoneWebSocketUrl(settings.webSocketUrl);
+ const finalUrl = buildAudioWebSocketUrl(settings.webSocketUrl);
const capture = await phoneAudioRecorder.startRecording(async (frame) => {
- const wsReady = audioStreamer.getWebSocketReadyState();
- if (wsReady === WebSocket.OPEN && frame.opus.length > 0) {
- audioStreamer.sendInteractiveFrame(frame);
- }
+ if (frame.opus.length === 0) return;
+ audioStreamer.sendFrame('phone', frame);
});
- await audioStreamer.startStreaming(finalUrl, { phoneVoice: capture });
- setIsPhoneAudioMode(true);
+ await audioStreamer.startStreaming(finalUrl, { kind: 'phone', ...capture });
} catch (error) {
+ phoneAudioDiagnostics.failure('orchestrator_start', error);
Alert.alert('Error', 'Could not start phone audio streaming.');
- if (audioStreamer.isStreaming) audioStreamer.stopStreaming();
- if (phoneAudioRecorder.isRecording) await phoneAudioRecorder.stopRecording();
- setIsPhoneAudioMode(false);
+ await audioStreamer.stopStreaming();
}
- }, [audioStreamer, phoneAudioRecorder, settings.webSocketUrl, buildPhoneWebSocketUrl]);
+ }, [audioStreamer, phoneAudioRecorder, settings.webSocketUrl, buildAudioWebSocketUrl]);
const handleStopPhoneAudioStreaming = useCallback(async () => {
await audioStreamer.stopStreaming();
- await phoneAudioRecorder.stopRecording();
- setIsPhoneAudioMode(false);
- }, [phoneAudioRecorder, audioStreamer]);
+ phoneAudioDiagnostics.stopped();
+ }, [audioStreamer]);
const handleTogglePhoneAudio = useCallback(async () => {
- if (isPhoneAudioMode || phoneAudioRecorder.isRecording) {
+ if (phoneAudioRecorder.isRecording) {
await handleStopPhoneAudioStreaming();
} else {
+ phoneAudioDiagnostics.beginAttempt();
await handleStartPhoneAudioStreaming();
}
- }, [isPhoneAudioMode, phoneAudioRecorder.isRecording, handleStartPhoneAudioStreaming, handleStopPhoneAudioStreaming]);
+ }, [phoneAudioRecorder.isRecording, handleStartPhoneAudioStreaming, handleStopPhoneAudioStreaming]);
return {
- isPhoneAudioMode,
- setIsPhoneAudioMode,
+ isPhoneAudioMode: phoneAudioRecorder.isRecording,
handleStartAudioListeningAndStreaming,
handleStopAudioListeningAndStreaming,
handleTogglePhoneAudio,
diff --git a/app/src/hooks/usePhoneAudioRecorder.ts b/app/src/hooks/usePhoneAudioRecorder.ts
index 92e03038f..da1f5e979 100644
--- a/app/src/hooks/usePhoneAudioRecorder.ts
+++ b/app/src/hooks/usePhoneAudioRecorder.ts
@@ -4,6 +4,7 @@ import { PermissionsAndroid, Platform } from 'react-native';
import base64 from 'react-native-base64';
import {
+ addCaptureDiagnosticListener,
addOpusFrameListener,
startVoiceSession,
stopVoiceSession,
@@ -13,11 +14,15 @@ import {
capturedOpusFrameFromNative,
type CapturedOpusFrame,
} from '../protocol/capturedOpusFrame';
+import { phoneAudioDiagnostics } from '../services/phoneAudioDiagnostics';
+
+const FIRST_FRAME_TIMEOUT_MS = 3_000;
+const AUDIO_LEVEL_TIMEOUT_MS = 5_000;
+const ACTIVE_AUDIO_LEVEL = 0.01;
export interface PhoneCaptureSession {
captureEpoch: number;
capabilities: VoiceCapabilities;
- restartCapture: () => Promise;
stopCapture: () => Promise;
}
@@ -49,11 +54,22 @@ export const usePhoneAudioRecorder = (): UsePhoneAudioRecorder => {
const mountedRef = useRef(true);
const captureEpochRef = useRef(0);
const frameSubscriptionRef = useRef<{ remove: () => void } | null>(null);
+ const diagnosticSubscriptionRef = useRef<{ remove: () => void } | null>(null);
const onAudioDataRef = useRef<((frame: CapturedOpusFrame) => void) | null>(null);
+ const firstFrameSeenRef = useRef(false);
+ const firstFrameTimeoutRef = useRef | null>(null);
+ const audioLevelActiveRef = useRef(false);
+ const audioLevelTimeoutRef = useRef | null>(null);
const markCaptureStopped = useCallback(() => {
+ if (firstFrameTimeoutRef.current) clearTimeout(firstFrameTimeoutRef.current);
+ firstFrameTimeoutRef.current = null;
+ if (audioLevelTimeoutRef.current) clearTimeout(audioLevelTimeoutRef.current);
+ audioLevelTimeoutRef.current = null;
frameSubscriptionRef.current?.remove();
frameSubscriptionRef.current = null;
+ diagnosticSubscriptionRef.current?.remove();
+ diagnosticSubscriptionRef.current = null;
onAudioDataRef.current = null;
if (mountedRef.current) {
setIsRecording(false);
@@ -71,13 +87,30 @@ export const usePhoneAudioRecorder = (): UsePhoneAudioRecorder => {
}, [markCaptureStopped]);
const startNativeCapture = useCallback(async (): Promise => {
+ if (firstFrameTimeoutRef.current) clearTimeout(firstFrameTimeoutRef.current);
+ firstFrameTimeoutRef.current = null;
+ if (audioLevelTimeoutRef.current) clearTimeout(audioLevelTimeoutRef.current);
+ audioLevelTimeoutRef.current = null;
const captureEpoch = captureEpochRef.current + 1;
captureEpochRef.current = captureEpoch;
const capabilities = await startVoiceSession({ captureEpoch });
+ phoneAudioDiagnostics.engineStarted(captureEpoch, capabilities);
+ if (!firstFrameSeenRef.current) {
+ firstFrameTimeoutRef.current = setTimeout(() => {
+ phoneAudioDiagnostics.timeout('native_frame_timeout');
+ if (mountedRef.current) {
+ setError('Microphone started, but Chronicle received no audio frames.');
+ }
+ }, FIRST_FRAME_TIMEOUT_MS);
+ }
+ if (!audioLevelActiveRef.current) {
+ audioLevelTimeoutRef.current = setTimeout(() => {
+ phoneAudioDiagnostics.timeout('audio_level_stalled');
+ }, AUDIO_LEVEL_TIMEOUT_MS);
+ }
return {
captureEpoch,
capabilities,
- restartCapture: startNativeCapture,
stopCapture: stopRecording,
};
}, [stopRecording]);
@@ -100,11 +133,44 @@ export const usePhoneAudioRecorder = (): UsePhoneAudioRecorder => {
}
try {
+ firstFrameSeenRef.current = false;
+ audioLevelActiveRef.current = false;
onAudioDataRef.current = onAudioData;
+ phoneAudioDiagnostics.listenerInstalled(captureEpochRef.current + 1);
+ diagnosticSubscriptionRef.current = addCaptureDiagnosticListener((event) => {
+ if (!mountedRef.current || event.captureEpoch !== captureEpochRef.current) return;
+ phoneAudioDiagnostics.nativeStage(event);
+ });
frameSubscriptionRef.current = addOpusFrameListener((frame) => {
if (!mountedRef.current || frame.captureEpoch !== captureEpochRef.current) return;
- const captured = capturedOpusFrameFromNative(frame, decodeBase64);
- if (!captured.opus.length) return;
+ let captured: CapturedOpusFrame;
+ try {
+ captured = capturedOpusFrameFromNative(frame, decodeBase64);
+ } catch (cause) {
+ phoneAudioDiagnostics.invalidNativeFrame(
+ cause instanceof Error ? cause.message : 'invalid_native_frame',
+ );
+ return;
+ }
+ const level = Math.min(1, Math.max(0, frame.audioLevel || 0));
+ phoneAudioDiagnostics.nativeFrame({
+ captureEpoch: frame.captureEpoch,
+ opusBytes: captured.opus.length,
+ audioLevel: level,
+ });
+ if (!firstFrameSeenRef.current) {
+ firstFrameSeenRef.current = true;
+ if (firstFrameTimeoutRef.current) clearTimeout(firstFrameTimeoutRef.current);
+ firstFrameTimeoutRef.current = null;
+ setError(null);
+ }
+ if (!audioLevelActiveRef.current && level >= ACTIVE_AUDIO_LEVEL) {
+ audioLevelActiveRef.current = true;
+ if (audioLevelTimeoutRef.current) clearTimeout(audioLevelTimeoutRef.current);
+ audioLevelTimeoutRef.current = null;
+ phoneAudioDiagnostics.audioLevelActive(level);
+ }
+ setAudioLevel(previous => previous === 0 ? level : (previous * 0.65) + (level * 0.35));
onAudioDataRef.current?.(captured);
});
const capture = await startNativeCapture();
@@ -114,8 +180,11 @@ export const usePhoneAudioRecorder = (): UsePhoneAudioRecorder => {
}
return capture;
} catch (cause) {
+ phoneAudioDiagnostics.failure('native_capture_start', cause);
frameSubscriptionRef.current?.remove();
frameSubscriptionRef.current = null;
+ diagnosticSubscriptionRef.current?.remove();
+ diagnosticSubscriptionRef.current = null;
const message = cause instanceof Error ? cause.message : 'Failed to start duplex audio';
if (mountedRef.current) {
setError(message);
@@ -130,6 +199,10 @@ export const usePhoneAudioRecorder = (): UsePhoneAudioRecorder => {
mountedRef.current = false;
frameSubscriptionRef.current?.remove();
frameSubscriptionRef.current = null;
+ diagnosticSubscriptionRef.current?.remove();
+ diagnosticSubscriptionRef.current = null;
+ if (firstFrameTimeoutRef.current) clearTimeout(firstFrameTimeoutRef.current);
+ if (audioLevelTimeoutRef.current) clearTimeout(audioLevelTimeoutRef.current);
stopVoiceSession().catch(() => undefined);
}, []);
diff --git a/app/src/protocol/audioV2Socket.ts b/app/src/protocol/audioV2Socket.ts
index 67a842db6..390771a3b 100644
--- a/app/src/protocol/audioV2Socket.ts
+++ b/app/src/protocol/audioV2Socket.ts
@@ -53,31 +53,74 @@ export interface StartCaptureOptions {
capabilities?: CaptureCapabilities;
}
-interface AudioV2SocketOptions {
+export interface AudioV2SocketDiagnostic {
+ stage:
+ | 'socket_created'
+ | 'transport_open'
+ | 'client_hello_sent'
+ | 'server_hello_received'
+ | 'capture_start_sent'
+ | 'capture_started_received'
+ | 'capture_stop_sent'
+ | 'capture_stopped_received'
+ | 'server_error'
+ | 'transport_error'
+ | 'transport_closed'
+ | 'control_decode_failed';
+ detail?: string;
+}
+
+export interface AudioV2SocketOptions {
url: string;
bearerToken: string;
sourceId: string;
displayName: string;
deviceKind: DeviceKind;
+ uplinkFrameDurationMs: 20 | 60;
onPacketAccepted?: (sequence: number) => void;
onPlaybackPacket?: (packet: PlaybackMediaPacket) => void;
onControl?: (control: ServerControl) => void;
onClosed?: () => void;
+ onDiagnostic?: (event: AudioV2SocketDiagnostic) => void;
webSocketFactory?: (url: string, protocols: string | string[]) => WebSocket;
}
type AwaitedEvent = 'hello' | 'captureStarted' | 'captureStopped';
+interface RuntimeCrypto {
+ randomUUID?: () => string;
+}
+
+let fallbackEventIdSequence = 0;
+
+function fallbackEventId(): string {
+ fallbackEventIdSequence = (fallbackEventIdSequence + 1) % 0x1_0000;
+ const random = Array.from(
+ { length: 19 },
+ () => Math.floor(Math.random() * 16).toString(16),
+ ).join('');
+ const sequence = fallbackEventIdSequence.toString(16).padStart(4, '0');
+ const timestamp = Date.now().toString(16).padStart(12, '0').slice(-12);
+ const variant = (8 + Math.floor(Math.random() * 4)).toString(16);
+ return `${random.slice(0, 8)}-${sequence}-4${random.slice(8, 11)}-${variant}${random.slice(11, 14)}-${timestamp}`;
+}
+
+export function createClientEventIdValue(
+ runtimeCrypto: RuntimeCrypto | null | undefined = (globalThis as { crypto?: RuntimeCrypto }).crypto,
+): string {
+ return runtimeCrypto?.randomUUID ? runtimeCrypto.randomUUID() : fallbackEventId();
+}
+
function eventId() {
- return create(EventIdSchema, { value: crypto.randomUUID() });
+ return create(EventIdSchema, { value: createClientEventIdValue() });
}
-function uplinkSpec() {
+function uplinkSpec(frameDurationMs: 20 | 60) {
return create(AudioSpecSchema, {
codec: AudioCodec.OPUS,
sampleRateHz: 16_000,
channelCount: 1,
- frameDuration: create(DurationSchema, { nanos: 20_000_000 }),
+ frameDuration: create(DurationSchema, { nanos: frameDurationMs * 1_000_000 }),
bitrateBps: 24_000,
});
}
@@ -99,6 +142,7 @@ export class AudioV2Socket {
private waiters = new Map void;
reject: (error: Error) => void;
+ timeout: ReturnType;
}>();
constructor(options: AudioV2SocketOptions) {
@@ -117,26 +161,40 @@ export class AudioV2Socket {
if (this.socket) throw new Error('audio-v2 socket already exists');
const factory = this.options.webSocketFactory ?? ((url, protocols) => new WebSocket(url, protocols));
const socket = factory(this.options.url, AUDIO_V2_SUBPROTOCOL);
+ this.diagnostic('socket_created');
socket.binaryType = 'arraybuffer';
this.socket = socket;
const hello = this.waitFor('hello');
await new Promise((resolve, reject) => {
socket.onopen = () => {
- this.sendControl({
- case: 'hello',
- value: create(ClientHelloSchema, {
- bearerToken: this.options.bearerToken,
- sourceId: create(CaptureSourceIdSchema, { value: this.options.sourceId }),
- deviceKind: this.options.deviceKind,
- displayName: this.options.displayName,
- supportedUplink: [uplinkSpec()],
- supportedDownlink: [downlinkSpec()],
- }),
- });
- resolve();
+ this.diagnostic('transport_open');
+ try {
+ this.sendControl({
+ case: 'hello',
+ value: create(ClientHelloSchema, {
+ bearerToken: this.options.bearerToken,
+ sourceId: create(CaptureSourceIdSchema, { value: this.options.sourceId }),
+ deviceKind: this.options.deviceKind,
+ displayName: this.options.displayName,
+ supportedUplink: [uplinkSpec(this.options.uplinkFrameDurationMs)],
+ supportedDownlink: [downlinkSpec()],
+ }),
+ });
+ this.diagnostic('client_hello_sent');
+ resolve();
+ } catch (error) {
+ reject(error instanceof Error ? error : new Error(String(error)));
+ }
+ };
+ socket.onerror = () => {
+ this.diagnostic('transport_error');
+ reject(new Error('audio-v2 WebSocket failed'));
};
- socket.onerror = () => reject(new Error('audio-v2 WebSocket failed'));
- socket.onclose = () => {
+ socket.onclose = event => {
+ this.diagnostic(
+ 'transport_closed',
+ `code=${event.code} clean=${event.wasClean} reason=${event.reason.slice(0, 160)}`,
+ );
this.rejectWaiters(new Error('audio-v2 WebSocket closed'));
this.options.onClosed?.();
};
@@ -155,11 +213,12 @@ export class AudioV2Socket {
processingProfile: options.processingProfile,
dataPurpose: options.dataPurpose ?? DataPurpose.NORMAL_CAPTURE,
deliveryClass: options.deliveryClass,
- audioSpec: uplinkSpec(),
+ audioSpec: uplinkSpec(this.options.uplinkFrameDurationMs),
capabilities: options.capabilities,
recoveryBatchId: options.recoveryBatchId ?? '',
}),
});
+ this.diagnostic('capture_start_sent');
const control = await started;
if (control.event.case !== 'captureStarted') {
throw new Error('expected capture_started');
@@ -167,6 +226,7 @@ export class AudioV2Socket {
const binding = control.event.value.binding;
if (!binding?.captureSessionId?.value) throw new Error('capture start has no binding');
this.binding = binding;
+ this.diagnostic('capture_started_received');
return binding;
}
@@ -208,7 +268,9 @@ export class AudioV2Socket {
case: 'stopCapture',
value: create(StopCaptureSchema, { binding, reason }),
});
+ this.diagnostic('capture_stop_sent');
await stopped;
+ this.diagnostic('capture_stopped_received');
this.binding = null;
this.currentDeliveryClass = DeliveryClass.UNSPECIFIED;
}
@@ -272,12 +334,24 @@ export class AudioV2Socket {
private receive(payload: string | ArrayBuffer | Blob): void {
if (typeof payload === 'string') {
- const control = decodeServerControl(payload);
+ let control: ServerControl;
+ try {
+ control = decodeServerControl(payload);
+ } catch (error) {
+ this.diagnostic(
+ 'control_decode_failed',
+ error instanceof Error ? error.message.slice(0, 160) : String(error).slice(0, 160),
+ );
+ this.rejectWaiters(new Error('audio-v2 server control could not be decoded'));
+ return;
+ }
const event = control.event.case;
if (event === 'error') {
+ this.diagnostic('server_error', control.event.value.detail.slice(0, 160));
this.rejectWaiters(new Error(control.event.value.detail));
return;
}
+ if (event === 'hello') this.diagnostic('server_hello_received');
if (event === 'captureStarted') this.binding = control.event.value.binding ?? null;
if (event === 'capturePacketAccepted') {
this.options.onPacketAccepted?.(Number(control.event.value.sequence));
@@ -285,6 +359,7 @@ export class AudioV2Socket {
const waiter = event ? this.waiters.get(event as AwaitedEvent) : undefined;
if (waiter) {
this.waiters.delete(event as AwaitedEvent);
+ clearTimeout(waiter.timeout);
waiter.resolve(control);
}
this.options.onControl?.(control);
@@ -302,14 +377,27 @@ export class AudioV2Socket {
private waitFor(event: AwaitedEvent): Promise {
if (this.waiters.has(event)) throw new Error(`already waiting for ${event}`);
- return new Promise((resolve, reject) => this.waiters.set(event, { resolve, reject }));
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ this.waiters.delete(event);
+ reject(new Error(`audio-v2 timed out waiting for ${event}`));
+ }, 10_000);
+ this.waiters.set(event, { resolve, reject, timeout });
+ });
}
private rejectWaiters(error: Error): void {
- this.waiters.forEach(waiter => waiter.reject(error));
+ this.waiters.forEach(waiter => {
+ clearTimeout(waiter.timeout);
+ waiter.reject(error);
+ });
this.waiters.clear();
}
+ private diagnostic(stage: AudioV2SocketDiagnostic['stage'], detail?: string): void {
+ this.options.onDiagnostic?.({ stage, detail });
+ }
+
private requireOpen(): WebSocket {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
throw new Error('audio-v2 WebSocket is not open');
diff --git a/app/src/services/auth.ts b/app/src/services/auth.ts
index 285f78b59..5714dc93b 100644
--- a/app/src/services/auth.ts
+++ b/app/src/services/auth.ts
@@ -53,7 +53,6 @@ export const isTokenExpired = (token: string | null, skewSeconds = 60): boolean
return Date.now() / 1000 >= exp - skewSeconds;
};
-/** Listeners notified whenever a fresh token is obtained (login or refresh). */
type TokenListener = (token: string) => void;
const tokenListeners = new Set();
@@ -62,16 +61,6 @@ export const onTokenRefreshed = (listener: TokenListener): (() => void) => {
return () => tokenListeners.delete(listener);
};
-const notifyToken = (token: string) => {
- tokenListeners.forEach(l => {
- try {
- l(token);
- } catch (e) {
- console.warn('[Auth] token listener error:', e);
- }
- });
-};
-
// De-dupe concurrent refreshes: many callers may hit a 401 at once.
let refreshInFlight: Promise | null = null;
@@ -106,7 +95,7 @@ export const login = async (
await saveAuthEmail(email);
await saveAuthPassword(password);
await saveJwtToken(token);
- notifyToken(token);
+ tokenListeners.forEach(listener => listener(token));
return token;
};
diff --git a/app/src/services/clientDiagnostics.ts b/app/src/services/clientDiagnostics.ts
index dcba1d91d..3dd72354c 100644
--- a/app/src/services/clientDiagnostics.ts
+++ b/app/src/services/clientDiagnostics.ts
@@ -1,3 +1,4 @@
+import * as Application from 'expo-application';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
@@ -22,8 +23,8 @@ export async function uploadClientDiagnostic(contents: string): Promise
- `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
-
-/**
- * Append-only, document-directory audio spool.
- *
- * Every BLE packet reaches this file before it is offered to the WebSocket. Files
- * are retained until the backend acknowledges the packet sequence after writing
- * the decoded audio to its Redis WAL. Old files are discovered after app restart.
- */
-export class DurableAudioSpool {
- private readonly directory = new Directory(Paths.document, 'chronicle-audio-spool');
- private active: ActiveSegment | null = null;
- private readonly acknowledgmentChains = new Map>();
-
- private ensureDirectory(): void {
- if (!this.directory.exists) {
- this.directory.create({ idempotent: true, intermediates: true });
- }
- }
-
- private closeActive(): void {
- if (!this.active) return;
- this.active.handle.close();
- this.active = null;
- }
-
- private startSegment(capturedAtMs: number): ActiveSegment {
- this.ensureDirectory();
- const segmentId = makeSegmentId();
- const file = new File(this.directory, `${segmentId}.spool`);
- file.create({ overwrite: false, intermediates: true });
- const active = {
- file,
- handle: file.open(),
- segmentId,
- startedAtMs: capturedAtMs,
- nextSequence: 0,
- };
- this.active = active;
- return active;
- }
-
- append(payload: Uint8Array, capturedAtMs = Date.now()): SpoolPacket {
- let segment = this.active;
- if (!segment || capturedAtMs - segment.startedAtMs >= SEGMENT_MS) {
- this.closeActive();
- segment = this.startSegment(capturedAtMs);
- }
-
- const sequence = segment.nextSequence++;
- const frame = new Uint8Array(HEADER_BYTES + payload.length);
- const view = new DataView(frame.buffer);
- view.setUint32(0, sequence, false);
- view.setFloat64(4, capturedAtMs, false);
- view.setUint32(12, payload.length, false);
- frame.set(payload, HEADER_BYTES);
- segment.handle.writeBytes(frame);
-
- return {
- fileName: segment.file.name,
- segmentId: segment.segmentId,
- sequence,
- capturedAtMs,
- payload,
- };
- }
-
- async pendingPackets(): Promise {
- this.ensureDirectory();
- const packets: SpoolPacket[] = [];
- const files = this.directory
- .list()
- .filter((entry): entry is File => entry instanceof File && entry.name.endsWith('.spool'));
-
- for (const file of files) {
- await this.acknowledgmentChains.get(file.name)?.catch(() => undefined);
- const segmentId = file.name.slice(0, -'.spool'.length);
- const acknowledged = Number(await AsyncStorage.getItem(`${ACK_PREFIX}${file.name}`) ?? '-1');
- const bytes = file.bytesSync();
- let offset = 0;
- let finalSequence = -1;
- while (offset + HEADER_BYTES <= bytes.length) {
- const view = new DataView(bytes.buffer, bytes.byteOffset + offset, HEADER_BYTES);
- const sequence = view.getUint32(0, false);
- const capturedAtMs = view.getFloat64(4, false);
- const length = view.getUint32(12, false);
- const end = offset + HEADER_BYTES + length;
- if (end > bytes.length) break; // Ignore a final partial frame after a hard crash.
- finalSequence = sequence;
- if (sequence > acknowledged) {
- packets.push({
- fileName: file.name,
- segmentId,
- sequence,
- capturedAtMs,
- payload: bytes.slice(offset + HEADER_BYTES, end),
- });
- }
- offset = end;
- }
- if (
- finalSequence >= 0 &&
- acknowledged >= finalSequence &&
- this.active?.file.name !== file.name
- ) {
- file.delete();
- await AsyncStorage.removeItem(`${ACK_PREFIX}${file.name}`);
- }
- }
- return packets.sort((a, b) => a.capturedAtMs - b.capturedAtMs);
- }
-
- private async acknowledgeInOrder(packet: SpoolPacket): Promise {
- const ackKey = `${ACK_PREFIX}${packet.fileName}`;
- const isActive = this.active?.file.name === packet.fileName;
- const file = isActive ? null : new File(this.directory, packet.fileName);
- if (file && !file.exists) {
- // A higher ACK may already have retired this closed segment. Do not let a
- // later, lower ACK recreate its watermark after the file is gone.
- await AsyncStorage.removeItem(ackKey);
- return;
- }
-
- const previous = Number(await AsyncStorage.getItem(ackKey) ?? '-1');
- const acknowledged = Math.max(previous, packet.sequence);
- await AsyncStorage.setItem(ackKey, String(acknowledged));
- if (isActive) return;
-
- const bytes = file!.bytesSync();
- let offset = 0;
- let finalSequence = -1;
- while (offset + HEADER_BYTES <= bytes.length) {
- const view = new DataView(bytes.buffer, bytes.byteOffset + offset, HEADER_BYTES);
- const sequence = view.getUint32(0, false);
- const length = view.getUint32(12, false);
- const end = offset + HEADER_BYTES + length;
- if (end > bytes.length) break;
- finalSequence = sequence;
- offset = end;
- }
- if (finalSequence >= 0 && acknowledged >= finalSequence) {
- file!.delete();
- await AsyncStorage.removeItem(ackKey);
- }
- }
-
- async acknowledge(packet: SpoolPacket): Promise {
- const previous = this.acknowledgmentChains.get(packet.fileName) ?? Promise.resolve();
- const operation = previous
- // A transient storage failure must be reported to its own caller, but it
- // must not permanently poison retirement for every later ACK in the file.
- .catch(() => undefined)
- .then(() => this.acknowledgeInOrder(packet));
- this.acknowledgmentChains.set(packet.fileName, operation);
- try {
- await operation;
- } finally {
- if (this.acknowledgmentChains.get(packet.fileName) === operation) {
- this.acknowledgmentChains.delete(packet.fileName);
- }
- }
- }
-
- close(): void {
- this.closeActive();
- }
-}
-
-export const durableAudioSpool = new DurableAudioSpool();
diff --git a/app/src/services/phoneAudioDiagnostics.ts b/app/src/services/phoneAudioDiagnostics.ts
new file mode 100644
index 000000000..7aae0fd12
--- /dev/null
+++ b/app/src/services/phoneAudioDiagnostics.ts
@@ -0,0 +1,180 @@
+import type { VoiceCapabilities } from '../protocol/audioCapabilities';
+import type { NativeCaptureDiagnostic } from '../../modules/chronicle-duplex-audio';
+import { logError, logInfo, logWarn } from '@/utils/logger';
+
+type DiagnosticLevel = 'info' | 'warn' | 'error';
+type Clock = () => number;
+
+interface NativeFrameDiagnostic {
+ captureEpoch: number;
+ opusBytes: number;
+ audioLevel: number;
+}
+
+type CapabilityDiagnostic = Pick<
+ VoiceCapabilities,
+ 'mode' | 'input_route' | 'output_route' | 'native_sample_rate'
+>;
+
+const shortId = (value: string): string => value ? `${value.slice(0, 8)}…` : 'none';
+const safeLevel = (value: number): number => Math.min(1, Math.max(0, value || 0));
+const redactSecrets = (value: string): string => value
+ .replace(/([?&](?:token|access_token)=)[^&\s]+/gi, '$1')
+ .replace(/Bearer\s+[^\s]+/gi, 'Bearer ');
+
+export class PhoneAudioDiagnostics {
+ private attempt = 0;
+ private active = false;
+ private startedAtMs = 0;
+ private milestones = new Set();
+ private nativeFrames = 0;
+ private sentFrames = 0;
+ private ackedPackets = 0;
+ private lastAudioLevel = 0;
+
+ constructor(private readonly now: Clock = Date.now) {}
+
+ private write(level: DiagnosticLevel, event: string, details = ''): void {
+ const message = `${event} attempt=${this.attempt}${details ? ` ${details}` : ''}`;
+ if (level === 'error') logError('PhoneAudio', message);
+ else if (level === 'warn') logWarn('PhoneAudio', message);
+ else logInfo('PhoneAudio', message);
+ }
+
+ private once(level: DiagnosticLevel, event: string, details = ''): void {
+ if (this.milestones.has(event)) return;
+ this.milestones.add(event);
+ this.write(level, event, details);
+ }
+
+ beginAttempt(): void {
+ this.attempt += 1;
+ this.active = true;
+ this.startedAtMs = this.now();
+ this.milestones.clear();
+ this.nativeFrames = 0;
+ this.sentFrames = 0;
+ this.ackedPackets = 0;
+ this.lastAudioLevel = 0;
+ this.write('info', 'button_pressed');
+ }
+
+ listenerInstalled(captureEpoch: number): void {
+ this.once('info', 'native_listener_installed', `capture_epoch=${captureEpoch}`);
+ }
+
+ engineStarted(captureEpoch: number, capabilities: CapabilityDiagnostic): void {
+ this.once(
+ 'info',
+ 'native_engine_started',
+ [
+ `capture_epoch=${captureEpoch}`,
+ `mode=${capabilities.mode}`,
+ `input=${capabilities.input_route}`,
+ `output=${capabilities.output_route}`,
+ `sample_rate=${capabilities.native_sample_rate}`,
+ ].join(' '),
+ );
+ }
+
+ nativeFrame(frame: NativeFrameDiagnostic): void {
+ if (!this.active) return;
+ this.nativeFrames += 1;
+ this.lastAudioLevel = safeLevel(frame.audioLevel);
+ this.once(
+ 'info',
+ 'native_first_frame',
+ `capture_epoch=${frame.captureEpoch} opus_bytes=${frame.opusBytes} audio_level=${this.lastAudioLevel.toFixed(3)}`,
+ );
+ }
+
+ nativeStage(event: NativeCaptureDiagnostic): void {
+ if (!this.active) return;
+ const detail = redactSecrets(event.detail ?? '').slice(0, 240);
+ this.once(
+ event.stage.endsWith('_failed') ? 'error' : 'info',
+ `native_${event.stage}`,
+ [
+ `capture_epoch=${event.captureEpoch}`,
+ event.frameCount === undefined ? '' : `frames=${event.frameCount}`,
+ event.byteCount === undefined ? '' : `bytes=${event.byteCount}`,
+ detail ? `detail=${detail}` : '',
+ ].filter(Boolean).join(' '),
+ );
+ }
+
+ audioLevelActive(audioLevel: number): void {
+ this.once('info', 'audio_level_active', `audio_level=${safeLevel(audioLevel).toFixed(3)}`);
+ }
+
+ invalidNativeFrame(reason: string): void {
+ this.once('warn', 'native_frame_rejected', `reason=${reason}`);
+ }
+
+ socketConnecting(): void {
+ this.once('info', 'websocket_connecting');
+ }
+
+ socketStage(stage: string, detail?: string): void {
+ if (!this.active) return;
+ const safeDetail = redactSecrets(detail ?? '').replace(/[\r\n]+/g, ' ').slice(0, 180);
+ this.once(
+ stage.endsWith('error') || stage.endsWith('failed') ? 'warn' : 'info',
+ `websocket_${stage}`,
+ safeDetail ? `detail=${safeDetail}` : '',
+ );
+ }
+
+ socketOpen(): void {
+ this.once('info', 'websocket_open');
+ }
+
+ socketClosed(expected: boolean): void {
+ if (!this.active) return;
+ this.write(expected ? 'info' : 'warn', 'websocket_closed', `expected=${expected}`);
+ }
+
+ captureStarted(captureSessionId: string): void {
+ this.once('info', 'backend_capture_started', `capture_id=${shortId(captureSessionId)}`);
+ }
+
+ frameSent(opusBytes: number): void {
+ if (!this.active) return;
+ this.sentFrames += 1;
+ this.once('info', 'first_frame_sent', `opus_bytes=${opusBytes}`);
+ }
+
+ packetAccepted(sequence: number): void {
+ if (!this.active || !this.milestones.has('backend_capture_started')) return;
+ this.ackedPackets += 1;
+ this.once('info', 'first_packet_accepted', `sequence=${sequence}`);
+ }
+
+ timeout(reason: string): void {
+ if (!this.active) return;
+ this.write('warn', reason, this.snapshot());
+ }
+
+ failure(stage: string, cause: unknown): void {
+ const message = redactSecrets(cause instanceof Error ? cause.message : String(cause));
+ this.write('error', 'failed', `stage=${stage} error=${message.slice(0, 300)} ${this.snapshot()}`);
+ }
+
+ stopped(reason = 'user'): void {
+ if (!this.active) return;
+ this.write('info', 'stopped', `reason=${reason} ${this.snapshot()}`);
+ this.active = false;
+ }
+
+ private snapshot(): string {
+ return [
+ `elapsed_ms=${Math.max(0, this.now() - this.startedAtMs)}`,
+ `native_frames=${this.nativeFrames}`,
+ `sent_frames=${this.sentFrames}`,
+ `acked_packets=${this.ackedPackets}`,
+ `last_audio_level=${this.lastAudioLevel.toFixed(3)}`,
+ ].join(' ');
+ }
+}
+
+export const phoneAudioDiagnostics = new PhoneAudioDiagnostics();
diff --git a/app/src/services/phoneAudioSelfTest.ts b/app/src/services/phoneAudioSelfTest.ts
new file mode 100644
index 000000000..59735cb6c
--- /dev/null
+++ b/app/src/services/phoneAudioSelfTest.ts
@@ -0,0 +1,498 @@
+import { Platform } from 'react-native';
+// @ts-ignore - no type declarations available
+import base64 from 'react-native-base64';
+
+import {
+ addCaptureDiagnosticListener,
+ addOpusFrameListener,
+ addRouteChangeListener,
+ getVoiceSessionDiagnostics,
+ startVoiceSession,
+ stopVoiceSession,
+ type NativeCaptureDiagnostic,
+ type NativeOpusFrame,
+ type NativeRouteChange,
+ type NativeStopResult,
+ type NativeVoiceSessionDiagnostics,
+ type StartVoiceSessionOptions,
+} from '../../modules/chronicle-duplex-audio';
+import {
+ DataPurpose,
+ DeliveryClass,
+ DeviceKind,
+ ProcessingProfile,
+} from '../protocol/audioV2';
+import {
+ AudioV2Socket,
+ type AudioV2SocketDiagnostic,
+ type AudioV2SocketOptions,
+ type CapturePacket,
+ type StartCaptureOptions,
+} from '../protocol/audioV2Socket';
+import { logError, logInfo, logWarn } from '@/utils/logger';
+
+type DiagnosticProfile = NonNullable;
+type DiagnosticStatus = 'pass' | 'fail' | 'skipped';
+
+const PROFILES: DiagnosticProfile[] = [
+ 'production',
+ 'voice_processing_hold',
+ 'plain_capture_hold',
+ 'system_tap_format_hold',
+];
+const PROBE_DURATION_MS = 2_250;
+const PROBE_SETTLE_MS = 250;
+const NETWORK_PACKET_COUNT = 25;
+const NETWORK_TIMEOUT_MS = 10_000;
+const SYNTHETIC_OPUS_SILENCE = new Uint8Array([0xf8, 0xff, 0xfe]);
+
+interface Subscription {
+ remove(): void;
+}
+
+interface DiagnosticSocket {
+ connect(): Promise;
+ beginCapture(options: StartCaptureOptions): Promise<{
+ captureSessionId?: { value?: string };
+ }>;
+ sendPacket(packet: CapturePacket): void;
+ stopCapture(): Promise;
+ close(): void;
+}
+
+export interface PhoneAudioDiagnosticDependencies {
+ now(): number;
+ sleep(milliseconds: number): Promise;
+ addOpusFrameListener(listener: (event: NativeOpusFrame) => void): Subscription;
+ addCaptureDiagnosticListener(listener: (event: NativeCaptureDiagnostic) => void): Subscription;
+ addRouteChangeListener(listener: (event: NativeRouteChange) => void): Subscription;
+ startVoiceSession(options: StartVoiceSessionOptions): Promise;
+ getVoiceSessionDiagnostics(): Promise;
+ stopVoiceSession(): Promise;
+ createSocket(options: AudioV2SocketOptions): DiagnosticSocket;
+}
+
+type VoiceCapabilitiesResult = Awaited>;
+
+export interface PhoneAudioDiagnosticProgress {
+ phase: 'native' | 'network' | 'complete';
+ label: string;
+ current: number;
+ total: number;
+}
+
+export interface NativeProbeResult {
+ profile: DiagnosticProfile;
+ status: Exclude;
+ elapsedMs: number;
+ frameCount: number;
+ failure: string | null;
+ snapshot: NativeVoiceSessionDiagnostics | null;
+}
+
+export interface NetworkProbeResult {
+ status: DiagnosticStatus;
+ elapsedMs: number;
+ phase: string;
+ payloadSource: 'native_mic' | 'synthetic_silence' | 'none';
+ packetsSent: number;
+ packetsAcked: number;
+ captureSessionId: string | null;
+ failure: string | null;
+}
+
+export interface PhoneAudioDiagnosticRunResult {
+ runId: string;
+ status: 'pass' | 'partial' | 'fail';
+ elapsedMs: number;
+ nativeProbes: NativeProbeResult[];
+ networkProbe: NetworkProbeResult;
+}
+
+export interface PhoneAudioDiagnosticRunOptions {
+ backendUrl: string;
+ jwtToken: string | null;
+ onProgress?: (progress: PhoneAudioDiagnosticProgress) => void;
+}
+
+interface CapturedPacket {
+ capturedAtMs: number;
+ monotonicTimestampMs: number;
+ opus: Uint8Array;
+}
+
+const defaultDependencies: PhoneAudioDiagnosticDependencies = {
+ now: Date.now,
+ sleep: milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
+ addOpusFrameListener,
+ addCaptureDiagnosticListener,
+ addRouteChangeListener,
+ startVoiceSession,
+ getVoiceSessionDiagnostics,
+ stopVoiceSession,
+ createSocket: options => new AudioV2Socket(options),
+};
+
+function safeText(value: unknown, maximum = 300): string {
+ return String(value)
+ .replace(/([?&](?:token|access_token)=)[^&\s]+/gi, '$1')
+ .replace(/Bearer\s+[^\s]+/gi, 'Bearer ')
+ .replace(/[\r\n]+/g, ' ')
+ .slice(0, maximum);
+}
+
+function decodeBase64(value: string): Uint8Array {
+ const binary = base64.decode(value);
+ const bytes = new Uint8Array(binary.length);
+ for (let index = 0; index < binary.length; index += 1) {
+ bytes[index] = binary.charCodeAt(index);
+ }
+ return bytes;
+}
+
+function write(
+ runId: string,
+ level: 'info' | 'warn' | 'error',
+ event: string,
+ details = '',
+): void {
+ const message = `run_id=${runId} event=${event}${details ? ` ${details}` : ''}`;
+ if (level === 'error') logError('PhoneAudioSelfTest', message);
+ else if (level === 'warn') logWarn('PhoneAudioSelfTest', message);
+ else logInfo('PhoneAudioSelfTest', message);
+}
+
+async function withTimeout(
+ operation: Promise,
+ milliseconds: number,
+ label: string,
+): Promise {
+ let timeout: ReturnType | null = null;
+ const deadline = new Promise((_, reject) => {
+ timeout = setTimeout(() => reject(new Error(`${label} timed out after ${milliseconds}ms`)), milliseconds);
+ });
+ try {
+ return await Promise.race([operation, deadline]);
+ } finally {
+ if (timeout) clearTimeout(timeout);
+ }
+}
+
+function backendSocketUrl(value: string): string {
+ const url = new URL(value.trim());
+ if (url.protocol === 'http:') url.protocol = 'ws:';
+ if (url.protocol === 'https:') url.protocol = 'wss:';
+ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
+ throw new Error('Chronicle backend URL must use HTTP(S) or WS(S)');
+ }
+ url.pathname = '/ws/audio';
+ url.search = '';
+ url.hash = '';
+ return url.toString();
+}
+
+function packetsForNetwork(nativePackets: CapturedPacket[], nowMs: number): {
+ source: NetworkProbeResult['payloadSource'];
+ packets: CapturePacket[];
+} {
+ const source = nativePackets.length ? 'native_mic' : 'synthetic_silence';
+ const firstMonotonic = nativePackets[0]?.monotonicTimestampMs ?? 0;
+ const packets = Array.from({ length: NETWORK_PACKET_COUNT }, (_, sequence) => {
+ const native = nativePackets[sequence % Math.max(1, nativePackets.length)];
+ return {
+ sequence,
+ capturedAtMs: nowMs + (sequence * 20),
+ monotonicOffsetUs: native
+ ? Math.max(0, Math.round((native.monotonicTimestampMs - firstMonotonic) * 1_000))
+ : sequence * 20_000,
+ opus: native?.opus ?? SYNTHETIC_OPUS_SILENCE,
+ };
+ });
+ return { source, packets };
+}
+
+async function runNetworkProbe(
+ runId: string,
+ options: PhoneAudioDiagnosticRunOptions,
+ nativePackets: CapturedPacket[],
+ dependencies: PhoneAudioDiagnosticDependencies,
+): Promise {
+ const startedAt = dependencies.now();
+ if (!options.backendUrl.trim() || !options.jwtToken) {
+ write(runId, 'warn', 'network_skipped', 'reason=backend_or_auth_not_configured');
+ return {
+ status: 'skipped',
+ elapsedMs: 0,
+ phase: 'configuration',
+ payloadSource: 'none',
+ packetsSent: 0,
+ packetsAcked: 0,
+ captureSessionId: null,
+ failure: 'Backend URL or authentication is not configured',
+ };
+ }
+
+ let phase = 'construct';
+ let packetsSent = 0;
+ let captureSessionId: string | null = null;
+ const acked = new Set();
+ let finishAcknowledgements: () => void = () => {};
+ const acknowledgements = new Promise(resolve => {
+ finishAcknowledgements = resolve;
+ });
+ let socket: DiagnosticSocket | null = null;
+ try {
+ const url = backendSocketUrl(options.backendUrl);
+ const payload = packetsForNetwork(nativePackets, dependencies.now());
+ write(
+ runId,
+ 'info',
+ 'network_started',
+ `endpoint=${new URL(url).host} payload_source=${payload.source} packet_target=${payload.packets.length}`,
+ );
+ socket = dependencies.createSocket({
+ url,
+ bearerToken: options.jwtToken,
+ sourceId: 'phone-audio-diagnostics',
+ displayName: 'phone-audio-diagnostics',
+ deviceKind: Platform.OS === 'ios' ? DeviceKind.IOS_PHONE : DeviceKind.ANDROID_PHONE,
+ uplinkFrameDurationMs: 20,
+ onPacketAccepted: sequence => {
+ acked.add(sequence);
+ if (acked.size >= payload.packets.length) finishAcknowledgements();
+ },
+ onDiagnostic: (event: AudioV2SocketDiagnostic) => {
+ phase = event.stage;
+ write(
+ runId,
+ event.stage.endsWith('error') || event.stage.endsWith('failed') ? 'warn' : 'info',
+ 'network_phase',
+ `phase=${event.stage}${event.detail ? ` detail=${safeText(event.detail, 180)}` : ''}`,
+ );
+ },
+ });
+
+ phase = 'connect';
+ await withTimeout(socket.connect(), NETWORK_TIMEOUT_MS, 'WebSocket hello');
+ phase = 'begin_capture';
+ const binding = await withTimeout(socket.beginCapture({
+ captureEpoch: 0,
+ processingProfile: ProcessingProfile.SOURCE_NATIVE,
+ dataPurpose: DataPurpose.ANNOTATION,
+ deliveryClass: DeliveryClass.RECOVERED,
+ recoveryBatchId: `phone-diag-${runId}`,
+ }), NETWORK_TIMEOUT_MS, 'backend capture start');
+ captureSessionId = binding.captureSessionId?.value ?? null;
+ if (!captureSessionId) throw new Error('backend returned no capture session ID');
+ write(runId, 'info', 'network_capture_bound', `capture_session_id=${captureSessionId}`);
+
+ phase = 'send_packets';
+ for (const packet of payload.packets) {
+ socket.sendPacket(packet);
+ packetsSent += 1;
+ }
+ await withTimeout(acknowledgements, NETWORK_TIMEOUT_MS, 'packet acknowledgements');
+ phase = 'stop_capture';
+ await withTimeout(socket.stopCapture(), NETWORK_TIMEOUT_MS, 'backend capture stop');
+
+ const result: NetworkProbeResult = {
+ status: acked.size === packetsSent ? 'pass' : 'fail',
+ elapsedMs: Math.max(0, dependencies.now() - startedAt),
+ phase: 'complete',
+ payloadSource: payload.source,
+ packetsSent,
+ packetsAcked: acked.size,
+ captureSessionId,
+ failure: null,
+ };
+ write(
+ runId,
+ result.status === 'pass' ? 'info' : 'error',
+ 'network_result',
+ `status=${result.status} capture_session_id=${captureSessionId} payload_source=${payload.source} packets_sent=${packetsSent} packets_acked=${acked.size} elapsed_ms=${result.elapsedMs}`,
+ );
+ return result;
+ } catch (cause) {
+ const failure = safeText(cause instanceof Error ? cause.message : cause);
+ const result: NetworkProbeResult = {
+ status: 'fail',
+ elapsedMs: Math.max(0, dependencies.now() - startedAt),
+ phase,
+ payloadSource: nativePackets.length ? 'native_mic' : 'synthetic_silence',
+ packetsSent,
+ packetsAcked: acked.size,
+ captureSessionId,
+ failure,
+ };
+ write(
+ runId,
+ 'error',
+ 'network_result',
+ `status=fail phase=${phase} capture_session_id=${captureSessionId ?? 'none'} packets_sent=${packetsSent} packets_acked=${acked.size} error=${failure}`,
+ );
+ return result;
+ } finally {
+ socket?.close();
+ }
+}
+
+export async function runPhoneAudioDiagnosticSuite(
+ options: PhoneAudioDiagnosticRunOptions,
+ dependencies: PhoneAudioDiagnosticDependencies = defaultDependencies,
+): Promise {
+ const suiteStartedAt = dependencies.now();
+ const runId = `${Math.floor(suiteStartedAt).toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
+ const baseEpoch = Math.max(1, Math.floor(suiteStartedAt % 2_000_000_000));
+ const nativeProbes: NativeProbeResult[] = [];
+ let activeEpoch = -1;
+ let activePackets: CapturedPacket[] = [];
+ let bestPackets: CapturedPacket[] = [];
+
+ write(
+ runId,
+ 'info',
+ 'suite_started',
+ `platform=${Platform.OS} native_profiles=${PROFILES.join(',')} probe_duration_ms=${PROBE_DURATION_MS} network_packets=${NETWORK_PACKET_COUNT}`,
+ );
+
+ const frameSubscription = dependencies.addOpusFrameListener(frame => {
+ if (frame.captureEpoch !== activeEpoch || activePackets.length >= NETWORK_PACKET_COUNT) return;
+ try {
+ const opus = decodeBase64(frame.opusBase64);
+ if (!opus.length) return;
+ activePackets.push({
+ capturedAtMs: frame.capturedAtMs,
+ monotonicTimestampMs: frame.monotonicTimestampMs,
+ opus,
+ });
+ } catch (cause) {
+ write(runId, 'warn', 'native_frame_decode_failed', `error=${safeText(cause)}`);
+ }
+ });
+ const nativeSubscription = dependencies.addCaptureDiagnosticListener(event => {
+ if (event.captureEpoch !== activeEpoch) return;
+ write(
+ runId,
+ event.stage.endsWith('_failed') ? 'warn' : 'info',
+ 'native_stage',
+ [
+ `profile=${PROFILES[nativeProbes.length] ?? 'unknown'}`,
+ `stage=${event.stage}`,
+ event.frameCount === undefined ? '' : `frames=${event.frameCount}`,
+ event.byteCount === undefined ? '' : `bytes=${event.byteCount}`,
+ event.detail ? `detail=${safeText(event.detail, 220)}` : '',
+ ].filter(Boolean).join(' '),
+ );
+ });
+ const routeSubscription = dependencies.addRouteChangeListener(event => {
+ if (event.captureEpoch !== activeEpoch) return;
+ write(
+ runId,
+ 'info',
+ 'native_route_event',
+ `profile=${PROFILES[nativeProbes.length] ?? 'unknown'} reason=${event.reason} input=${event.capabilities.input_route} output=${event.capabilities.output_route} mode=${event.capabilities.mode}`,
+ );
+ });
+
+ try {
+ for (const [index, profile] of PROFILES.entries()) {
+ options.onProgress?.({
+ phase: 'native',
+ label: `Testing ${profile.replace(/_/g, ' ')}`,
+ current: index + 1,
+ total: PROFILES.length,
+ });
+ activeEpoch = baseEpoch + index;
+ activePackets = [];
+ const probeStartedAt = dependencies.now();
+ let snapshot: NativeVoiceSessionDiagnostics | null = null;
+ let failure: string | null = null;
+ let capabilities: VoiceCapabilitiesResult | null = null;
+ write(runId, 'info', 'native_probe_started', `profile=${profile} capture_epoch=${activeEpoch}`);
+ try {
+ capabilities = await dependencies.startVoiceSession({
+ captureEpoch: activeEpoch,
+ diagnosticProfile: profile,
+ });
+ write(
+ runId,
+ 'info',
+ 'native_probe_capabilities',
+ `profile=${profile} mode=${capabilities.mode} input=${capabilities.input_route} output=${capabilities.output_route} sample_rate=${capabilities.native_sample_rate} aec=${capabilities.aec.enabled} noise_suppression=${capabilities.noise_suppression.enabled} fallback=${capabilities.fallback_reason ?? 'none'}`,
+ );
+ await dependencies.sleep(PROBE_DURATION_MS);
+ snapshot = await dependencies.getVoiceSessionDiagnostics();
+ } catch (cause) {
+ failure = safeText(cause instanceof Error ? cause.message : cause);
+ try {
+ snapshot = await dependencies.getVoiceSessionDiagnostics();
+ } catch {
+ // The start failure remains the useful signal.
+ }
+ } finally {
+ try {
+ const restoration = await dependencies.stopVoiceSession();
+ write(
+ runId,
+ restoration.restorationSucceeded ? 'info' : 'warn',
+ 'native_probe_cleanup',
+ `profile=${profile} restoration_succeeded=${restoration.restorationSucceeded} failure_code=${restoration.failureCode ?? 'none'}`,
+ );
+ } catch (cause) {
+ write(runId, 'warn', 'native_probe_cleanup', `profile=${profile} error=${safeText(cause)}`);
+ }
+ }
+
+ const frameCount = Math.max(activePackets.length, snapshot?.opusPacketCount ?? 0);
+ if (!failure && frameCount === 0) failure = 'no_opus_frames';
+ const result: NativeProbeResult = {
+ profile,
+ status: failure ? 'fail' : 'pass',
+ elapsedMs: Math.max(0, dependencies.now() - probeStartedAt),
+ frameCount,
+ failure,
+ snapshot,
+ };
+ nativeProbes.push(result);
+ if (result.status === 'pass' && activePackets.length > bestPackets.length) {
+ bestPackets = [...activePackets];
+ }
+ write(
+ runId,
+ result.status === 'pass' ? 'info' : 'warn',
+ 'native_probe_result',
+ `profile=${profile} status=${result.status} elapsed_ms=${result.elapsedMs} js_frames=${activePackets.length} failure=${failure ?? 'none'} snapshot=${JSON.stringify(snapshot)}`,
+ );
+ await dependencies.sleep(PROBE_SETTLE_MS);
+ }
+ } finally {
+ activeEpoch = -1;
+ frameSubscription.remove();
+ nativeSubscription.remove();
+ routeSubscription.remove();
+ }
+
+ options.onProgress?.({ phase: 'network', label: 'Testing Chronicle backend', current: 1, total: 1 });
+ const networkProbe = await runNetworkProbe(runId, options, bestPackets, dependencies);
+ const nativePassCount = nativeProbes.filter(probe => probe.status === 'pass').length;
+ const status: PhoneAudioDiagnosticRunResult['status'] = nativePassCount > 0 && networkProbe.status === 'pass'
+ ? 'pass'
+ : nativePassCount === 0 && networkProbe.status === 'fail'
+ ? 'fail'
+ : 'partial';
+ const result: PhoneAudioDiagnosticRunResult = {
+ runId,
+ status,
+ elapsedMs: Math.max(0, dependencies.now() - suiteStartedAt),
+ nativeProbes,
+ networkProbe,
+ };
+ write(
+ runId,
+ status === 'pass' ? 'info' : status === 'partial' ? 'warn' : 'error',
+ 'suite_complete',
+ `status=${status} native_passed=${nativePassCount}/${nativeProbes.length} network_status=${networkProbe.status} elapsed_ms=${result.elapsedMs}`,
+ );
+ options.onProgress?.({ phase: 'complete', label: 'Diagnostic run complete', current: 1, total: 1 });
+ return result;
+}
diff --git a/app/src/services/pushNotifications.ts b/app/src/services/pushNotifications.ts
index c35448e38..845f900eb 100644
--- a/app/src/services/pushNotifications.ts
+++ b/app/src/services/pushNotifications.ts
@@ -45,9 +45,12 @@ export const configureNotificationPresentation = async (): Promise => {
}
};
-const registerToken = async (backendUrl: string): Promise => {
+const registerToken = async (
+ backendUrl: string,
+ devicePushToken?: Notifications.DevicePushToken,
+): Promise => {
if (!projectId) throw new Error('This build has no EAS project ID.');
- const token = await Notifications.getExpoPushTokenAsync({ projectId });
+ const token = await Notifications.getExpoPushTokenAsync({ projectId, devicePushToken });
const installationId = await getOrCreateInstallationId();
const response = await fetchAuthed(
`${deriveBaseUrl(backendUrl)}/api/notifications/devices/${encodeURIComponent(installationId)}`,
@@ -123,8 +126,8 @@ export const startNotificationTapHandling = async (): Promise<() => void> => {
/** Native-token rotation means the Expo token must be fetched and registered again. */
export const listenForPushTokenChanges = (backendUrl: string): (() => void) => {
if (Platform.OS === 'web') return () => {};
- const subscription = Notifications.addPushTokenListener(() => {
- void refreshPushRegistration(backendUrl).catch(error => {
+ const subscription = Notifications.addPushTokenListener(devicePushToken => {
+ void registerToken(backendUrl, devicePushToken).catch(error => {
console.warn('[Notifications] token refresh failed:', error);
});
});
diff --git a/app/src/utils/logger.ts b/app/src/utils/logger.ts
index 9f3808dc2..43a9efa2d 100644
--- a/app/src/utils/logger.ts
+++ b/app/src/utils/logger.ts
@@ -1,4 +1,5 @@
import * as FileSystem from 'expo-file-system/legacy';
+import * as Application from 'expo-application';
import * as Updates from 'expo-updates';
import { Platform } from 'react-native';
import Constants from 'expo-constants';
@@ -139,9 +140,13 @@ export async function initLogger(): Promise {
`sessionId=${sessionId}`,
`time=${ts()}`,
`platform=${Platform.OS} ${Platform.Version}`,
- `appVersion=${Constants.expoConfig?.version ?? 'unknown'}`,
- `nativeAppVersion=${(Constants as any).nativeAppVersion ?? 'unknown'}`,
- `nativeBuildVersion=${(Constants as any).nativeBuildVersion ?? 'unknown'}`,
+ `appVersion=${Application.nativeApplicationVersion ?? 'unknown'}`,
+ `nativeAppVersion=${Application.nativeApplicationVersion ?? 'unknown'}`,
+ `nativeBuildVersion=${Application.nativeBuildVersion ?? 'unknown'}`,
+ `applicationId=${Application.applicationId ?? 'unknown'}`,
+ `applicationName=${Application.applicationName ?? 'unknown'}`,
+ `configuredAppVersion=${Constants.expoConfig?.version ?? 'unknown'}`,
+ `executionEnvironment=${Constants.executionEnvironment ?? 'unknown'}`,
`updates: ${describeUpdatesState()}`,
'=====================================================',
'',
diff --git a/backend/src/backend/controllers/audio_v2_controller.py b/backend/src/backend/controllers/audio_v2_controller.py
index aa20cf9c5..10f4b120e 100644
--- a/backend/src/backend/controllers/audio_v2_controller.py
+++ b/backend/src/backend/controllers/audio_v2_controller.py
@@ -466,7 +466,6 @@ async def handle_audio_v2_websocket(websocket: WebSocket) -> None:
},
provenance=provenance,
)
- active_binding = binding
binding = audio_pb2.CaptureBinding(
capture_session_id=audio_pb2.CaptureSessionId(
value=client_state.stream_session_id
@@ -476,6 +475,7 @@ async def handle_audio_v2_websocket(websocket: WebSocket) -> None:
),
capture_epoch=client_state.capture_epoch,
)
+ active_binding = binding
v2_streams = await AudioV2Streams.open(
producer.redis_client,
event=audio_pb2.CaptureStreamEvent(
diff --git a/backend/tests/test_audio_v2_websocket_entrypoint.py b/backend/tests/test_audio_v2_websocket_entrypoint.py
new file mode 100644
index 000000000..9d496f369
--- /dev/null
+++ b/backend/tests/test_audio_v2_websocket_entrypoint.py
@@ -0,0 +1,231 @@
+import asyncio
+from datetime import datetime, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from fakeredis import aioredis as fake_aioredis
+from google.protobuf import duration_pb2, json_format, timestamp_pb2
+from opuslib import Encoder
+
+from backend.audio_contract.v2 import audio_pb2
+from backend.audio_contract.v2.codec import (
+ serialize_client_control_json,
+ serialize_media_envelope,
+)
+from backend.controllers import audio_v2_controller
+from backend.routers.modules.websocket_routes import audio_v2_endpoint
+from backend.services.audio_stream.v2_streams import parse_stream_event
+
+pytestmark = pytest.mark.unit
+
+
+def _timestamp() -> timestamp_pb2.Timestamp:
+ value = timestamp_pb2.Timestamp()
+ value.FromDatetime(datetime(2026, 9, 8, 18, 27, 29, tzinfo=timezone.utc))
+ return value
+
+
+def _control(**event) -> str:
+ return serialize_client_control_json(
+ audio_pb2.ClientControl(
+ event_id=audio_pb2.EventId(value=f"event-{next(_EVENT_IDS)}"),
+ sent_at=_timestamp(),
+ **event,
+ )
+ )
+
+
+def _binding() -> audio_pb2.CaptureBinding:
+ return audio_pb2.CaptureBinding(
+ capture_session_id=audio_pb2.CaptureSessionId(value="capture-1"),
+ voice_session_id=audio_pb2.VoiceSessionId(value="voice-1"),
+ capture_epoch=1,
+ )
+
+
+def _parse_server_control(raw: str) -> audio_pb2.ServerControl:
+ control = audio_pb2.ServerControl()
+ json_format.Parse(raw, control)
+ return control
+
+
+_EVENT_IDS = iter(range(1, 20))
+
+
+class PhoneWebSocket:
+ def __init__(self, messages):
+ self.scope = {"subprotocols": ["chronicle.audio.v2"]}
+ self._messages = iter(messages)
+ self.accepted_subprotocol = None
+ self.sent_text = []
+ self.closed = None
+
+ async def accept(self, *, subprotocol):
+ self.accepted_subprotocol = subprotocol
+
+ async def receive_text(self):
+ return _control(
+ hello=audio_pb2.ClientHello(
+ bearer_token="phone-token",
+ source_id=audio_pb2.CaptureSourceId(value="a421c9-phone"),
+ device_kind=audio_pb2.DEVICE_KIND_IOS_PHONE,
+ display_name="phone",
+ )
+ )
+
+ async def receive(self):
+ return next(self._messages, {"type": "websocket.disconnect"})
+
+ async def send_text(self, value):
+ self.sent_text.append(value)
+
+ async def send_bytes(self, _value):
+ raise AssertionError("the capture path must not send binary downlink")
+
+ async def close(self, *, code, reason):
+ self.closed = (code, reason)
+
+
+@pytest.mark.asyncio
+async def test_registered_audio_websocket_accepts_phone_frame_and_stops(monkeypatch):
+ """The server-testable phone path must survive hello through durable ingress."""
+
+ audio_spec = audio_pb2.AudioSpec(
+ codec=audio_pb2.AUDIO_CODEC_OPUS,
+ sample_rate_hz=16_000,
+ channel_count=1,
+ frame_duration=duration_pb2.Duration(nanos=20_000_000),
+ bitrate_bps=24_000,
+ )
+ start = _control(
+ start_capture=audio_pb2.StartCapture(
+ capture_epoch=1,
+ processing_profile=audio_pb2.PROCESSING_PROFILE_DUPLEX_AEC,
+ data_purpose=audio_pb2.DATA_PURPOSE_NORMAL_CAPTURE,
+ delivery_class=audio_pb2.DELIVERY_CLASS_LIVE,
+ audio_spec=audio_spec,
+ capabilities=audio_pb2.CaptureCapabilities(
+ duplex_mode=audio_pb2.DUPLEX_MODE_FULL,
+ input_route=audio_pb2.INPUT_ROUTE_BUILT_IN_MIC,
+ output_route=audio_pb2.OUTPUT_ROUTE_SPEAKERPHONE,
+ native_sample_rate_hz=48_000,
+ acoustic_echo_cancellation=audio_pb2.EffectStatus(
+ requested=True, available=True, enabled=True
+ ),
+ noise_suppression=audio_pb2.EffectStatus(
+ requested=True, available=True, enabled=True
+ ),
+ ),
+ )
+ )
+ opus_silence = Encoder(16_000, 1, "audio").encode(bytes(640), 320)
+ media = serialize_media_envelope(
+ audio_pb2.MediaEnvelope(
+ capture=audio_pb2.CaptureMediaPacket(
+ binding=_binding(),
+ sequence=0,
+ captured_at=_timestamp(),
+ delivery_class=audio_pb2.DELIVERY_CLASS_LIVE,
+ opus_payload=opus_silence,
+ )
+ )
+ )
+ stop = _control(
+ stop_capture=audio_pb2.StopCapture(
+ binding=_binding(), reason=audio_pb2.STOP_REASON_USER_REQUESTED
+ )
+ )
+ websocket = PhoneWebSocket(
+ [
+ {"type": "websocket.receive", "text": start},
+ {"type": "websocket.receive", "bytes": media},
+ {"type": "websocket.receive", "text": stop},
+ ]
+ )
+
+ redis_client = fake_aioredis.FakeRedis()
+ state = SimpleNamespace(
+ stream_session_id=None,
+ voice_session_id=None,
+ capture_epoch=0,
+ data_purpose=None,
+ socket_id=None,
+ )
+ producer = SimpleNamespace(
+ redis_client=redis_client,
+ update_session_job_ids=AsyncMock(),
+ )
+
+ async def initialize_capture_session(**kwargs):
+ initialized_state = kwargs["client_state"]
+ initialized_state.stream_session_id = "capture-1"
+ initialized_state.voice_session_id = "voice-1"
+ initialized_state.capture_epoch = 1
+ initialized_state.data_purpose = "normal_capture"
+
+ async def finalize_capture_session(**kwargs):
+ kwargs["client_state"].stream_session_id = None
+
+ monkeypatch.setattr(
+ audio_v2_controller,
+ "websocket_auth",
+ AsyncMock(
+ return_value=(
+ SimpleNamespace(
+ id="69b80e5894aa9ec334a421c9",
+ user_id="69b80e5894aa9ec334a421c9",
+ email="phone@example.test",
+ ),
+ None,
+ )
+ ),
+ )
+ monkeypatch.setattr(
+ audio_v2_controller, "create_client_state", AsyncMock(return_value=state)
+ )
+ monkeypatch.setattr(
+ audio_v2_controller, "get_audio_stream_producer", lambda: producer
+ )
+ monkeypatch.setattr(
+ audio_v2_controller, "initialize_capture_session", initialize_capture_session
+ )
+ monkeypatch.setattr(
+ audio_v2_controller, "finalize_capture_session", finalize_capture_session
+ )
+ monkeypatch.setattr(
+ audio_v2_controller,
+ "start_streaming_jobs",
+ lambda **_kwargs: {
+ "speech_detection": "speech-job-1",
+ "audio_persistence": "persistence-job-1",
+ },
+ )
+ monkeypatch.setattr(
+ audio_v2_controller, "cleanup_client_state", AsyncMock(return_value=True)
+ )
+
+ await asyncio.wait_for(audio_v2_endpoint(websocket), timeout=2)
+
+ controls = [_parse_server_control(raw) for raw in websocket.sent_text]
+ assert [control.WhichOneof("event") for control in controls] == [
+ "hello",
+ "capture_started",
+ "capture_packet_accepted",
+ "capture_stopped",
+ ]
+ assert controls[1].capture_started.binding == _binding()
+ assert controls[2].capture_packet_accepted.sequence == 0
+ assert websocket.accepted_subprotocol == "chronicle.audio.v2"
+ assert websocket.closed is None
+
+ durable_entries = await redis_client.xrange("audio:v2:durable:capture-1")
+ durable_events = [
+ parse_stream_event(fields) for _entry_id, fields in durable_entries
+ ]
+ assert [event.WhichOneof("event") for event in durable_events] == [
+ "opened",
+ "frame",
+ "ended",
+ ]
+ assert durable_events[1].frame.pcm_s16le == bytes(640)
diff --git a/backend/tests/test_spool_ack_contract.py b/backend/tests/test_spool_ack_contract.py
deleted file mode 100644
index f2909a0ef..000000000
--- a/backend/tests/test_spool_ack_contract.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""The mobile spool's segment id is not the backend's audio session id.
-
-The phone spools every BLE packet to a file before offering it to the WebSocket, and
-that file has an id. It used to travel as ``durable_session_id`` and come back as
-``session_id`` — so a spool-file identity wore the name of the backend ``SessionId``,
-which is a different thing with a different lifetime (one WebSocket connection, minted
-for each authenticated audio-v2 connection as ``{client_id}-{uuid4}``).
-
-Nothing was mis-routed by it, because the receipt key is namespaced by user and client
-and the value is only ever echoed back. That is exactly why it is worth pinning: the
-names are the only thing keeping the two apart, and the wire is where they meet.
-
-These assert the shape both ends agree on, without a live socket.
-"""
-
-from pathlib import Path
-
-import pytest
-from google.protobuf.descriptor import FieldDescriptor
-
-from backend.audio_contract.v2 import audio_pb2
-
-SRC = Path(__file__).resolve().parents[1] / "src" / "backend"
-APP = Path(__file__).resolve().parents[2] / "app" / "src"
-
-AUDIO_STREAMER = APP / "hooks" / "useAudioStreamer.ts"
-SPOOL = APP / "services" / "durableAudioSpool.ts"
-
-
-def _code(path: Path) -> str:
- """File text with comment lines removed, so prose about the old names is ignored."""
-
- lines = []
- for line in path.read_text(encoding="utf-8").splitlines():
- stripped = line.strip()
- if stripped.startswith(("#", "//", "*", "/*")):
- continue
- lines.append(line)
- return "\n".join(lines)
-
-
-def test_v2_ack_is_bound_to_the_backend_capture_and_exact_packet_sequence():
- """The V2 receipt cannot confuse a local spool file with a capture session."""
-
- fields = audio_pb2.CapturePacketAccepted.DESCRIPTOR.fields_by_name
- assert fields["binding"].message_type.full_name.endswith("CaptureBinding")
- assert fields["sequence"].type == FieldDescriptor.TYPE_UINT64
- assert "spool_segment_id" not in fields
- assert "session_id" not in fields
-
-
-@pytest.mark.parametrize("path", [AUDIO_STREAMER, SPOOL])
-def test_the_app_uses_segment_naming_end_to_end(path):
- if not path.exists(): # pragma: no cover - app tree absent in some checkouts
- pytest.skip(f"{path} not present")
- code = _code(path)
-
- assert "durable_session_id" not in code
- assert "sessionId" not in code
-
-
-def test_the_app_sends_and_matches_the_same_wire_fields_the_backend_reads():
- if not AUDIO_STREAMER.exists(): # pragma: no cover
- pytest.skip("app tree not present")
- app_code = _code(AUDIO_STREAMER)
- # Spool identity stays local. The client maps a generated packet sequence back
- # to its pending file and retires it only on CapturePacketAccepted.
- assert "acceptedRef" in app_code
- assert "onPacketAccepted" in app_code
- assert "durableAudioSpool.acknowledge(packet)" in app_code
- assert "spool_segment_id" not in app_code
diff --git a/docs/backend/audio-interface-map.md b/docs/backend/audio-interface-map.md
index 69f860138..de7695021 100644
--- a/docs/backend/audio-interface-map.md
+++ b/docs/backend/audio-interface-map.md
@@ -12,9 +12,9 @@ test and a deployed trace both exist.
| IOS-CAPTURE | AVAudioEngine → app transport | PCM base64 map | native Opus `CaptureMediaPacket` | amber: source complete, native build pending | Expo typecheck; TestFlight/Xcode compile required |
| ANDROID-CAPTURE | AudioRecord → app transport | PCM base64 map | native Opus `CaptureMediaPacket` | amber: source complete, Gradle/device pending | MediaCodec Opus adapter; Expo typecheck |
| WEB-CAPTURE | Web Audio → backend | paired-header PCM | WebCodecs raw Opus packets | green source/build; browser E2E pending | `RecordingContext.test.tsx`; WebUI production build |
-| OMI-NEO | BLE → app/tray → backend | raw Opus via Wyoming | Opus packet adapter | green app + tray adapters; physical device pending | `AudioV2Socket`; shared Python `AudioV2Client` test |
+| OMI-NEO | BLE → app/tray → backend | raw Opus via Wyoming | declared 60 ms Opus normalized to canonical frames | amber: source/tests green; physical retest pending | real 60 ms Opus decode; app and shared-client duration tests |
| HAVPE | firmware → relay → backend | device-local JSONL/PCM → relay V2 adapter | generated V2 at backend boundary | green source; physical pending | PCM normalizer/raw-Opus round trip; typed button/playback adapter |
-| RECOVERY | phone spool → backend | inferred bare packets | typed recovered packets | green app + ingress + persistence | typed packet ACK; `test_audio_durability.py` |
+| RECOVERY | external recovery client → backend | inferred bare packets | typed recovered packets | backend supported; removed from Expo live path | `test_audio_durability.py` |
| DURABLE-REDIS | ingress → persistence | string fields/magic end marker | `CaptureStreamEvent` binary | green V2 producer + persistence consumer | `test_audio_v2_streams.py`, `test_audio_durability.py` |
| REALTIME-REDIS | ingress → ASR/wake/turns | wildcard string-field streams | typed `CanonicalPcmFrame` | green producer + all three consumers | backend streaming tests; wakeword consumer tests |
| MONGO-AUDIO | persistence → claims | fixed Opus chunks | retained; stricter domain types | green deployed | live completed session + 1 canonical 1.1 s chunk |
@@ -30,7 +30,8 @@ test and a deployed trace both exist.
## Fixed invariants
-- Live uplink is 16 kHz mono raw Opus in 20 ms packets.
+- Live uplink is declared 16 kHz mono raw Opus: 20 ms from phone/web/HAVPE or
+ 60 ms from OMI/Neo. Ingress normalizes both to canonical 20 ms PCM frames.
- Live downlink is 24 kHz mono raw Opus in 20 ms packets.
- PCM S16LE is internal only.
- Recovered packets never enter a live wake, turn, or action path.
@@ -40,6 +41,28 @@ test and a deployed trace both exist.
## Worklog
+### 2026-09-08
+
+- Removed the Expo durable spool/recovery state machine after a physical iPhone
+ trace caught recovery and user-stop competing for the same `captureStopped`
+ waiter. One button press now owns one live capture and one stop; frames flow
+ directly through that binding, with captured/sent/accepted counts retained in
+ the exported diagnostics. This deliberately drops offline/reconnect replay from
+ the app instead of layering another lifecycle guard around it.
+- Collapsed mobile capture to one explicit three-operation interface: start a named
+ source, enqueue a source-tagged frame, and stop. Removed URL-carried credentials,
+ optional phone/wearable inference, duplicate URL builders, caller-side socket
+ readiness checks, and the second reconnect state machine.
+- Made source packet duration part of the socket interface. Phone declares 20 ms;
+ OMI/Neo declares its native 60 ms packets; HAVPE remains explicit 20 ms. The
+ backend decodes one declared packet and publishes one or three contiguous
+ canonical 20 ms frames.
+- The app refuses non-Opus wearable capture before installing the BLE listener.
+- Protocol rejection now finalizes the technical stream with `failure` and status
+ `failed`; the Mongo persistence worker preserves that result instead of replacing
+ it with `complete`. Verification is source-level only until backend deployment and
+ a physical Neo trace prove Redis and Mongo persistence.
+
### 2026-08-29
- Added the Protobuf v2 source and generated Python/TypeScript bindings.
@@ -239,7 +262,7 @@ test and a deployed trace both exist.
## Remaining verification gates
-- Record one physical iPhone capture/recovery/playback trace and one physical
+- Record one physical iPhone capture/playback trace and one physical
OMI/Neo or HAVPE capture trace through ingress, Redis, Mongo, inference, and action.
- Restore or replace the exhausted OpenRouter allowance, then verify short summary,
detailed summary, and memory extraction on one of the E2E-created Conversations.
diff --git a/extras/chronicle-client/chronicle_client/audio_v2.py b/extras/chronicle-client/chronicle_client/audio_v2.py
index e7b5e0a2e..069bc54b0 100644
--- a/extras/chronicle-client/chronicle_client/audio_v2.py
+++ b/extras/chronicle-client/chronicle_client/audio_v2.py
@@ -21,6 +21,16 @@ def _timestamp(epoch_seconds: float) -> timestamp_pb2.Timestamp:
return value
+def _opus_spec(sample_rate_hz: int, frame_duration_ms: int) -> audio_pb2.AudioSpec:
+ return audio_pb2.AudioSpec(
+ codec=audio_pb2.AUDIO_CODEC_OPUS,
+ sample_rate_hz=sample_rate_hz,
+ channel_count=1,
+ frame_duration=duration_pb2.Duration(nanos=frame_duration_ms * 1_000_000),
+ bitrate_bps=24_000,
+ )
+
+
class AudioV2Client:
"""Own connection, capture binding, sequencing, and generated wire encoding."""
@@ -32,15 +42,19 @@ def __init__(
source_id: str,
display_name: str,
device_kind: int,
+ uplink_frame_duration_ms: int,
ssl_context=None,
on_control: ControlHandler | None = None,
on_playback: PlaybackHandler | None = None,
) -> None:
+ if uplink_frame_duration_ms not in {20, 60}:
+ raise ValueError("uplink_frame_duration_ms must be 20 or 60")
self.websocket_url = websocket_url
self.bearer_token = bearer_token
self.source_id = source_id
self.display_name = display_name
self.device_kind = device_kind
+ self.uplink_frame_duration_ms = uplink_frame_duration_ms
self.ssl_context = ssl_context
self.on_control = on_control
self.on_playback = on_playback
@@ -53,16 +67,6 @@ def __init__(
self._started_monotonic = 0.0
self._send_lock = asyncio.Lock()
- @staticmethod
- def opus_spec(sample_rate_hz: int) -> audio_pb2.AudioSpec:
- return audio_pb2.AudioSpec(
- codec=audio_pb2.AUDIO_CODEC_OPUS,
- sample_rate_hz=sample_rate_hz,
- channel_count=1,
- frame_duration=duration_pb2.Duration(nanos=20_000_000),
- bitrate_bps=24_000,
- )
-
async def connect(self) -> None:
connect_options = dict(
subprotocols=["chronicle.audio.v2"],
@@ -81,8 +85,8 @@ async def connect(self) -> None:
source_id=audio_pb2.CaptureSourceId(value=self.source_id),
device_kind=self.device_kind,
display_name=self.display_name,
- supported_uplink=[self.opus_spec(16_000)],
- supported_downlink=[self.opus_spec(24_000)],
+ supported_uplink=[_opus_spec(16_000, self.uplink_frame_duration_ms)],
+ supported_downlink=[_opus_spec(24_000, 20)],
)
)
await hello
@@ -104,7 +108,7 @@ async def start_capture(
processing_profile=processing_profile,
data_purpose=data_purpose,
delivery_class=delivery_class,
- audio_spec=self.opus_spec(16_000),
+ audio_spec=_opus_spec(16_000, self.uplink_frame_duration_ms),
capabilities=capabilities,
recovery_batch_id=recovery_batch_id,
)
diff --git a/extras/chronicle-client/tests/test_audio_v2.py b/extras/chronicle-client/tests/test_audio_v2.py
index 501e329b2..ebbdfb449 100644
--- a/extras/chronicle-client/tests/test_audio_v2.py
+++ b/extras/chronicle-client/tests/test_audio_v2.py
@@ -43,6 +43,18 @@ def _control(**event):
)
+async def test_client_requires_an_explicit_supported_uplink_duration():
+ with pytest.raises(ValueError, match="20 or 60"):
+ AudioV2Client(
+ websocket_url="wss://chronicle/ws/audio",
+ bearer_token="token",
+ source_id="bad",
+ display_name="bad",
+ device_kind=audio_pb2.DEVICE_KIND_PROBE,
+ uplink_frame_duration_ms=40,
+ )
+
+
async def test_client_sends_atomic_bound_opus_packet(monkeypatch):
socket = FakeSocket()
@@ -56,6 +68,7 @@ async def connect(*_args, **_kwargs):
source_id="neo",
display_name="Neo",
device_kind=audio_pb2.DEVICE_KIND_NEO,
+ uplink_frame_duration_ms=60,
)
connect_task = asyncio.create_task(client.connect())
await asyncio.sleep(0)
@@ -77,7 +90,7 @@ async def connect(*_args, **_kwargs):
await socket.incoming.put(
_control(
capture_started=audio_pb2.CaptureStarted(
- binding=binding, audio_spec=client.opus_spec(16_000)
+ binding=binding, audio_spec=audio_pb2.AudioSpec()
)
)
)
@@ -89,6 +102,10 @@ async def connect(*_args, **_kwargs):
assert envelope.capture.binding == binding
assert envelope.capture.sequence == 0
assert envelope.capture.opus_payload == b"raw-opus"
- assert json.loads(socket.sent[0])["hello"]["bearer_token"] == "token"
+ hello = json.loads(socket.sent[0])["hello"]
+ assert hello["bearer_token"] == "token"
+ assert hello["supported_uplink"][0]["frame_duration"] == "0.060s"
+ start = json.loads(socket.sent[1])["start_capture"]
+ assert start["audio_spec"]["frame_duration"] == "0.060s"
await socket.close()
await asyncio.gather(client._receive_task, return_exceptions=True)
diff --git a/extras/havpe-relay/relay_core.py b/extras/havpe-relay/relay_core.py
index 69d895ed3..824197550 100644
--- a/extras/havpe-relay/relay_core.py
+++ b/extras/havpe-relay/relay_core.py
@@ -106,6 +106,7 @@ async def run_device_session(
source_id=config.device_name,
display_name=config.device_name,
device_kind=audio_pb2.DEVICE_KIND_HAVPE,
+ uplink_frame_duration_ms=20,
)
playback = HavpePlayback(client, device)
client.on_control = playback.control
diff --git a/extras/local-wearable-client/chronicle_wearable/backend.py b/extras/local-wearable-client/chronicle_wearable/backend.py
index c3ac25db6..f081f60f5 100644
--- a/extras/local-wearable-client/chronicle_wearable/backend.py
+++ b/extras/local-wearable-client/chronicle_wearable/backend.py
@@ -87,6 +87,7 @@ async def stream_to_backend(
if "neo" in device_name.lower()
else audio_pb2.DEVICE_KIND_OMI
),
+ uplink_frame_duration_ms=60,
ssl_context=_ssl_context(),
)
connected_at = None
diff --git a/scripts/rainbow-testflight-handoff.sh b/scripts/rainbow-testflight-handoff.sh
index ba0c2ea71..0270f1dd9 100755
--- a/scripts/rainbow-testflight-handoff.sh
+++ b/scripts/rainbow-testflight-handoff.sh
@@ -52,7 +52,7 @@ fi
npm ci
npm run typecheck
-npm run test:durable-audio-spool
+npm run test:phone-audio-diagnostics
npm run check:theme
npx --no-install expo config --type public --json >/dev/null
npx --no-install expo-modules-autolinking verify --platform ios --verbose
diff --git a/tests/libs/audio_stream_library.py b/tests/libs/audio_stream_library.py
index ddd0c30cd..098523193 100644
--- a/tests/libs/audio_stream_library.py
+++ b/tests/libs/audio_stream_library.py
@@ -69,6 +69,7 @@ def start_stream(
source_id=device_name,
display_name=device_name,
device_kind=audio_pb2.DEVICE_KIND_PROBE,
+ uplink_frame_duration_ms=20,
)
session = _Session(loop=loop, thread=thread, client=client)
stream_id = str(uuid.uuid4())
@@ -251,4 +252,5 @@ def get_audio_stream_client(
source_id=device_name,
display_name=device_name,
device_kind=audio_pb2.DEVICE_KIND_PROBE,
+ uplink_frame_duration_ms=20,
)