@akhilpulse/samadhaan-session-replay 0.3.7 → 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 CHANGED
@@ -72,6 +72,70 @@ import { useSessionReplay } from "@akhilpulse/samadhaan-session-replay";
72
72
  const { pause, resume, sessionId, status } = useSessionReplay();
73
73
  ```
74
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
+
75
139
  ## What it does
76
140
 
77
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.3.7",
4
- "description": "React Native SDK for Samadhaan Guided Support — session recording, live remote screen-share, take-control, and guided-tour overlays.",
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.3.7";
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);
@@ -310,11 +351,14 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
310
351
 
311
352
  // ---- Screenshot capture ----
312
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;
313
357
  useEffect(() => {
314
- // Only capture screenshots in live mode. Idle sessions emit interaction
315
- // events (taps, navigation, etc.) but do not waste CPU/battery/network on
316
- // periodic screenshots frames stream only when an agent is actively viewing.
317
- if (config.disableScreenshots || !sessionId || !liveMode) return;
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;
318
362
  let stopped = false;
319
363
  // ~4 fps target — low device load while still feeling responsive. Capture
320
364
  // takes time, so the loop self-paces: next tick is scheduled AFTER the previous capture completes.
@@ -377,11 +421,11 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
377
421
  };
378
422
  setTimeout(tick, liveTargetMs);
379
423
  return () => { stopped = true; };
380
- }, [sessionId, ingestToken, liveMode, paused, keyboardVisible, config.disableScreenshots, serverUrl, queueEvent]);
424
+ }, [sessionId, ingestToken, streamActive, paused, keyboardVisible, config.disableScreenshots, serverUrl, queueEvent]);
381
425
 
382
- // ---- WebSocket (live mode) ----
426
+ // ---- WebSocket (live mode OR assistant active) ----
383
427
  useEffect(() => {
384
- if (!sessionId || !ingestToken || !liveMode) {
428
+ if (!sessionId || !ingestToken || !streamActive) {
385
429
  if (wsRef.current) { try { wsRef.current.close(); } catch {} wsRef.current = null; }
386
430
  return;
387
431
  }
@@ -399,7 +443,7 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
399
443
  };
400
444
  ws.onclose = () => { if (wsRef.current === ws) wsRef.current = null; };
401
445
  return () => { try { ws.close(); } catch {} };
402
- }, [sessionId, ingestToken, liveMode, serverUrl]);
446
+ }, [sessionId, ingestToken, streamActive, serverUrl]);
403
447
 
404
448
  // ---- Gesture capture (passive PanResponder) ----
405
449
  const panResponder = useMemo(() => PanResponder.create({
@@ -459,6 +503,16 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
459
503
  setTourState({ phase: "idle" });
460
504
  setPointer(null);
461
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;
462
516
  }
463
517
  }
464
518
 
@@ -470,6 +524,106 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
470
524
  setTimeout(() => setRipples((r) => r.filter((p) => p.id !== id)), 600);
471
525
  }
472
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
+
473
627
  // ---- Guided tour state ----
474
628
  const [tourState, setTourState] = useState<{ phase: "idle" | "consent" | "active"; agentName?: string }>({ phase: "idle" });
475
629
  const [pointer, setPointer] = useState<{ x: number; y: number; t: number } | null>(null);
@@ -548,6 +702,8 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
548
702
  status,
549
703
  pause: () => setPaused(true),
550
704
  resume: () => setPaused(false),
705
+ triggerAssist: () => { startAssist(); },
706
+ assistantPhase,
551
707
  };
552
708
 
553
709
  return (
@@ -624,11 +780,105 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
624
780
  )}
625
781
  </>
626
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
+ )}
627
830
  </View>
628
831
  </SessionReplayContext.Provider>
629
832
  );
630
833
  }
631
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
+
632
882
  // ---- helpers ----
633
883
  async function uploadFrameAsync(
634
884
  serverUrl: string,
@@ -739,4 +989,48 @@ const styles = StyleSheet.create({
739
989
  position: "absolute", width: 48, height: 48, borderRadius: 24,
740
990
  backgroundColor: "rgba(239,68,68,0.25)",
741
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
+ },
742
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 };