Skip to content

Commit eb12c62

Browse files
committed
Merge shipped phone capture with backend lifecycle release gate
2 parents 01b8bb8 + 318572a commit eb12c62

39 files changed

Lines changed: 2903 additions & 819 deletions

‎.github/workflows/ios-testflight.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,11 @@ jobs:
9797
run: |
9898
npm run test:wearable-activation
9999
npm run typecheck
100-
npm run test:durable-audio-spool
100+
npm run test:phone-audio-diagnostics
101101
npm run test:push-notifications
102102
npm run check:theme
103103
npx --no-install expo-modules-autolinking verify --platform ios --verbose
104+
swift test --package-path modules/chronicle-duplex-audio/ios
104105
105106
- name: Write and validate ASC API key
106107
env:

‎app/app.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"expo": {
33
"name": "chronicle",
44
"slug": "friend-lite-app",
5-
"version": "1.14.0",
5+
"version": "1.15.0",
66
"scheme": "chronicle",
77
"orientation": "portrait",
88
"icon": "./assets/icon.png",

‎app/app/index.tsx‎

Lines changed: 7 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -38,34 +38,24 @@ export default function App() {
3838
// Bluetooth
3939
const { bleManager, bluetoothState, permissionGranted, requestBluetoothPermission, isPermissionsLoading } = useBluetoothManager();
4040

41-
// Settings (must be before audioStreamer so the token refresh callback can reference it)
41+
// Settings
4242
const settings = useSharedAppSettings();
4343

4444
// Live backend reachability (Connection Doctor), re-probed on pull-to-refresh.
4545
const { healthStatus, checkBackendHealth } = useBackendHealth(settings.webSocketUrl, settings.jwtToken);
4646
const [refreshing, setRefreshing] = useState(false);
4747

4848
// Audio
49-
const audioStreamer = useAudioStreamer({
50-
autoReconnectEnabled: settings.autoReconnectEnabled,
51-
onTokenRefreshed: (newToken) => {
52-
// Update app-level auth state when auto-re-login refreshes the token
53-
if (settings.currentUserEmail) {
54-
settings.handleAuthStatusChange(true, settings.currentUserEmail, newToken);
55-
}
56-
},
57-
});
49+
const audioStreamer = useAudioStreamer();
5850
const phoneAudioRecorder = usePhoneAudioRecorder();
5951

6052
const { isListeningAudio: isOmiAudioListenerActive, audioPacketsReceived, startAudioListener: originalStartAudioListener, stopAudioListener: originalStopAudioListener, isRetrying: isAudioListenerRetrying, retryAttempts: audioListenerRetryAttempts } = useAudioListener(omiConnection, () => !!deviceConnection.connectedDeviceId);
6153

6254
// Refs for disconnect cleanup
6355
const isOmiAudioListenerActiveRef = useRef(isOmiAudioListenerActive);
64-
const isAudioStreamingRef = useRef(audioStreamer.isStreaming);
6556
// Track if audio pipeline was active before BLE disconnect (for auto-restart on reconnect)
6657
const wasStreamingBeforeDisconnectRef = useRef(false);
6758
useEffect(() => { isOmiAudioListenerActiveRef.current = isOmiAudioListenerActive; }, [isOmiAudioListenerActive]);
68-
useEffect(() => { isAudioStreamingRef.current = audioStreamer.isStreaming; }, [audioStreamer.isStreaming]);
6959

7060
// Refs to break the declaration-order cycle:
7161
// onDeviceConnect/onDeviceDisconnect need orchestrator + autoReconnect,
@@ -99,22 +89,13 @@ export default function App() {
9989
}, [omiConnection]);
10090

10191
const onDeviceDisconnect = useCallback(async () => {
102-
// Remember if audio was active so we can auto-restart on reconnect
103-
if (isOmiAudioListenerActiveRef.current || isAudioStreamingRef.current) {
92+
// BLE disconnect only owns the wearable pipeline. Phone capture is independent.
93+
if (isOmiAudioListenerActiveRef.current) {
10494
wasStreamingBeforeDisconnectRef.current = true;
95+
await originalStopAudioListener();
96+
await audioStreamer.stopStreaming();
10597
}
106-
107-
// Stop audio listener (BLE is gone, can't read audio)
108-
if (isOmiAudioListenerActiveRef.current) await originalStopAudioListener();
109-
110-
// Keep WebSocket alive — it will reconnect or idle until BLE comes back.
111-
// Only stop WebSocket for phone audio mode (no BLE needed there).
112-
if (phoneAudioRecorder.isRecording) {
113-
audioStreamer.stopStreaming();
114-
await phoneAudioRecorder.stopRecording();
115-
orchestratorRef.current?.setIsPhoneAudioMode(false);
116-
}
117-
}, [originalStopAudioListener, audioStreamer.stopStreaming, phoneAudioRecorder.stopRecording, phoneAudioRecorder.isRecording]);
98+
}, [originalStopAudioListener, audioStreamer.stopStreaming]);
11899

