@akhilpulse/samadhaan-session-replay 0.3.7 → 0.4.1

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,82 @@ 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 opens the voice assistant, asks a question out loud
78
+ ("how do I pay my bill?"), the AI looks at their current screen and draws an
79
+ **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
+ **New in 0.4.1 — floating help button (default trigger).** A small draggable
84
+ `?` button floats at the top-right of the screen. Tapping it starts the voice
85
+ guide; the user can drag it anywhere and it stays inside the screen. This is
86
+ the definitive trigger for **Expo** apps, where the native shake module isn't
87
+ available. Shake (if `react-native-shake` is installed) and `triggerAssist()`
88
+ still work as alternate triggers.
89
+
90
+ ```tsx
91
+ // The button shows by default. To hide it:
92
+ <SessionReplayProvider config={{ appId: "app_xxx", showAssistButton: false }}>
93
+ ```
94
+
95
+ ```tsx
96
+ <SessionReplayProvider
97
+ config={{
98
+ appId: "app_xxx",
99
+ appName: "Pulse Energy", // spoken by the assistant in its greeting
100
+ assistantLang: "hi", // "en" (default) or "hi"
101
+ // disableAssistant: true, // turn the whole feature off
102
+ }}
103
+ >
104
+ <YourApp />
105
+ </SessionReplayProvider>
106
+ ```
107
+
108
+ Manual trigger (e.g. a "Need help?" button) — useful if you don't install the shake
109
+ dependency, or want a tappable entry point too:
110
+
111
+ ```tsx
112
+ const { triggerAssist, assistantPhase } = useSessionReplay();
113
+ // assistantPhase: "idle" | "connecting" | "listening" | "thinking" | "speaking" | "error"
114
+ <Button title="Need help?" onPress={triggerAssist} />
115
+ ```
116
+
117
+ #### Optional native peer deps
118
+
119
+ Voice and shake rely on **optional** native modules. The SDK degrades gracefully —
120
+ if a module is missing that capability is simply skipped (no crash):
121
+
122
+ | Capability | Package | If missing |
123
+ |---------------------------|------------------------------------|------------|
124
+ | Shake detection | `react-native-shake` | Use `triggerAssist()` manually |
125
+ | Microphone (voice input) | `react-native-live-audio-stream` | Assistant can still speak + draw arrows; just can't hear the user |
126
+ | Agent voice playback | provide a `PcmAudioPlayer` (see below) | Agent audio is dropped (arrows + transcript still work) |
127
+
128
+ ```bash
129
+ npm install react-native-shake react-native-live-audio-stream
130
+ cd ios && pod install
131
+ ```
132
+
133
+ The agent streams **int16 mono PCM at 16 kHz**. Plug in your own player to hear it:
134
+
135
+ ```tsx
136
+ import type { PcmAudioPlayer } from "@akhilpulse/samadhaan-session-replay";
137
+
138
+ const myPlayer: PcmAudioPlayer = {
139
+ start: (sampleRate) => {/* init native player */},
140
+ play: (pcm, sampleRate) => {/* feed int16 mono PCM chunk */},
141
+ clear: () => {/* barge-in: drop buffered audio */},
142
+ stop: () => {/* tear down */},
143
+ };
144
+
145
+ <SessionReplayProvider config={{ appId: "app_xxx", assistantAudioPlayer: myPlayer }}>
146
+ ```
147
+
148
+ After installing native deps on Android, **rebuild the app**
149
+ (`cd android && ./gradlew clean && cd .. && npx react-native run-android`).
150
+
75
151
  ## What it does
76
152
 
77
153
  - 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.1",
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.1";
51
57
 
52
58
  // ---- Module-level session singleton --------------------------------------
53
59
  // Multiple SessionReplayProvider mounts (intentional or via React StrictMode's
