Skip to content

Commit a023945

Browse files
committed
feat(app): show live phone voice status
1 parent fd1b6dc commit a023945

5 files changed

Lines changed: 94 additions & 8 deletions

File tree

‎app/app/index.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ export default function App() {
323323
isDisabled={!!deviceConnection.connectedDeviceId || deviceConnection.isConnecting}
324324
audioLevel={phoneAudioRecorder.audioLevel}
325325
error={phoneAudioRecorder.error}
326+
playbackState={audioStreamer.phonePlaybackState}
326327
onPress={orchestrator.handleTogglePhoneAudio}
327328
/>
328329
</>

‎app/scripts/test-phone-duplex-controller.cjs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ const controllerPath = path.join(__dirname, '../src/protocol/phoneDuplexControll
3030
const { PhoneDuplexController } = loadTypeScript(controllerPath, {
3131
'./voiceProtocol': protocol,
3232
});
33+
const statusPath = path.join(__dirname, '../src/protocol/phoneVoiceStatus.ts');
34+
const { phoneVoiceStatus } = loadTypeScript(statusPath);
3335

3436
const capabilities = {
3537
mode: 'duplex_full',
@@ -58,6 +60,28 @@ const bound = (type, eventId) => ({
5860
});
5961

6062
(async () => {
63+
assert.equal(phoneVoiceStatus(false, null), null);
64+
assert.deepEqual(phoneVoiceStatus(true, null), {
65+
label: 'Listening',
66+
tone: 'muted',
67+
});
68+
assert.deepEqual(phoneVoiceStatus(true, 'started'), {
69+
label: 'Chronicle speaking',
70+
tone: 'accent',
71+
});
72+
assert.deepEqual(phoneVoiceStatus(true, 'cancelled'), {
73+
label: 'Speech interrupted',
74+
tone: 'warning',
75+
});
76+
assert.deepEqual(phoneVoiceStatus(true, 'done'), {
77+
label: 'Response complete',
78+
tone: 'success',
79+
});
80+
assert.deepEqual(phoneVoiceStatus(true, 'failed'), {
81+
label: 'Playback failed',
82+
tone: 'danger',
83+
});
84+
6185
const appConfig = JSON.parse(
6286
fs.readFileSync(path.join(__dirname, '../app.json'), 'utf8'),
6387
);

‎app/src/components/PhoneAudioButton.tsx‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import React from 'react';
22
import { View, StyleSheet } from 'react-native';
33

44
import { Button, Caption, type ButtonVariant } from '@/components/ui';
5+
import type { NativePlaybackState } from '../../modules/chronicle-duplex-audio';
6+
import {
7+
phoneVoiceStatus,
8+
type PhoneVoiceStatusTone,
9+
} from '@/protocol/phoneVoiceStatus';
510
import { useTheme, type Theme } from '@/theme';
611

712
interface PhoneAudioButtonProps {
@@ -10,6 +15,7 @@ interface PhoneAudioButtonProps {
1015
isDisabled: boolean;
1116
audioLevel: number;
1217
error: string | null;
18+
playbackState: NativePlaybackState['state'] | null;
1319
onPress: () => void;
1420
}
1521

@@ -19,10 +25,22 @@ const PhoneAudioButton: React.FC<PhoneAudioButtonProps> = ({
1925
isDisabled,
2026
audioLevel,
2127
error,
28+
playbackState,
2229
onPress,
2330
}) => {
2431
const t = useTheme();
2532
const s = createStyles(t);
33+
const voiceStatus = phoneVoiceStatus(isRecording, playbackState);
34+
35+
const statusColor = (tone: PhoneVoiceStatusTone): string => {
36+
switch (tone) {
37+
case 'accent': return t.color.accent.fg;
38+
case 'warning': return t.color.status.warning.fg;
39+
case 'success': return t.color.status.success.fg;
40+
case 'danger': return t.color.status.danger.fg;
41+
default: return t.color.text.muted;
42+
}
43+
};
2644

2745
const getButtonVariant = (): ButtonVariant => {
2846
// `secondary` is the neutral chip fill, which is the old `disabled` grey;
@@ -60,13 +78,17 @@ const PhoneAudioButton: React.FC<PhoneAudioButtonProps> = ({
6078
<View style={[s.audioLevelBar, { width: `${Math.min(audioLevel * 100, 100)}%` }]} />
6179
</View>
6280
<Caption style={s.audioLevelText}>Audio Level</Caption>
81+
{voiceStatus && (
82+
<Caption
83+
accessibilityLiveRegion="polite"
84+
style={[s.voiceStatusText, { color: statusColor(voiceStatus.tone) }]}
85+
>
86+
{voiceStatus.label}
87+
</Caption>
88+
)}
6389
</View>
6490
)}
6591

66-
{isRecording && (
67-
<Caption style={s.statusText}>Streaming audio to backend...</Caption>
68-
)}
69-
7092
{error && !isRecording && (
7193
<Caption style={s.errorText}>{error}</Caption>
7294
)}
@@ -85,10 +107,6 @@ const createStyles = (t: Theme) => StyleSheet.create({
85107
buttonWrapper: {
86108
alignSelf: 'stretch',
87109
},
88-
statusText: {
89-
textAlign: 'center',
90-
marginTop: t.space[2],
91-
},
92110
errorText: {
93111
textAlign: 'center',
94112
marginTop: t.space[2],
@@ -118,6 +136,10 @@ const createStyles = (t: Theme) => StyleSheet.create({
118136
audioLevelText: {
119137
marginTop: t.space[1],
120138
},
139+
voiceStatusText: {
140+
marginTop: t.space[1],
141+
fontWeight: t.weight.medium,
142+
},
121143
});
122144

123145
export default PhoneAudioButton;

‎app/src/hooks/useAudioStreamer.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
cancelResponse,
1313
scheduleResponse,
1414
stopVoiceSession,
15+
type NativePlaybackState,
1516
} from '../../modules/chronicle-duplex-audio';
1617
import {
1718
PhoneDuplexController,
@@ -31,6 +32,7 @@ interface UseAudioStreamer {
3132
isStreaming: boolean;
3233
isConnecting: boolean;
3334
error: string | null;
35+
phonePlaybackState: NativePlaybackState['state'] | null;
3436
startStreaming: (url: string, config?: StreamStartConfig) => Promise<void>;
3537
getWebSocketReadyState: () => number | undefined;
3638
stopStreaming: () => Promise<void>;
@@ -162,6 +164,9 @@ export const useAudioStreamer = (options?: UseAudioStreamerOptions): UseAudioStr
162164
const [isStreaming, setIsStreaming] = useState<boolean>(false);
163165
const [isConnecting, setIsConnecting] = useState<boolean>(false);
164166
const [error, setError] = useState<string | null>(null);
167+
const [phonePlaybackState, setPhonePlaybackState] = useState<
168+
NativePlaybackState['state'] | null
169+
>(null);
165170

166171
const websocketRef = useRef<WebSocket | null>(null);
167172
const manuallyStoppedRef = useRef<boolean>(false);
@@ -368,6 +373,7 @@ export const useAudioStreamer = (options?: UseAudioStreamerOptions): UseAudioStr
368373
activeAudioFormatRef.current = AMBIENT_AUDIO_FORMAT;
369374
streamConfigRef.current = undefined;
370375
duplexUnsupportedRef.current = false;
376+
setStateSafe(setPhonePlaybackState, null);
371377
await stopForegroundServiceNotification();
372378
}, [clearDuplexSocketState, sendWyomingEvent, setStateSafe]);
373379

@@ -436,6 +442,9 @@ export const useAudioStreamer = (options?: UseAudioStreamerOptions): UseAudioStr
436442
}
437443
currentUrlRef.current = trimmed;
438444
streamConfigRef.current = requestedConfig;
445+
if (requestedConfig?.phoneVoice) {
446+
setStateSafe(setPhonePlaybackState, null);
447+
}
439448
activeAudioFormatRef.current = requestedConfig?.phoneVoice
440449
? phoneAudioFormat(
441450
requestedConfig.phoneVoice,
@@ -535,6 +544,7 @@ export const useAudioStreamer = (options?: UseAudioStreamerOptions): UseAudioStr
535544
duplexControllerRef.current = controller;
536545
duplexSubscriptionsRef.current = [
537546
addPlaybackStateListener((state) => {
547+
setStateSafe(setPhonePlaybackState, state.state);
538548
controller.nativePlaybackChanged(state).catch((cause) =>
539549
console.error('[AudioStreamer] Playback ACK failed:', cause)
540550
);
@@ -848,6 +858,7 @@ export const useAudioStreamer = (options?: UseAudioStreamerOptions): UseAudioStr
848858
isStreaming,
849859
isConnecting,
850860
error,
861+
phonePlaybackState,
851862
startStreaming,
852863
getWebSocketReadyState,
853864
stopStreaming,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { NativePlaybackState } from '../../modules/chronicle-duplex-audio';
2+
3+
export type PhoneVoiceStatusTone = 'muted' | 'accent' | 'warning' | 'success' | 'danger';
4+
5+
export interface PhoneVoiceStatus {
6+
label: string;
7+
tone: PhoneVoiceStatusTone;
8+
}
9+
10+
export function phoneVoiceStatus(
11+
isRecording: boolean,
12+
playbackState: NativePlaybackState['state'] | null
13+
): PhoneVoiceStatus | null {
14+
if (!isRecording) return null;
15+
16+
switch (playbackState) {
17+
case 'started':
18+
return { label: 'Chronicle speaking', tone: 'accent' };
19+
case 'cancelled':
20+
return { label: 'Speech interrupted', tone: 'warning' };
21+
case 'done':
22+
return { label: 'Response complete', tone: 'success' };
23+
case 'failed':
24+
return { label: 'Playback failed', tone: 'danger' };
25+
default:
26+
return { label: 'Listening', tone: 'muted' };
27+
}
28+
}

0 commit comments

Comments
 (0)