119100
const deviceConnection = useDeviceConnection(omiConnection, bleManager, onDeviceDisconnect, onDeviceConnect);
120101

‎app/app/settings.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import AuthSection from '@/components/AuthSection';
44
import BackendStatus from '@/components/BackendStatus';
55
import NetworkOverview from '@/components/NetworkOverview';
66
import NotificationsSection from '@/components/NotificationsSection';
7+
import PhoneAudioDiagnosticsSection from '@/components/PhoneAudioDiagnosticsSection';
78
import SystemAdminControls from '@/components/SystemAdminControls';
89
import { Screen, SectionLabel } from '@/components/ui';
910
import { useSharedAppSettings } from '@/contexts/AppSettingsContext';
@@ -30,6 +31,12 @@ export default function SettingsScreen() {
3031
authenticated={settings.isAuthenticated}
3132
/>
3233

34+
<SectionLabel>Diagnostics</SectionLabel>
35+
<PhoneAudioDiagnosticsSection
36+
backendUrl={settings.webSocketUrl}
37+
jwtToken={settings.jwtToken}
38+
/>
39+
3340
<SectionLabel>Administration</SectionLabel>
3441
<SystemAdminControls backendUrl={settings.webSocketUrl} jwtToken={settings.jwtToken} />
3542
<NetworkOverview backendUrl={settings.webSocketUrl} />

‎app/modules/chronicle-duplex-audio/android/src/main/java/com/chronicle/duplexaudio/ChronicleDuplexAudioModule.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ class ChronicleDuplexAudioModule : Module() {
219219
"sampleRate" to 16_000,
220220
"channels" to 1,
221221
"frameDurationMs" to durationMs,
222+
"audioLevel" to DuplexAudioPolicy.audioLevel(frame, count),
222223
"opusBase64" to Base64.encodeToString(packet, Base64.NO_WRAP),
223224
),
224225
)

‎app/modules/chronicle-duplex-audio/android/src/main/java/com/chronicle/duplexaudio/DuplexAudioPolicy.kt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,21 @@ internal object DuplexAudioPolicy {
2020
): Boolean = current != null &&
2121
(responseId == "*" || responseId == current.id) &&
2222
cancellationGeneration >= current.generation
23+
24+
fun audioLevel(pcm: ByteArray, byteCount: Int): Double {
25+
val boundedBytes = minOf(byteCount, pcm.size)
26+
val usableBytes = boundedBytes - (boundedBytes % 2)
27+
if (usableBytes <= 0) return 0.0
28+
var sumOfSquares = 0.0
29+
var index = 0
30+
while (index < usableBytes) {
31+
val sample = ((pcm[index].toInt() and 0xff) or (pcm[index + 1].toInt() shl 8)).toShort()
32+
val normalized = sample.toDouble() / 32_768.0
33+
sumOfSquares += normalized * normalized
34+
index += 2
35+
}
36+
return kotlin.math.min(1.0, kotlin.math.sqrt(sumOfSquares / (usableBytes / 2).toDouble()))
37+
}
2338
}
2439

2540
internal data class EpochResponse(

‎app/modules/chronicle-duplex-audio/android/src/test/java/com/chronicle/duplexaudio/DuplexAudioPolicyTest.kt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,14 @@ class DuplexAudioPolicyTest {
5151
gate.schedule(EpochResponse("two", 1, 4))
5252
}
5353
}
54+
55+
@Test fun audioMeterReportsSilenceAndNormalizedPeak() {
56+
assertEquals(0.0, DuplexAudioPolicy.audioLevel(ByteArray(640), 640), 0.001)
57+
val halfScale = ByteArray(640)
58+
for (index in halfScale.indices step 2) {
59+
halfScale[index] = 0
60+
halfScale[index + 1] = 64
61+
}
62+
assertEquals(0.5, DuplexAudioPolicy.audioLevel(halfScale, 640), 0.001)
63+
}
5464
}