@@ -80,13 +86,38 @@ 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
+ * Show a small draggable floating help button (top-right) that opens the
98
+ * voice guide on tap. This is the definitive trigger for Expo apps where
99
+ * the native shake module isn't available. Default: true.
100
+ */
101
+ showAssistButton?: boolean;
102
+ /**
103
+ * Optional streaming-PCM player for the agent's voice. Without it the
104
+ * assistant still listens + shows the on-screen arrow, but won't speak aloud.
105
+ * See mobile-sdk/README.md → "Shake-to-Assist".
106
+ */
107
+ assistantAudioPlayer?: PcmAudioPlayer;
83
108
  };
84
109
 
110
+ type AssistantPhase = "idle" | VoiceState;
111
+
85
112
  type Ctx = {
86
113
  sessionId: string | null;
87
114
  status: "idle" | "active" | "live" | "ended";
88
115
  pause: () => void;
89
116
  resume: () => void;
117
+ /** Manually open the AI voice guide (fallback when shake isn't available). */
118
+ triggerAssist: () => void;
119
+ /** Current voice-guide phase, or "idle" when closed. */
120
+ assistantPhase: AssistantPhase;
90
121
  };
91
122
 
92
123
  const SessionReplayContext = createContext<Ctx>({
@@ -94,6 +125,8 @@ const SessionReplayContext = createContext<Ctx>({
94
125
  status: "idle",
95
126
  pause: () => {},
96
127
  resume: () => {},
128
+ triggerAssist: () => {},
129
+ assistantPhase: "idle",
97
130
  });
98
131
 
99
132
  export function useSessionReplay() {
@@ -122,6 +155,20 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
122
155
  const eventBufRef = useRef<any[]>([]);
123
156
  const lastTapRef = useRef<{ x: number; y: number; t: number } | null>(null);
124
157
 
158
+ // ---- Shake-to-Assist (AI voice guide) state ----
159
+ const [assistantPhase, setAssistantPhase] = useState<AssistantPhase>("idle");
160
+ const [assistantArrow, setAssistantArrow] = useState<
161
+ { nx: number; ny: number; label?: string; instruction?: string } | null
162
+ >(null);
163
+ const assistantSessionIdRef = useRef<string | null>(null);
164
+ const voiceRef = useRef<UltravoxVoiceSession | null>(null);
165
+ const assistantStartingRef = useRef(false);
166
+ // Incremented on every endAssist so an in-flight startAssist can detect it was
167
+ // cancelled mid-connect and abort instead of resurrecting the session.
168
+ const assistEpochRef = useRef(0);
169
+ // Assistant is "active" while not idle — drives WS connect + frame streaming.
170
+ const assistantActive = assistantPhase !== "idle";
171
+
125
172
  const flushEvents = useCallback(async () => {
126
173
  if (!sessionId || !ingestToken || eventBufRef.current.length === 0) return;
127
174
  const events = eventBufRef.current.splice(0, eventBufRef.current.length);
@@ -310,11 +357,14 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
310
357
 
311
358
  // ---- Screenshot capture ----
312
359
  const liveMode = status === "live";
360
+ // Frames must also stream while the AI voice guide is active so its vision
361
+ // tool can "see" the current screen.
362
+ const streamActive = liveMode || assistantActive;
313
363
  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;
364
+ // Capture frames in live mode OR while the assistant is active. Otherwise
365
+ // idle sessions emit interaction events (taps, navigation, etc.) but do not
366
+ // waste CPU/battery/network on periodic screenshots.
367
+ if (config.disableScreenshots || !sessionId || !streamActive) return;
318
368
  let stopped = false;
319
369
  // ~4 fps target — low device load while still feeling responsive. Capture
320
370
  // takes time, so the loop self-paces: next tick is scheduled AFTER the previous capture completes.
@@ -377,11 +427,11 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
377
427
  };
378
428
  setTimeout(tick, liveTargetMs);
379
429
  return () => { stopped = true; };
380
- }, [sessionId, ingestToken, liveMode, paused, keyboardVisible, config.disableScreenshots, serverUrl, queueEvent]);
430
+ }, [sessionId, ingestToken, streamActive, paused, keyboardVisible, config.disableScreenshots, serverUrl, queueEvent]);
381
431
 
