@akhilpulse/samadhaan-session-replay 0.3.6 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -1
- package/package.json +13 -3
- package/src/SessionReplayProvider.tsx +340 -9
- package/src/index.ts +11 -0
- package/src/voice.ts +244 -0
package/README.md
CHANGED
|
@@ -27,7 +27,11 @@ export default function App() {
|
|
|
27
27
|
config={{
|
|
28
28
|
// serverUrl is optional — defaults to https://samadhaan.pulseenergy.io
|
|
29
29
|
appId: "app_xxx",
|
|
30
|
-
user
|
|
30
|
+
// `user` can be omitted at first (anonymous) and supplied later once the
|
|
31
|
+
// user logs in. Update the value passed to `config.user` after OTP login
|
|
32
|
+
// and the SDK automatically backfills the session with the phone/name/
|
|
33
|
+
// email — so the dashboard stops showing "Anonymous".
|
|
34
|
+
user: { userId: "user_123", phone: "+91xxxxxxxxxx", email: "user@example.com" },
|
|
31
35
|
appVersion: "1.4.2",
|
|
32
36
|
}}
|
|
33
37
|
>
|
|
@@ -68,6 +72,70 @@ import { useSessionReplay } from "@akhilpulse/samadhaan-session-replay";
|
|
|
68
72
|
const { pause, resume, sessionId, status } = useSessionReplay();
|
|
69
73
|
```
|
|
70
74
|
|
|
75
|
+
### Shake-to-Assist (AI voice guide)
|
|
76
|
+
|
|
77
|
+
**New in 0.4.0.** The user shakes their phone, a voice assistant connects, they ask
|
|
78
|
+
a question out loud ("how do I pay my bill?"), the AI looks at their current screen
|
|
79
|
+
and draws an **on-screen arrow** pointing at the next element to tap — step by step.
|
|
80
|
+
Works in **English and Hindi**. Every assistant session (questions + AI steps +
|
|
81
|
+
screenshots) is recorded and visible in the dashboard.
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
<SessionReplayProvider
|
|
85
|
+
config={{
|
|
86
|
+
appId: "app_xxx",
|
|
87
|
+
appName: "Pulse Energy", // spoken by the assistant in its greeting
|
|
88
|
+
assistantLang: "hi", // "en" (default) or "hi"
|
|
89
|
+
// disableAssistant: true, // turn the whole feature off
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
<YourApp />
|
|
93
|
+
</SessionReplayProvider>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Manual trigger (e.g. a "Need help?" button) — useful if you don't install the shake
|
|
97
|
+
dependency, or want a tappable entry point too:
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
const { triggerAssist, assistantPhase } = useSessionReplay();
|
|
101
|
+
// assistantPhase: "idle" | "connecting" | "listening" | "thinking" | "speaking" | "error"
|
|
102
|
+
<Button title="Need help?" onPress={triggerAssist} />
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
#### Optional native peer deps
|
|
106
|
+
|
|
107
|
+
Voice and shake rely on **optional** native modules. The SDK degrades gracefully —
|
|
108
|
+
if a module is missing that capability is simply skipped (no crash):
|
|
109
|
+
|
|
110
|
+
| Capability | Package | If missing |
|
|
111
|
+
|---------------------------|------------------------------------|------------|
|
|
112
|
+
| Shake detection | `react-native-shake` | Use `triggerAssist()` manually |
|
|
113
|
+
| Microphone (voice input) | `react-native-live-audio-stream` | Assistant can still speak + draw arrows; just can't hear the user |
|
|
114
|
+
| Agent voice playback | provide a `PcmAudioPlayer` (see below) | Agent audio is dropped (arrows + transcript still work) |
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npm install react-native-shake react-native-live-audio-stream
|
|
118
|
+
cd ios && pod install
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The agent streams **int16 mono PCM at 16 kHz**. Plug in your own player to hear it:
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
import type { PcmAudioPlayer } from "@akhilpulse/samadhaan-session-replay";
|
|
125
|
+
|
|
126
|
+
const myPlayer: PcmAudioPlayer = {
|
|
127
|
+
start: (sampleRate) => {/* init native player */},
|
|
128
|
+
play: (pcm, sampleRate) => {/* feed int16 mono PCM chunk */},
|
|
129
|
+
clear: () => {/* barge-in: drop buffered audio */},
|
|
130
|
+
stop: () => {/* tear down */},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
<SessionReplayProvider config={{ appId: "app_xxx", assistantAudioPlayer: myPlayer }}>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
After installing native deps on Android, **rebuild the app**
|
|
137
|
+
(`cd android && ./gradlew clean && cd .. && npx react-native run-android`).
|
|
138
|
+
|
|
71
139
|
## What it does
|
|
72
140
|
|
|
73
141
|
- Creates a session against `POST /api/guided-support/sessions` and persists `sessionId` in AsyncStorage so it can be resumed after relaunch.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akhilpulse/samadhaan-session-replay",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "React Native SDK for Samadhaan Guided Support — session recording, live remote screen-share, take-control,
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "React Native SDK for Samadhaan Guided Support — session recording, live remote screen-share, take-control, guided-tour overlays, and Shake-to-Assist AI voice guide.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"module": "src/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
|
@@ -40,6 +40,16 @@
|
|
|
40
40
|
"react": ">=17.0.0",
|
|
41
41
|
"react-native": ">=0.68.0",
|
|
42
42
|
"react-native-view-shot": ">=3.0.0",
|
|
43
|
-
"@react-native-async-storage/async-storage": ">=1.17.0"
|
|
43
|
+
"@react-native-async-storage/async-storage": ">=1.17.0",
|
|
44
|
+
"react-native-shake": ">=5.0.0",
|
|
45
|
+
"react-native-live-audio-stream": ">=1.1.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"react-native-shake": {
|
|
49
|
+
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"react-native-live-audio-stream": {
|
|
52
|
+
"optional": true
|
|
53
|
+
}
|
|
44
54
|
}
|
|
45
55
|
}
|
|
@@ -35,6 +35,12 @@ import {
|
|
|
35
35
|
} from "react-native";
|
|
36
36
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
37
37
|
import { captureRef, captureScreen } from "react-native-view-shot";
|
|
38
|
+
import {
|
|
39
|
+
subscribeShake,
|
|
40
|
+
UltravoxVoiceSession,
|
|
41
|
+
type PcmAudioPlayer,
|
|
42
|
+
type VoiceState,
|
|
43
|
+
} from "./voice";
|
|
38
44
|
|
|
39
45
|
// Bundled native module (Android only) that uses PixelCopy to capture the
|
|
40
46
|
// real on-screen pixels — including hardware-accelerated layers (views with
|
|
@@ -47,7 +53,7 @@ const SessionReplayPixelCopy: PixelCopyNative | undefined =
|
|
|
47
53
|
let pixelCopyDisabled = false; // turned true after a runtime failure to avoid retrying every frame
|
|
48
54
|
|
|
49
55
|
const STORAGE_KEY = "@akhilpulse/samadhaan-session-replay/sessionId";
|
|
50
|
-
const SDK_VERSION = "0.
|
|
56
|
+
const SDK_VERSION = "0.4.0";
|
|
51
57
|
|
|
52
58
|
// ---- Module-level session singleton --------------------------------------
|
|
53
59
|
// Multiple SessionReplayProvider mounts (intentional or via React StrictMode's
|
|
@@ -80,13 +86,32 @@ type Config = {
|
|
|
80
86
|
appVersion?: string;
|
|
81
87
|
/** Disable screenshot capture entirely */
|
|
82
88
|
disableScreenshots?: boolean;
|
|
89
|
+
// ---- Shake-to-Assist (AI voice guide) ----
|
|
90
|
+
/** Friendly app name spoken by the assistant (defaults to "this app"). */
|
|
91
|
+
appName?: string;
|
|
92
|
+
/** Primary language for the voice guide. Default "en". */
|
|
93
|
+
assistantLang?: "en" | "hi";
|
|
94
|
+
/** Disable the shake-to-assist voice guide entirely. */
|
|
95
|
+
disableAssistant?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Optional streaming-PCM player for the agent's voice. Without it the
|
|
98
|
+
* assistant still listens + shows the on-screen arrow, but won't speak aloud.
|
|
99
|
+
* See mobile-sdk/README.md → "Shake-to-Assist".
|
|
100
|
+
*/
|
|
101
|
+
assistantAudioPlayer?: PcmAudioPlayer;
|
|
83
102
|
};
|
|
84
103
|
|
|
104
|
+
type AssistantPhase = "idle" | VoiceState;
|
|
105
|
+
|
|
85
106
|
type Ctx = {
|
|
86
107
|
sessionId: string | null;
|
|
87
108
|
status: "idle" | "active" | "live" | "ended";
|
|
88
109
|
pause: () => void;
|
|
89
110
|
resume: () => void;
|
|
111
|
+
/** Manually open the AI voice guide (fallback when shake isn't available). */
|
|
112
|
+
triggerAssist: () => void;
|
|
113
|
+
/** Current voice-guide phase, or "idle" when closed. */
|
|
114
|
+
assistantPhase: AssistantPhase;
|
|
90
115
|
};
|
|
91
116
|
|
|
92
117
|
const SessionReplayContext = createContext<Ctx>({
|
|
@@ -94,6 +119,8 @@ const SessionReplayContext = createContext<Ctx>({
|
|
|
94
119
|
status: "idle",
|
|
95
120
|
pause: () => {},
|
|
96
121
|
resume: () => {},
|
|
122
|
+
triggerAssist: () => {},
|
|
123
|
+
assistantPhase: "idle",
|
|
97
124
|
});
|
|
98
125
|
|
|
99
126
|
export function useSessionReplay() {
|
|
@@ -122,6 +149,20 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
122
149
|
const eventBufRef = useRef<any[]>([]);
|
|
123
150
|
const lastTapRef = useRef<{ x: number; y: number; t: number } | null>(null);
|
|
124
151
|
|
|
152
|
+
// ---- Shake-to-Assist (AI voice guide) state ----
|
|
153
|
+
const [assistantPhase, setAssistantPhase] = useState<AssistantPhase>("idle");
|
|
154
|
+
const [assistantArrow, setAssistantArrow] = useState<
|
|
155
|
+
{ nx: number; ny: number; label?: string; instruction?: string } | null
|
|
156
|
+
>(null);
|
|
157
|
+
const assistantSessionIdRef = useRef<string | null>(null);
|
|
158
|
+
const voiceRef = useRef<UltravoxVoiceSession | null>(null);
|
|
159
|
+
const assistantStartingRef = useRef(false);
|
|
160
|
+
// Incremented on every endAssist so an in-flight startAssist can detect it was
|
|
161
|
+
// cancelled mid-connect and abort instead of resurrecting the session.
|
|
162
|
+
const assistEpochRef = useRef(0);
|
|
163
|
+
// Assistant is "active" while not idle — drives WS connect + frame streaming.
|
|
164
|
+
const assistantActive = assistantPhase !== "idle";
|
|
165
|
+
|
|
125
166
|
const flushEvents = useCallback(async () => {
|
|
126
167
|
if (!sessionId || !ingestToken || eventBufRef.current.length === 0) return;
|
|
127
168
|
const events = eventBufRef.current.splice(0, eventBufRef.current.length);
|
|
@@ -240,6 +281,43 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
240
281
|
return () => { cancelled = true; };
|
|
241
282
|
}, [config.appId, serverUrl, config.appVersion, config.user, initTick]);
|
|
242
283
|
|
|
284
|
+
// ---- Identify: backfill user identity after login ----
|
|
285
|
+
// A session is usually created while the user is still anonymous (e.g. before
|
|
286
|
+
// OTP login). Once the host app supplies user info via config.user, we PATCH
|
|
287
|
+
// the existing session so the dashboard shows the real phone/name/email
|
|
288
|
+
// instead of "Anonymous". We dedupe on the serialized identity so this only
|
|
289
|
+
// fires when the identity actually changes, and retry on failure.
|
|
290
|
+
const lastIdentitySentRef = useRef<string | null>(null);
|
|
291
|
+
const lastIdentitySessionRef = useRef<string | null>(null);
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
if (!sessionId || !ingestToken) return;
|
|
294
|
+
// A recycled session (server idle-end -> new session) must re-send identity,
|
|
295
|
+
// so reset the dedupe key whenever the session id changes.
|
|
296
|
+
if (lastIdentitySessionRef.current !== sessionId) {
|
|
297
|
+
lastIdentitySessionRef.current = sessionId;
|
|
298
|
+
lastIdentitySentRef.current = null;
|
|
299
|
+
}
|
|
300
|
+
const u = config.user;
|
|
301
|
+
if (!u) return;
|
|
302
|
+
const hasIdentity = !!(u.userId || u.userName || u.email || u.phone);
|
|
303
|
+
if (!hasIdentity) return;
|
|
304
|
+
const key = JSON.stringify({ userId: u.userId, userName: u.userName, email: u.email, phone: u.phone, metadata: u.metadata });
|
|
305
|
+
if (lastIdentitySentRef.current === key) return;
|
|
306
|
+
lastIdentitySentRef.current = key;
|
|
307
|
+
fetch(`${serverUrl}/api/guided-support/sessions/${sessionId}`, {
|
|
308
|
+
method: "PATCH",
|
|
309
|
+
headers: { "Content-Type": "application/json", "x-ingest-token": ingestToken },
|
|
310
|
+
body: JSON.stringify({ user: u }),
|
|
311
|
+
}).then((res) => {
|
|
312
|
+
// Treat non-2xx (e.g. transient 401/5xx) as failure so we retry instead
|
|
313
|
+
// of permanently deduping on an identity the server never stored.
|
|
314
|
+
if (!res.ok) lastIdentitySentRef.current = null;
|
|
315
|
+
}).catch(() => {
|
|
316
|
+
// allow retry on the next render/identity change
|
|
317
|
+
lastIdentitySentRef.current = null;
|
|
318
|
+
});
|
|
319
|
+
}, [sessionId, ingestToken, serverUrl, config.user]);
|
|
320
|
+
|
|
243
321
|
// ---- Keyboard tracking (iOS auto-pause) ----
|
|
244
322
|
useEffect(() => {
|
|
245
323
|
const showSub = Keyboard.addListener(Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow", () => setKeyboardVisible(true));
|
|
@@ -273,11 +351,14 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
273
351
|
|
|
274
352
|
// ---- Screenshot capture ----
|
|
275
353
|
const liveMode = status === "live";
|
|
354
|
+
// Frames must also stream while the AI voice guide is active so its vision
|
|
355
|
+
// tool can "see" the current screen.
|
|
356
|
+
const streamActive = liveMode || assistantActive;
|
|
276
357
|
useEffect(() => {
|
|
277
|
-
//
|
|
278
|
-
// events (taps, navigation, etc.) but do not
|
|
279
|
-
//
|
|
280
|
-
if (config.disableScreenshots || !sessionId || !
|
|
358
|
+
// Capture frames in live mode OR while the assistant is active. Otherwise
|
|
359
|
+
// idle sessions emit interaction events (taps, navigation, etc.) but do not
|
|
360
|
+
// waste CPU/battery/network on periodic screenshots.
|
|
361
|
+
if (config.disableScreenshots || !sessionId || !streamActive) return;
|
|
281
362
|
let stopped = false;
|
|
282
363
|
// ~4 fps target — low device load while still feeling responsive. Capture
|
|
283
364
|
// takes time, so the loop self-paces: next tick is scheduled AFTER the previous capture completes.
|
|
@@ -340,11 +421,11 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
340
421
|
};
|
|
341
422
|
setTimeout(tick, liveTargetMs);
|
|
342
423
|
return () => { stopped = true; };
|
|
343
|
-
}, [sessionId, ingestToken,
|
|
424
|
+
}, [sessionId, ingestToken, streamActive, paused, keyboardVisible, config.disableScreenshots, serverUrl, queueEvent]);
|
|
344
425
|
|
|
345
|
-
// ---- WebSocket (live mode) ----
|
|
426
|
+
// ---- WebSocket (live mode OR assistant active) ----
|
|
346
427
|
useEffect(() => {
|
|
347
|
-
if (!sessionId || !ingestToken || !
|
|
428
|
+
if (!sessionId || !ingestToken || !streamActive) {
|
|
348
429
|
if (wsRef.current) { try { wsRef.current.close(); } catch {} wsRef.current = null; }
|
|
349
430
|
return;
|
|
350
431
|
}
|
|
@@ -362,7 +443,7 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
362
443
|
};
|
|
363
444
|
ws.onclose = () => { if (wsRef.current === ws) wsRef.current = null; };
|
|
364
445
|
return () => { try { ws.close(); } catch {} };
|
|
365
|
-
}, [sessionId, ingestToken,
|
|
446
|
+
}, [sessionId, ingestToken, streamActive, serverUrl]);
|
|
366
447
|
|
|
367
448
|
// ---- Gesture capture (passive PanResponder) ----
|
|
368
449
|
const panResponder = useMemo(() => PanResponder.create({
|
|
@@ -422,6 +503,16 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
422
503
|
setTourState({ phase: "idle" });
|
|
423
504
|
setPointer(null);
|
|
424
505
|
break;
|
|
506
|
+
case "assistant_arrow": {
|
|
507
|
+
// AI voice guide pointing at the next element. Coords are normalized 0..1.
|
|
508
|
+
if (typeof msg.nx === "number" && typeof msg.ny === "number") {
|
|
509
|
+
setAssistantArrow({ nx: msg.nx, ny: msg.ny, label: msg.label, instruction: msg.instruction });
|
|
510
|
+
}
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
case "assistant_arrow_clear":
|
|
514
|
+
setAssistantArrow(null);
|
|
515
|
+
break;
|
|
425
516
|
}
|
|
426
517
|
}
|
|
427
518
|
|
|
@@ -433,6 +524,106 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
433
524
|
setTimeout(() => setRipples((r) => r.filter((p) => p.id !== id)), 600);
|
|
434
525
|
}
|
|
435
526
|
|
|
527
|
+
// ---- Shake-to-Assist: start / end the AI voice guide ----
|
|
528
|
+
const endAssist = useCallback(() => {
|
|
529
|
+
// Null the refs BEFORE stopping the voice session: stop() fires onState
|
|
530
|
+
// ("ended") which routes back here, and clearing first makes the re-entrant
|
|
531
|
+
// call a no-op (no double-stop, no duplicate /assistant/end).
|
|
532
|
+
const asid = assistantSessionIdRef.current;
|
|
533
|
+
const voice = voiceRef.current;
|
|
534
|
+
assistEpochRef.current += 1; // cancel any in-flight startAssist
|
|
535
|
+
assistantSessionIdRef.current = null;
|
|
536
|
+
voiceRef.current = null;
|
|
537
|
+
try { voice?.stop(); } catch {}
|
|
538
|
+
setAssistantArrow(null);
|
|
539
|
+
setAssistantPhase("idle");
|
|
540
|
+
if (asid && sessionId && ingestToken) {
|
|
541
|
+
fetch(`${serverUrl}/api/guided-support/assistant/end`, {
|
|
542
|
+
method: "POST",
|
|
543
|
+
headers: { "Content-Type": "application/json", "x-ingest-token": ingestToken },
|
|
544
|
+
body: JSON.stringify({ sessionId, assistantSessionId: asid }),
|
|
545
|
+
}).catch(() => {});
|
|
546
|
+
}
|
|
547
|
+
}, [sessionId, ingestToken, serverUrl]);
|
|
548
|
+
|
|
549
|
+
const startAssist = useCallback(async () => {
|
|
550
|
+
if (config.disableAssistant) return;
|
|
551
|
+
if (!sessionId || !ingestToken) return;
|
|
552
|
+
if (assistantStartingRef.current || assistantSessionIdRef.current) return;
|
|
553
|
+
assistantStartingRef.current = true;
|
|
554
|
+
const epoch = assistEpochRef.current;
|
|
555
|
+
setAssistantPhase("connecting");
|
|
556
|
+
try {
|
|
557
|
+
const res = await fetch(`${serverUrl}/api/guided-support/assistant/start`, {
|
|
558
|
+
method: "POST",
|
|
559
|
+
headers: { "Content-Type": "application/json", "x-ingest-token": ingestToken },
|
|
560
|
+
body: JSON.stringify({
|
|
561
|
+
sessionId,
|
|
562
|
+
lang: config.assistantLang || "en",
|
|
563
|
+
appName: config.appName,
|
|
564
|
+
}),
|
|
565
|
+
});
|
|
566
|
+
const j = await res.json();
|
|
567
|
+
if (!res.ok || !j.joinUrl || !j.assistantSessionId) {
|
|
568
|
+
throw new Error(j?.error || "assistant start failed");
|
|
569
|
+
}
|
|
570
|
+
// User closed the assistant while we were connecting — abort and tell the
|
|
571
|
+
// server to end the just-created call instead of resurrecting it.
|
|
572
|
+
if (epoch !== assistEpochRef.current) {
|
|
573
|
+
if (sessionId && ingestToken) {
|
|
574
|
+
fetch(`${serverUrl}/api/guided-support/assistant/end`, {
|
|
575
|
+
method: "POST",
|
|
576
|
+
headers: { "Content-Type": "application/json", "x-ingest-token": ingestToken },
|
|
577
|
+
body: JSON.stringify({ sessionId, assistantSessionId: j.assistantSessionId }),
|
|
578
|
+
}).catch(() => {});
|
|
579
|
+
}
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
assistantSessionIdRef.current = j.assistantSessionId;
|
|
583
|
+
const voice = new UltravoxVoiceSession({
|
|
584
|
+
joinUrl: j.joinUrl,
|
|
585
|
+
sampleRate: 16000,
|
|
586
|
+
audioPlayer: config.assistantAudioPlayer,
|
|
587
|
+
onState: (s) => {
|
|
588
|
+
// Natural socket close ("ended") tears the whole assistant down.
|
|
589
|
+
if (s === "ended") { endAssist(); return; }
|
|
590
|
+
setAssistantPhase(s);
|
|
591
|
+
},
|
|
592
|
+
onError: () => setAssistantPhase("error"),
|
|
593
|
+
});
|
|
594
|
+
voiceRef.current = voice;
|
|
595
|
+
voice.start();
|
|
596
|
+
} catch (e) {
|
|
597
|
+
if (__DEV__) console.warn("[session-replay] shake-to-assist failed to start:", e);
|
|
598
|
+
assistantSessionIdRef.current = null;
|
|
599
|
+
setAssistantPhase("error");
|
|
600
|
+
// Auto-dismiss the error state after a moment.
|
|
601
|
+
setTimeout(() => setAssistantPhase((p) => (p === "error" ? "idle" : p)), 2500);
|
|
602
|
+
} finally {
|
|
603
|
+
assistantStartingRef.current = false;
|
|
604
|
+
}
|
|
605
|
+
}, [
|
|
606
|
+
sessionId, ingestToken, serverUrl, endAssist,
|
|
607
|
+
config.disableAssistant, config.assistantLang, config.appName, config.assistantAudioPlayer,
|
|
608
|
+
]);
|
|
609
|
+
|
|
610
|
+
// Shake detection (optional react-native-shake dep). The manual trigger
|
|
611
|
+
// exposed via context is the fallback when the dep isn't installed.
|
|
612
|
+
useEffect(() => {
|
|
613
|
+
if (config.disableAssistant) return;
|
|
614
|
+
const unsub = subscribeShake(() => {
|
|
615
|
+
if (!assistantSessionIdRef.current && !assistantStartingRef.current) startAssist();
|
|
616
|
+
});
|
|
617
|
+
return unsub;
|
|
618
|
+
}, [config.disableAssistant, startAssist]);
|
|
619
|
+
|
|
620
|
+
// Keep a ref to the latest endAssist so the unmount cleanup can run the full
|
|
621
|
+
// teardown (stop voice + best-effort /assistant/end) without a stale closure.
|
|
622
|
+
const endAssistRef = useRef(endAssist);
|
|
623
|
+
endAssistRef.current = endAssist;
|
|
624
|
+
// Tear down the assistant (voice + server session) when the provider unmounts.
|
|
625
|
+
useEffect(() => () => { try { endAssistRef.current(); } catch {} }, []);
|
|
626
|
+
|
|
436
627
|
// ---- Guided tour state ----
|
|
437
628
|
const [tourState, setTourState] = useState<{ phase: "idle" | "consent" | "active"; agentName?: string }>({ phase: "idle" });
|
|
438
629
|
const [pointer, setPointer] = useState<{ x: number; y: number; t: number } | null>(null);
|
|
@@ -511,6 +702,8 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
511
702
|
status,
|
|
512
703
|
pause: () => setPaused(true),
|
|
513
704
|
resume: () => setPaused(false),
|
|
705
|
+
triggerAssist: () => { startAssist(); },
|
|
706
|
+
assistantPhase,
|
|
514
707
|
};
|
|
515
708
|
|
|
516
709
|
return (
|
|
@@ -587,11 +780,105 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
587
780
|
)}
|
|
588
781
|
</>
|
|
589
782
|
)}
|
|
783
|
+
|
|
784
|
+
{/* Shake-to-Assist: on-screen arrow pointing at the next element to tap. */}
|
|
785
|
+
{assistantActive && assistantArrow && (() => {
|
|
786
|
+
const { w, h } = viewSizeRef.current;
|
|
787
|
+
const px = assistantArrow.nx * (w || 0);
|
|
788
|
+
const py = assistantArrow.ny * (h || 0);
|
|
789
|
+
// Keep the label bubble on-screen: flip below the target near the top edge.
|
|
790
|
+
const below = py < 96;
|
|
791
|
+
return (
|
|
792
|
+
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
|
|
793
|
+
<View style={[styles.assistHalo, { left: px - 26, top: py - 26 }]} />
|
|
794
|
+
<View style={[styles.assistDot, { left: px - 9, top: py - 9 }]} />
|
|
795
|
+
{/* Arrow shaft pointing down at the target (or up when flipped). */}
|
|
796
|
+
<View
|
|
797
|
+
style={[
|
|
798
|
+
styles.assistArrow,
|
|
799
|
+
below
|
|
800
|
+
? { left: px - 9, top: py + 12 }
|
|
801
|
+
: { left: px - 9, top: py - 30, transform: [{ rotate: "180deg" }] },
|
|
802
|
+
]}
|
|
803
|
+
/>
|
|
804
|
+
{!!(assistantArrow.label || assistantArrow.instruction) && (
|
|
805
|
+
<View style={[styles.assistBubble, { left: 16, right: 16, [below ? "top" : "bottom"]: below ? py + 36 : (h || 0) - py + 36 } as any]}>
|
|
806
|
+
{!!assistantArrow.label && <Text style={styles.assistBubbleLabel}>{assistantArrow.label}</Text>}
|
|
807
|
+
{!!assistantArrow.instruction && <Text style={styles.assistBubbleText}>{assistantArrow.instruction}</Text>}
|
|
808
|
+
</View>
|
|
809
|
+
)}
|
|
810
|
+
</View>
|
|
811
|
+
);
|
|
812
|
+
})()}
|
|
813
|
+
|
|
814
|
+
{/* Shake-to-Assist: bottom voice "wave" control + status. */}
|
|
815
|
+
{assistantActive && (
|
|
816
|
+
<View style={styles.assistDock} pointerEvents="box-none">
|
|
817
|
+
<View style={styles.assistDockCard}>
|
|
818
|
+
<VoiceWave phase={assistantPhase} />
|
|
819
|
+
<Text style={styles.assistStatusText}>{assistantStatusLabel(assistantPhase)}</Text>
|
|
820
|
+
<Pressable
|
|
821
|
+
onPress={endAssist}
|
|
822
|
+
style={({ pressed }) => [styles.assistClose, pressed && styles.btnPressed]}
|
|
823
|
+
hitSlop={10}
|
|
824
|
+
>
|
|
825
|
+
<Text style={styles.assistCloseText}>Done</Text>
|
|
826
|
+
</Pressable>
|
|
827
|
+
</View>
|
|
828
|
+
</View>
|
|
829
|
+
)}
|
|
590
830
|
</View>
|
|
591
831
|
</SessionReplayContext.Provider>
|
|
592
832
|
);
|
|
593
833
|
}
|
|
594
834
|
|
|
835
|
+
// ---- Shake-to-Assist UI bits ----
|
|
836
|
+
function assistantStatusLabel(phase: AssistantPhase): string {
|
|
837
|
+
switch (phase) {
|
|
838
|
+
case "connecting": return "Connecting…";
|
|
839
|
+
case "listening": return "Listening… ask me anything";
|
|
840
|
+
case "thinking": return "Looking at your screen…";
|
|
841
|
+
case "speaking": return "Speaking…";
|
|
842
|
+
case "error": return "Couldn't start — try again";
|
|
843
|
+
default: return "";
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Animated 5-bar voice wave. Bars idle when not listening/speaking. */
|
|
848
|
+
function VoiceWave({ phase }: { phase: AssistantPhase }) {
|
|
849
|
+
const active = phase === "listening" || phase === "speaking";
|
|
850
|
+
const bars = useRef([0, 1, 2, 3, 4].map(() => new Animated.Value(0.3))).current;
|
|
851
|
+
useEffect(() => {
|
|
852
|
+
if (!active) {
|
|
853
|
+
bars.forEach((b) => Animated.timing(b, { toValue: 0.3, duration: 200, useNativeDriver: false }).start());
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
const loops = bars.map((b, i) =>
|
|
857
|
+
Animated.loop(
|
|
858
|
+
Animated.sequence([
|
|
859
|
+
Animated.timing(b, { toValue: 1, duration: 300 + i * 60, useNativeDriver: false }),
|
|
860
|
+
Animated.timing(b, { toValue: 0.3, duration: 300 + i * 60, useNativeDriver: false }),
|
|
861
|
+
])
|
|
862
|
+
)
|
|
863
|
+
);
|
|
864
|
+
loops.forEach((l) => l.start());
|
|
865
|
+
return () => loops.forEach((l) => l.stop());
|
|
866
|
+
}, [active, bars]);
|
|
867
|
+
return (
|
|
868
|
+
<View style={styles.waveRow}>
|
|
869
|
+
{bars.map((b, i) => (
|
|
870
|
+
<Animated.View
|
|
871
|
+
key={i}
|
|
872
|
+
style={[
|
|
873
|
+
styles.waveBar,
|
|
874
|
+
{ transform: [{ scaleY: b }], opacity: active ? 1 : 0.5 },
|
|
875
|
+
]}
|
|
876
|
+
/>
|
|
877
|
+
))}
|
|
878
|
+
</View>
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
|
|
595
882
|
// ---- helpers ----
|
|
596
883
|
async function uploadFrameAsync(
|
|
597
884
|
serverUrl: string,
|
|
@@ -702,4 +989,48 @@ const styles = StyleSheet.create({
|
|
|
702
989
|
position: "absolute", width: 48, height: 48, borderRadius: 24,
|
|
703
990
|
backgroundColor: "rgba(239,68,68,0.25)",
|
|
704
991
|
},
|
|
992
|
+
// ---- Shake-to-Assist arrow + bubble ----
|
|
993
|
+
assistHalo: {
|
|
994
|
+
position: "absolute", width: 52, height: 52, borderRadius: 26,
|
|
995
|
+
backgroundColor: "rgba(59,130,246,0.22)",
|
|
996
|
+
},
|
|
997
|
+
assistDot: {
|
|
998
|
+
position: "absolute", width: 18, height: 18, borderRadius: 9,
|
|
999
|
+
backgroundColor: "#3b82f6", borderWidth: 3, borderColor: "#fff",
|
|
1000
|
+
shadowColor: "#3b82f6", shadowOpacity: 0.9, shadowRadius: 8,
|
|
1001
|
+
},
|
|
1002
|
+
// A simple chevron-like arrow built from a rotated bordered box.
|
|
1003
|
+
assistArrow: {
|
|
1004
|
+
position: "absolute", width: 18, height: 18,
|
|
1005
|
+
borderRightWidth: 4, borderBottomWidth: 4, borderColor: "#3b82f6",
|
|
1006
|
+
transform: [{ rotate: "45deg" }],
|
|
1007
|
+
},
|
|
1008
|
+
assistBubble: {
|
|
1009
|
+
position: "absolute", backgroundColor: "rgba(17,24,39,0.94)",
|
|
1010
|
+
borderRadius: 12, paddingVertical: 10, paddingHorizontal: 14,
|
|
1011
|
+
shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 10, shadowOffset: { width: 0, height: 4 },
|
|
1012
|
+
},
|
|
1013
|
+
assistBubbleLabel: { color: "#93c5fd", fontSize: 12, fontWeight: "700", marginBottom: 2 },
|
|
1014
|
+
assistBubbleText: { color: "#fff", fontSize: 14, lineHeight: 19 },
|
|
1015
|
+
// ---- Shake-to-Assist bottom dock ----
|
|
1016
|
+
assistDock: {
|
|
1017
|
+
position: "absolute", left: 0, right: 0, bottom: 0,
|
|
1018
|
+
alignItems: "center", paddingBottom: 28, paddingHorizontal: 16,
|
|
1019
|
+
},
|
|
1020
|
+
assistDockCard: {
|
|
1021
|
+
flexDirection: "row", alignItems: "center",
|
|
1022
|
+
backgroundColor: "rgba(17,24,39,0.96)", borderRadius: 999,
|
|
1023
|
+
paddingVertical: 10, paddingHorizontal: 16, maxWidth: "100%",
|
|
1024
|
+
shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 12, shadowOffset: { width: 0, height: 6 },
|
|
1025
|
+
},
|
|
1026
|
+
assistStatusText: { color: "#e5e7eb", fontSize: 14, fontWeight: "600", marginHorizontal: 12, flexShrink: 1 },
|
|
1027
|
+
assistClose: {
|
|
1028
|
+
backgroundColor: "#3b82f6", borderRadius: 999,
|
|
1029
|
+
paddingVertical: 8, paddingHorizontal: 16, minHeight: 36, justifyContent: "center",
|
|
1030
|
+
},
|
|
1031
|
+
assistCloseText: { color: "#fff", fontSize: 14, fontWeight: "700" },
|
|
1032
|
+
waveRow: { flexDirection: "row", alignItems: "center", height: 28, width: 44 },
|
|
1033
|
+
waveBar: {
|
|
1034
|
+
width: 4, height: 22, marginHorizontal: 2, borderRadius: 2, backgroundColor: "#60a5fa",
|
|
1035
|
+
},
|
|
705
1036
|
});
|
package/src/index.ts
CHANGED
|
@@ -4,3 +4,14 @@ export {
|
|
|
4
4
|
useSessionReplay,
|
|
5
5
|
DEFAULT_SERVER_URL,
|
|
6
6
|
} from "./SessionReplayProvider";
|
|
7
|
+
|
|
8
|
+
// Shake-to-Assist voice transport (optional advanced usage / custom audio).
|
|
9
|
+
export {
|
|
10
|
+
subscribeShake,
|
|
11
|
+
UltravoxVoiceSession,
|
|
12
|
+
} from "./voice";
|
|
13
|
+
export type {
|
|
14
|
+
PcmAudioPlayer,
|
|
15
|
+
VoiceState,
|
|
16
|
+
StartVoiceOpts,
|
|
17
|
+
} from "./voice";
|
package/src/voice.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shake-to-Assist voice transport for @akhilpulse/samadhaan-session-replay.
|
|
3
|
+
*
|
|
4
|
+
* Two concerns live here:
|
|
5
|
+
* 1. subscribeShake() — optional accelerometer shake detection.
|
|
6
|
+
* 2. UltravoxVoiceSession — duplex raw-PCM voice over the Ultravox
|
|
7
|
+
* serverWebSocket medium (mic up / agent down).
|
|
8
|
+
*
|
|
9
|
+
* Everything degrades gracefully: the native audio modules are OPTIONAL peer
|
|
10
|
+
* deps. If they aren't installed the rest of the SDK keeps working — the
|
|
11
|
+
* assistant just can't capture/play audio, and the manual trigger + on-screen
|
|
12
|
+
* arrow still function.
|
|
13
|
+
*
|
|
14
|
+
* OPTIONAL peer deps (install in the host app for full voice):
|
|
15
|
+
* - react-native-shake (shake detection; otherwise manual button only)
|
|
16
|
+
* - react-native-live-audio-stream(microphone PCM16 capture)
|
|
17
|
+
* - an audio player for streamed PCM16 (pass `audioPlayer` to startVoiceSession
|
|
18
|
+
* if you have one; otherwise the agent's voice is silently dropped).
|
|
19
|
+
*
|
|
20
|
+
* Server audio format is int16 mono PCM at `sampleRate` (default 16 kHz),
|
|
21
|
+
* matching server/ultravox.ts (serverWebSocket inputSampleRate/outputSampleRate).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Loads an optional native module without crashing Metro when it's absent.
|
|
25
|
+
function loadOptional<T = any>(name: string): T | null {
|
|
26
|
+
try {
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
28
|
+
return require(name) as T;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---- Shake detection -----------------------------------------------------
|
|
35
|
+
/**
|
|
36
|
+
* Subscribe to phone-shake events. Returns an unsubscribe function.
|
|
37
|
+
* Uses `react-native-shake` if installed; otherwise it's a no-op and the host
|
|
38
|
+
* app should rely on the manual trigger exposed via useSessionReplay().
|
|
39
|
+
*/
|
|
40
|
+
export function subscribeShake(onShake: () => void): () => void {
|
|
41
|
+
const mod: any = loadOptional("react-native-shake");
|
|
42
|
+
if (!mod) return () => {};
|
|
43
|
+
try {
|
|
44
|
+
// react-native-shake v5+: default export is { addListener }
|
|
45
|
+
const api = mod.default || mod;
|
|
46
|
+
const sub = api.addListener(onShake);
|
|
47
|
+
return () => { try { sub?.remove?.(); } catch {} };
|
|
48
|
+
} catch {
|
|
49
|
+
return () => {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- Audio helpers -------------------------------------------------------
|
|
54
|
+
function base64ToArrayBuffer(b64: string): ArrayBuffer {
|
|
55
|
+
const bin =
|
|
56
|
+
typeof globalThis.atob === "function"
|
|
57
|
+
? globalThis.atob(b64)
|
|
58
|
+
: // RN always ships atob in modern versions; Buffer is the fallback.
|
|
59
|
+
(globalThis as any).Buffer
|
|
60
|
+
? (globalThis as any).Buffer.from(b64, "base64").toString("binary")
|
|
61
|
+
: "";
|
|
62
|
+
const len = bin.length;
|
|
63
|
+
const bytes = new Uint8Array(len);
|
|
64
|
+
for (let i = 0; i < len; i++) bytes[i] = bin.charCodeAt(i);
|
|
65
|
+
return bytes.buffer;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function arrayBufferToBase64(buf: ArrayBuffer): string {
|
|
69
|
+
const bytes = new Uint8Array(buf);
|
|
70
|
+
let bin = "";
|
|
71
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
72
|
+
if (typeof globalThis.btoa === "function") return globalThis.btoa(bin);
|
|
73
|
+
if ((globalThis as any).Buffer) return (globalThis as any).Buffer.from(bin, "binary").toString("base64");
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Pluggable streaming-PCM player so integrators can wire their own native module. */
|
|
78
|
+
export type PcmAudioPlayer = {
|
|
79
|
+
/** Called once before the first chunk. */
|
|
80
|
+
start?: (sampleRate: number) => void;
|
|
81
|
+
/** Feed one int16 mono PCM chunk (ArrayBuffer). */
|
|
82
|
+
play: (pcm: ArrayBuffer, sampleRate: number) => void;
|
|
83
|
+
/** Drop any buffered audio (server asked us to interrupt). */
|
|
84
|
+
clear?: () => void;
|
|
85
|
+
/** Tear down. */
|
|
86
|
+
stop?: () => void;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type VoiceState = "connecting" | "listening" | "thinking" | "speaking" | "ended" | "error";
|
|
90
|
+
|
|
91
|
+
// react-native-live-audio-stream is a singleton with no per-listener removal,
|
|
92
|
+
// so we install the "data" handler exactly once and route frames to whichever
|
|
93
|
+
// session is currently active. This prevents listener accumulation (and
|
|
94
|
+
// duplicate frame sends) across repeated assist sessions.
|
|
95
|
+
let _micListenerInstalled = false;
|
|
96
|
+
let _activeMicSession: UltravoxVoiceSession | null = null;
|
|
97
|
+
|
|
98
|
+
export type StartVoiceOpts = {
|
|
99
|
+
/** wss joinUrl returned by /assistant/start. */
|
|
100
|
+
joinUrl: string;
|
|
101
|
+
/** Must match the server medium sample rate (server/ultravox.ts → 16000). */
|
|
102
|
+
sampleRate?: number;
|
|
103
|
+
/** Optional player for the agent's streamed voice. */
|
|
104
|
+
audioPlayer?: PcmAudioPlayer;
|
|
105
|
+
onState?: (s: VoiceState) => void;
|
|
106
|
+
/** Live transcript text (both user + agent), with role + final flag. */
|
|
107
|
+
onTranscript?: (t: { role: string; text: string; final: boolean }) => void;
|
|
108
|
+
onError?: (e: Error) => void;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A single realtime voice session. Connects the WS, streams mic PCM up, plays
|
|
113
|
+
* agent PCM down (if a player is provided), and surfaces state + transcripts.
|
|
114
|
+
*/
|
|
115
|
+
export class UltravoxVoiceSession {
|
|
116
|
+
private ws: WebSocket | null = null;
|
|
117
|
+
private opts: StartVoiceOpts;
|
|
118
|
+
private sampleRate: number;
|
|
119
|
+
private audioStream: any = null;
|
|
120
|
+
private micActive = false;
|
|
121
|
+
private stopped = false;
|
|
122
|
+
|
|
123
|
+
constructor(opts: StartVoiceOpts) {
|
|
124
|
+
this.opts = opts;
|
|
125
|
+
this.sampleRate = opts.sampleRate || 16000;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
start() {
|
|
129
|
+
this.opts.onState?.("connecting");
|
|
130
|
+
let ws: WebSocket;
|
|
131
|
+
try {
|
|
132
|
+
ws = new WebSocket(this.opts.joinUrl);
|
|
133
|
+
} catch (e: any) {
|
|
134
|
+
this.fail(e);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
ws.binaryType = "arraybuffer" as any;
|
|
138
|
+
this.ws = ws;
|
|
139
|
+
|
|
140
|
+
ws.onopen = () => {
|
|
141
|
+
if (this.stopped) return;
|
|
142
|
+
this.opts.audioPlayer?.start?.(this.sampleRate);
|
|
143
|
+
this.startMic();
|
|
144
|
+
this.opts.onState?.("listening");
|
|
145
|
+
};
|
|
146
|
+
ws.onmessage = (ev: any) => {
|
|
147
|
+
const data = ev.data;
|
|
148
|
+
if (data instanceof ArrayBuffer) {
|
|
149
|
+
// Agent voice — raw int16 PCM at outputSampleRate.
|
|
150
|
+
try { this.opts.audioPlayer?.play(data, this.sampleRate); } catch {}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (typeof data === "string") {
|
|
154
|
+
this.handleControl(data);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
ws.onerror = (e: any) => { this.fail(new Error(e?.message || "voice websocket error")); };
|
|
158
|
+
ws.onclose = () => { if (!this.stopped) this.opts.onState?.("ended"); };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private handleControl(raw: string) {
|
|
162
|
+
let msg: any;
|
|
163
|
+
try { msg = JSON.parse(raw); } catch { return; }
|
|
164
|
+
switch (msg.type) {
|
|
165
|
+
case "state":
|
|
166
|
+
// Ultravox emits agent state: "listening" | "thinking" | "speaking".
|
|
167
|
+
if (msg.state === "listening" || msg.state === "thinking" || msg.state === "speaking") {
|
|
168
|
+
this.opts.onState?.(msg.state);
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
case "transcript":
|
|
172
|
+
this.opts.onTranscript?.({
|
|
173
|
+
role: msg.role || "agent",
|
|
174
|
+
text: msg.text ?? msg.delta ?? "",
|
|
175
|
+
final: !!msg.final,
|
|
176
|
+
});
|
|
177
|
+
break;
|
|
178
|
+
case "playback_clear_buffer":
|
|
179
|
+
// Barge-in: user started talking, drop queued agent audio.
|
|
180
|
+
try { this.opts.audioPlayer?.clear?.(); } catch {}
|
|
181
|
+
break;
|
|
182
|
+
default:
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private startMic() {
|
|
188
|
+
const audioMod: any = loadOptional("react-native-live-audio-stream");
|
|
189
|
+
if (!audioMod) return; // No mic module — assistant can still speak/guide.
|
|
190
|
+
const LiveAudioStream = audioMod.default || audioMod;
|
|
191
|
+
try {
|
|
192
|
+
LiveAudioStream.init({
|
|
193
|
+
sampleRate: this.sampleRate,
|
|
194
|
+
channels: 1,
|
|
195
|
+
bitsPerSample: 16,
|
|
196
|
+
audioSource: 6, // Android VOICE_RECOGNITION
|
|
197
|
+
bufferSize: 4096,
|
|
198
|
+
});
|
|
199
|
+
// Install the data handler once, route to the active session.
|
|
200
|
+
if (!_micListenerInstalled) {
|
|
201
|
+
_micListenerInstalled = true;
|
|
202
|
+
LiveAudioStream.on("data", (b64: string) => {
|
|
203
|
+
const s = _activeMicSession;
|
|
204
|
+
if (!s || s.stopped || !s.ws || s.ws.readyState !== WebSocket.OPEN) return;
|
|
205
|
+
try { s.ws.send(base64ToArrayBuffer(b64)); } catch {}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
_activeMicSession = this;
|
|
209
|
+
LiveAudioStream.start();
|
|
210
|
+
this.audioStream = LiveAudioStream;
|
|
211
|
+
this.micActive = true;
|
|
212
|
+
} catch (e) {
|
|
213
|
+
// Mic failed — keep the session alive for agent audio + arrows.
|
|
214
|
+
this.audioStream = null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Send a text message to the agent (optional helper for typed input). */
|
|
219
|
+
sendText(text: string) {
|
|
220
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
221
|
+
try { this.ws.send(JSON.stringify({ type: "input_text_message", text })); } catch {}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
stop() {
|
|
226
|
+
this.stopped = true;
|
|
227
|
+
if (_activeMicSession === this) _activeMicSession = null;
|
|
228
|
+
if (this.micActive && this.audioStream) {
|
|
229
|
+
try { this.audioStream.stop(); } catch {}
|
|
230
|
+
}
|
|
231
|
+
this.micActive = false;
|
|
232
|
+
try { this.opts.audioPlayer?.stop?.(); } catch {}
|
|
233
|
+
if (this.ws) { try { this.ws.close(); } catch {} this.ws = null; }
|
|
234
|
+
this.opts.onState?.("ended");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private fail(e: Error) {
|
|
238
|
+
this.opts.onError?.(e);
|
|
239
|
+
this.opts.onState?.("error");
|
|
240
|
+
this.stop();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export { arrayBufferToBase64 };
|