‎app/modules/chronicle-duplex-audio/index.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import type { VoiceCapabilities } from '../../src/protocol/audioCapabilities';
99

1010
export interface StartVoiceSessionOptions {
1111
captureEpoch: number;
12+
diagnosticProfile?:
13+
| 'production'
14+
| 'voice_processing_hold'
15+
| 'plain_capture_hold'
16+
| 'system_tap_format_hold';
1217
}
1318

1419
export interface NativeOpusFrame {
@@ -18,9 +23,29 @@ export interface NativeOpusFrame {
1823
sampleRate: 16000;
1924
channels: 1;
2025
frameDurationMs: number;
26+
audioLevel: number;
2127
opusBase64: string;
2228
}
2329

30+
export interface NativeCaptureDiagnostic {
31+
captureEpoch: number;
32+
stage:
33+
| 'tap_received'
34+
| 'pcm_converted'
35+
| 'pcm_conversion_failed'
36+
| 'pcm_empty'
37+
| 'opus_encoded'
38+
| 'opus_encode_failed'
39+
| 'voice_processing_fallback'
40+
| 'capture_failed'
41+
| 'system_change'
42+
| 'watchdog_evaluated';
43+
monotonicTimestampMs: number;
44+
frameCount?: number;
45+
byteCount?: number;
46+
detail?: string;
47+
}
48+
2449
export interface NativeResponse {
2550
responseId: string;
2651
generation: number;
@@ -48,15 +73,43 @@ export interface NativeStopResult {
4873
failureCode: 'far_field_restore_failed' | 'permission_denied' | 'engine_unavailable' | null;
4974
}
5075

76+
export interface NativeVoiceSessionDiagnostics {
77+
diagnosticProfile: NonNullable<StartVoiceSessionOptions['diagnosticProfile']>;
78+
captureEpoch: number;
79+
engineRunning: boolean;
80+
sessionRunning: boolean;
81+
tapInstalled: boolean;
82+
tapFrameCount: number;
83+
convertedFrameCount: number;
84+
opusPacketCount: number;
85+
opusByteCount: number;
86+
peakAudioLevel: number;
87+
systemChangeCount: number;
88+
lastSystemChangeReason: string;
89+
watchdogEvaluationCount: number;
90+
voiceProcessingEnabled: boolean;
91+
audioSessionCategory: string;
92+
audioSessionMode: string;
93+
audioSessionSampleRate: number;
94+
audioSessionIOBufferDurationMs: number;
95+
inputFormat: string;
96+
outputFormat: string;
97+
}
98+
5199
type ChronicleDuplexAudioNative = NativeModule & {
52100
startVoiceSession(options: StartVoiceSessionOptions): Promise<VoiceCapabilities>;
101+
getVoiceSessionDiagnostics(): Promise<NativeVoiceSessionDiagnostics>;
53102
scheduleResponse(response: NativeResponse): Promise<void>;
54103
cancelResponse(responseId: string, generation: number): Promise<void>;
55104
stopVoiceSession(): Promise<NativeStopResult>;
56105
addListener(
57106
eventName: 'onOpusFrame',
58107
listener: (event: NativeOpusFrame) => void
59108
): EventSubscription;
109+
addListener(
110+
eventName: 'onCaptureDiagnostic',
111+
listener: (event: NativeCaptureDiagnostic) => void
112+
): EventSubscription;
60113
addListener(
61114
eventName: 'onPlaybackState',
62115
listener: (event: NativePlaybackState) => void
@@ -94,6 +147,16 @@ export function addOpusFrameListener(
94147
return requireNative().addListener('onOpusFrame', listener);
95148
}
96149

150+
export function getVoiceSessionDiagnostics(): Promise<NativeVoiceSessionDiagnostics> {
151+
return requireNative().getVoiceSessionDiagnostics();
152+
}
153+
154+
export function addCaptureDiagnosticListener(
155+
listener: (event: NativeCaptureDiagnostic) => void
156+
): EventSubscription {
157+
return requireNative().addListener('onCaptureDiagnostic', listener);
158+
}
159+
97160
export function addPlaybackStateListener(
98161
listener: (event: NativePlaybackState) => void
99162
): EventSubscription {

0 commit comments

Comments
 (0)