382
- // ---- WebSocket (live mode) ----
432
+ // ---- WebSocket (live mode OR assistant active) ----
383
433
  useEffect(() => {
384
- if (!sessionId || !ingestToken || !liveMode) {
434
+ if (!sessionId || !ingestToken || !streamActive) {
385
435
  if (wsRef.current) { try { wsRef.current.close(); } catch {} wsRef.current = null; }
386
436
  return;
387
437
  }
@@ -399,7 +449,7 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
399
449
  };
400
450
  ws.onclose = () => { if (wsRef.current === ws) wsRef.current = null; };
401
451
  return () => { try { ws.close(); } catch {} };
402
- }, [sessionId, ingestToken, liveMode, serverUrl]);
452
+ }, [sessionId, ingestToken, streamActive, serverUrl]);
403
453
 
404
454
  // ---- Gesture capture (passive PanResponder) ----
405
455
  const panResponder = useMemo(() => PanResponder.create({
@@ -459,6 +509,16 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
459
509
  setTourState({ phase: "idle" });
460
510
  setPointer(null);
461
511
  break;
512
+ case "assistant_arrow": {
513
+ // AI voice guide pointing at the next element. Coords are normalized 0..1.
514
+ if (typeof msg.nx === "number" && typeof msg.ny === "number") {
515
+ setAssistantArrow({ nx: msg.nx, ny: msg.ny, label: msg.label, instruction: msg.instruction });
516
+ }
517
+ break;
518
+ }
519
+ case "assistant_arrow_clear":
520
+ setAssistantArrow(null);
521
+ break;
462
522
  }
463
523
  }
464
524
 
@@ -470,6 +530,106 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
470
530
  setTimeout(() => setRipples((r) => r.filter((p) => p.id !== id)), 600);
471
531
  }
472
532
 
533
+ // ---- Shake-to-Assist: start / end the AI voice guide ----
534
+ const endAssist = useCallback(() => {
535
+ // Null the refs BEFORE stopping the voice session: stop() fires onState
536
+ // ("ended") which routes back here, and clearing first makes the re-entrant
537
+ // call a no-op (no double-stop, no duplicate /assistant/end).
538
+ const asid = assistantSessionIdRef.current;
539
+ const voice = voiceRef.current;
540
+ assistEpochRef.current += 1; // cancel any in-flight startAssist
541
+ assistantSessionIdRef.current = null;
542
+ voiceRef.current = null;
543
+ try { voice?.stop(); } catch {}
544
+ setAssistantArrow(null);
545
+ setAssistantPhase("idle");
546
+ if (asid && sessionId && ingestToken) {
547
+ fetch(`${serverUrl}/api/guided-support/assistant/end`, {
548
+ method: "POST",
549
+ headers: { "Content-Type": "application/json", "x-ingest-token": ingestToken },
550
+ body: JSON.stringify({ sessionId, assistantSessionId: asid }),
551
+ }).catch(() => {});
552
+ }
553
+ }, [sessionId, ingestToken, serverUrl]);
554
+
555
+ const startAssist = useCallback(async () => {
556
+ if (config.disableAssistant) return;
557
+ if (!sessionId || !ingestToken) return;
558
+ if (assistantStartingRef.current || assistantSessionIdRef.current) return;
559
+ assistantStartingRef.current = true;
560
+ const epoch = assistEpochRef.current;
561
+ setAssistantPhase("connecting");
562
+ try {
563
+ const res = await fetch(`${serverUrl}/api/guided-support/assistant/start`, {
564
+ method: "POST",
565
+ headers: { "Content-Type": "application/json", "x-ingest-token": ingestToken },
566
+ body: JSON.stringify({
567
+ sessionId,
568
+ lang: config.assistantLang || "en",
569
+ appName: config.appName,
570
+ }),
571
+ });
572
+ const j = await res.json();
573
+ if (!res.ok || !j.joinUrl || !j.assistantSessionId) {
574
+ throw new Error(j?.error || "assistant start failed");
575
+ }
576
+ // User closed the assistant while we were connecting — abort and tell the
577
+ // server to end the just-created call instead of resurrecting it.
578
+ if (epoch !== assistEpochRef.current) {
579
+ if (sessionId && ingestToken) {
580
+ fetch(`${serverUrl}/api/guided-support/assistant/end`, {
581
+ method: "POST",
582
+ headers: { "Content-Type": "application/json", "x-ingest-token": ingestToken },
583
+ body: JSON.stringify({ sessionId, assistantSessionId: j.assistantSessionId }),
584
+ }).catch(() => {});
585
+ }
586
+ return;
587
+ }
588
+ assistantSessionIdRef.current = j.assistantSessionId;
589
+ const voice = new UltravoxVoiceSession({
590
+ joinUrl: j.joinUrl,
591
+ sampleRate: 16000,
592
+ audioPlayer: config.assistantAudioPlayer,
593
+ onState: (s) => {
594
+ // Natural socket close ("ended") tears the whole assistant down.
595
+ if (s === "ended") { endAssist(); return; }
596
+ setAssistantPhase(s);
597
+ },
598
+ onError: () => setAssistantPhase("error"),
599
+ });
600
+ voiceRef.current = voice;
601
+ voice.start();
602
+ } catch (e) {
603
+ if (__DEV__) console.warn("[session-replay] shake-to-assist failed to start:", e);
604
+ assistantSessionIdRef.current = null;
605
+ setAssistantPhase("error");
606
+ // Auto-dismiss the error state after a moment.
607
+ setTimeout(() => setAssistantPhase((p) => (p === "error" ? "idle" : p)), 2500);
608
+ } finally {
609
+ assistantStartingRef.current = false;
610
+ }
611
+ }, [
612
+ sessionId, ingestToken, serverUrl, endAssist,
613
+ config.disableAssistant, config.assistantLang, config.appName, config.assistantAudioPlayer,
614
+ ]);
615
+
616
+ // Shake detection (optional react-native-shake dep). The manual trigger
617
+ // exposed via context is the fallback when the dep isn't installed.
618
+ useEffect(() => {
619
+ if (config.disableAssistant) return;
620
+ const unsub = subscribeShake(() => {
621
+ if (!assistantSessionIdRef.current && !assistantStartingRef.current) startAssist();
622
+ });
623
+ return unsub;
624
+ }, [config.disableAssistant, startAssist]);
625
+
626
+ // Keep a ref to the latest endAssist so the unmount cleanup can run the full
627
+ // teardown (stop voice + best-effort /assistant/end) without a stale closure.
628
+ const endAssistRef = useRef(endAssist);
629
+ endAssistRef.current = endAssist;
630
+ // Tear down the assistant (voice + server session) when the provider unmounts.
631
+ useEffect(() => () => { try { endAssistRef.current(); } catch {} }, []);
632
+
473
633
  // ---- Guided tour state ----
474
634
  const [tourState, setTourState] = useState<{ phase: "idle" | "consent" | "active"; agentName?: string }>({ phase: "idle" });
475
635
  const [pointer, setPointer] = useState<{ x: number; y: number; t: number } | null>(null);
@@ -517,6 +677,43 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
517
677
  [endTourPan]
518
678
  );
519
679
 
680
+ // ---- Draggable floating Assist help button (definitive trigger for Expo) ----
681
+ const assistFabPan = useRef(new Animated.ValueXY({ x: 0, y: 0 })).current;
682
+ const assistFabPanResponder = useMemo(
683
+ () =>
684
+ PanResponder.create({
685
+ // Don't claim taps — let the inner Pressable receive onPress.
686
+ onStartShouldSetPanResponder: () => false,
687
+ // Only claim once the user clearly starts dragging (>4px).
688
+ onMoveShouldSetPanResponder: (_e, g) => Math.abs(g.dx) > 4 || Math.abs(g.dy) > 4,
689
+ onPanResponderGrant: () => {
690
+ assistFabPan.extractOffset();
691
+ },
692
+ onPanResponderMove: Animated.event(
693
+ [null, { dx: assistFabPan.x, dy: assistFabPan.y }],
694
+ { useNativeDriver: false }
695
+ ),
696
+ onPanResponderRelease: () => {
697
+ assistFabPan.flattenOffset();
698
+ // Clamp inside the visible root-view bounds so the button never escapes off-screen.
699
+ const { w, h } = viewSizeRef.current;
700
+ const FAB = 44, ANCHOR_TOP = 96, ANCHOR_RIGHT = 12, MARGIN = 8;
701
+ const minX = -(Math.max(0, w - ANCHOR_RIGHT - FAB - MARGIN));
702
+ const maxX = 0;
703
+ const minY = -(ANCHOR_TOP - MARGIN);
704
+ const maxY = Math.max(0, h - ANCHOR_TOP - FAB - MARGIN);
705
+ const cx = (assistFabPan.x as any)._value;
706
+ const cy = (assistFabPan.y as any)._value;
707
+ const nx = Math.min(maxX, Math.max(minX, cx));
708
+ const ny = Math.min(maxY, Math.max(minY, cy));
709
+ if (nx !== cx || ny !== cy) {
710
+ Animated.spring(assistFabPan, { toValue: { x: nx, y: ny }, useNativeDriver: false, friction: 7 }).start();
711
+ }
712
+ },
713
+ }),
714
+ [assistFabPan]
715
+ );
716
+
520
717
  function consentTour(accept: boolean) {
521
718
  if (accept) {
522
719
  setTourState((s) => ({ phase: "active", agentName: s.agentName }));
@@ -548,6 +745,8 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
548
745
  status,
549
746
  pause: () => setPaused(true),
550
747
  resume: () => setPaused(false),
748
+ triggerAssist: () => { startAssist(); },
749
+ assistantPhase,
551
750
  };
552
751
 
553
752
  return (
@@ -624,11 +823,124 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
624
823
  )}
625
824
  </>
626
825
  )}
826
+
827
+ {/* Shake-to-Assist: on-screen arrow pointing at the next element to tap. */}
828
+ {assistantActive && assistantArrow && (() => {
829
+ const { w, h } = viewSizeRef.current;
830
+ const px = assistantArrow.nx * (w || 0);
831
+ const py = assistantArrow.ny * (h || 0);
832
+ // Keep the label bubble on-screen: flip below the target near the top edge.
833
+ const below = py < 96;
834
+ return (
835
+ <View pointerEvents="none" style={StyleSheet.absoluteFill}>
836
+ <View style={[styles.assistHalo, { left: px - 26, top: py - 26 }]} />
837
+ <View style={[styles.assistDot, { left: px - 9, top: py - 9 }]} />
838
+ {/* Arrow shaft pointing down at the target (or up when flipped). */}
839
+ <View
840
+ style={[
841
+ styles.assistArrow,
842
+ below
843
+ ? { left: px - 9, top: py + 12 }
844
+ : { left: px - 9, top: py - 30, transform: [{ rotate: "180deg" }] },
845
+ ]}
846
+ />
847
+ {!!(assistantArrow.label || assistantArrow.instruction) && (
848
+ <View style={[styles.assistBubble, { left: 16, right: 16, [below ? "top" : "bottom"]: below ? py + 36 : (h || 0) - py + 36 } as any]}>
849
+ {!!assistantArrow.label && <Text style={styles.assistBubbleLabel}>{assistantArrow.label}</Text>}
850
+ {!!assistantArrow.instruction && <Text style={styles.assistBubbleText}>{assistantArrow.instruction}</Text>}
851
+ </View>
852
+ )}
853
+ </View>
854
+ );
855
+ })()}
856
+
857
+ {/* Floating help button — draggable, tap to open the AI voice guide.
858
+ This is the definitive trigger for Expo apps (no native shake module). */}
859
+ {!config.disableAssistant && config.showAssistButton !== false && !assistantActive && (
860
+ <Animated.View
861
+ style={[styles.assistFab, { transform: assistFabPan.getTranslateTransform() }]}
862
+ {...assistFabPanResponder.panHandlers}
863
+ >
864
+ <Pressable
865
+ onPress={() => startAssist()}
866
+ style={({ pressed }) => [styles.assistFabPressable, pressed && styles.btnPressed]}
867
+ hitSlop={8}
868
+ accessibilityRole="button"
869
+ accessibilityLabel="Open voice help"
870
+ >
871
+ <Text style={styles.assistFabIcon}>?</Text>
872
+ </Pressable>
873
+ </Animated.View>
874
+ )}
875
+
876
+ {/* Shake-to-Assist: bottom voice "wave" control + status. */}
877
+ {assistantActive && (
878
+ <View style={styles.assistDock} pointerEvents="box-none">
879
+ <View style={styles.assistDockCard}>
880
+ <VoiceWave phase={assistantPhase} />
881
+ <Text style={styles.assistStatusText}>{assistantStatusLabel(assistantPhase)}</Text>
882
+ <Pressable
883
+ onPress={endAssist}
884
+ style={({ pressed }) => [styles.assistClose, pressed && styles.btnPressed]}
885
+ hitSlop={10}
886
+ >
887
+ <Text style={styles.assistCloseText}>Done</Text>
888
+ </Pressable>
889
+ </View>
890
+ </View>
891
+ )}
627
892
  </View>
628
893
  </SessionReplayContext.Provider>
629
894
  );
630
895
  }
631
896
 
897
+ // ---- Shake-to-Assist UI bits ----
898
+ function assistantStatusLabel(phase: AssistantPhase): string {
899
+ switch (phase) {
900
+ case "connecting": return "Connecting…";
901
+ case "listening": return "Listening… ask me anything";
902
+ case "thinking": return "Looking at your screen…";
903
+ case "speaking": return "Speaking…";
904
+ case "error": return "Couldn't start — try again";
905
+ default: return "";
906
+ }
907
+ }
908
+
909
+ /** Animated 5-bar voice wave. Bars idle when not listening/speaking. */
910
+ function VoiceWave({ phase }: { phase: AssistantPhase }) {
911
+ const active = phase === "listening" || phase === "speaking";
912
+ const bars = useRef([0, 1, 2, 3, 4].map(() => new Animated.Value(0.3))).current;
913
+ useEffect(() => {
914
+ if (!active) {
915
+ bars.forEach((b) => Animated.timing(b, { toValue: 0.3, duration: 200, useNativeDriver: false }).start());
916
+ return;
917
+ }
918
+ const loops = bars.map((b, i) =>
919
+ Animated.loop(
920
+ Animated.sequence([
921
+ Animated.timing(b, { toValue: 1, duration: 300 + i * 60, useNativeDriver: false }),
922
+ Animated.timing(b, { toValue: 0.3, duration: 300 + i * 60, useNativeDriver: false }),
923
+ ])
924
+ )
925
+ );
926
+ loops.forEach((l) => l.start());
927
+ return () => loops.forEach((l) => l.stop());
928
+ }, [active, bars]);
929
+ return (
930
+ <View style={styles.waveRow}>
931
+ {bars.map((b, i) => (
932
+ <Animated.View
933
+ key={i}
934
+ style={[
935
+ styles.waveBar,
936
+ { transform: [{ scaleY: b }], opacity: active ? 1 : 0.5 },
937
+ ]}
938
+ />
939
+ ))}
940
+ </View>
941
+ );
942
+ }
943
+
632
944
  // ---- helpers ----
633
945
  async function uploadFrameAsync(
634
946
  serverUrl: string,
@@ -739,4 +1051,61 @@ const styles = StyleSheet.create({
739
1051
  position: "absolute", width: 48, height: 48, borderRadius: 24,
740
1052
  backgroundColor: "rgba(239,68,68,0.25)",
741
1053
  },
1054
+ // ---- Shake-to-Assist arrow + bubble ----
1055
+ assistHalo: {
1056
+ position: "absolute", width: 52, height: 52, borderRadius: 26,
1057
+ backgroundColor: "rgba(59,130,246,0.22)",
1058
+ },
1059
+ assistDot: {
1060
+ position: "absolute", width: 18, height: 18, borderRadius: 9,
1061
+ backgroundColor: "#3b82f6", borderWidth: 3, borderColor: "#fff",
1062
+ shadowColor: "#3b82f6", shadowOpacity: 0.9, shadowRadius: 8,
1063
+ },
1064
+ // A simple chevron-like arrow built from a rotated bordered box.
1065
+ assistArrow: {
1066
+ position: "absolute", width: 18, height: 18,
1067
+ borderRightWidth: 4, borderBottomWidth: 4, borderColor: "#3b82f6",
1068
+ transform: [{ rotate: "45deg" }],
1069
+ },
1070
+ assistBubble: {
1071
+ position: "absolute", backgroundColor: "rgba(17,24,39,0.94)",
1072
+ borderRadius: 12, paddingVertical: 10, paddingHorizontal: 14,
1073
+ shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 10, shadowOffset: { width: 0, height: 4 },
1074
+ },
1075
+ assistBubbleLabel: { color: "#93c5fd", fontSize: 12, fontWeight: "700", marginBottom: 2 },
1076
+ assistBubbleText: { color: "#fff", fontSize: 14, lineHeight: 19 },
1077
+ // ---- Shake-to-Assist bottom dock ----
1078
+ assistDock: {
1079
+ position: "absolute", left: 0, right: 0, bottom: 0,
1080
+ alignItems: "center", paddingBottom: 28, paddingHorizontal: 16,
1081
+ },
1082
+ assistDockCard: {
1083
+ flexDirection: "row", alignItems: "center",
1084
+ backgroundColor: "rgba(17,24,39,0.96)", borderRadius: 999,
1085
+ paddingVertical: 10, paddingHorizontal: 16, maxWidth: "100%",
1086
+ shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 12, shadowOffset: { width: 0, height: 6 },
1087
+ },
1088
+ assistStatusText: { color: "#e5e7eb", fontSize: 14, fontWeight: "600", marginHorizontal: 12, flexShrink: 1 },
1089
+ assistClose: {
1090
+ backgroundColor: "#3b82f6", borderRadius: 999,
1091
+ paddingVertical: 8, paddingHorizontal: 16, minHeight: 36, justifyContent: "center",
1092
+ },
1093
+ assistCloseText: { color: "#fff", fontSize: 14, fontWeight: "700" },
1094
+ assistFab: {
1095
+ position: "absolute", top: 96, right: 12,
1096
+ width: 44, height: 44, borderRadius: 22,
1097
+ shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 8, shadowOffset: { width: 0, height: 4 },
1098
+ elevation: 6,
1099
+ },
1100
+ assistFabPressable: {
1101
+ width: 44, height: 44, borderRadius: 22,
1102
+ backgroundColor: "#3b82f6",
1103
+ alignItems: "center", justifyContent: "center",
1104
+ borderWidth: 2, borderColor: "rgba(255,255,255,0.85)",
1105
+ },
1106
+ assistFabIcon: { color: "#fff", fontSize: 20, fontWeight: "800", lineHeight: 24 },
1107
+ waveRow: { flexDirection: "row", alignItems: "center", height: 28, width: 44 },
1108
+ waveBar: {
1109
+ width: 4, height: 22, marginHorizontal: 2, borderRadius: 2, backgroundColor: "#60a5fa",
1110
+ },
742
1111
  });
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 };