@akhilpulse/samadhaan-session-replay 0.4.3 → 0.5.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 +22 -3
- package/android/src/main/java/com/akhilpulse/sessionreplay/SessionReplayPcmPlayerModule.kt +119 -0
- package/android/src/main/java/com/akhilpulse/sessionreplay/SessionReplayPixelCopyPackage.kt +4 -1
- package/package.json +1 -1
- package/src/SessionReplayProvider.tsx +23 -4
- package/src/index.ts +2 -0
- package/src/voice.ts +69 -3
package/README.md
CHANGED
|
@@ -122,15 +122,34 @@ if a module is missing that capability is simply skipped (no crash):
|
|
|
122
122
|
| Capability | Package | If missing |
|
|
123
123
|
|---------------------------|------------------------------------|------------|
|
|
124
124
|
| Shake detection | `react-native-shake` | Use `triggerAssist()` manually |
|
|
125
|
-
| Microphone (voice input) | `react-native-live-audio-stream` |
|
|
126
|
-
| Agent voice playback |
|
|
125
|
+
| Microphone (voice input) | `react-native-live-audio-stream` | Voice bar shows "Mic unavailable" — assistant can still speak + draw arrows |
|
|
126
|
+
| Agent voice playback | **built-in on Android (v0.5.0+)**; custom `PcmAudioPlayer` optional | On iOS without a custom player, agent audio is dropped (arrows + transcript still work) |
|
|
127
127
|
|
|
128
128
|
```bash
|
|
129
129
|
npm install react-native-shake react-native-live-audio-stream
|
|
130
130
|
cd ios && pod install
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
#### Microphone permission (required for voice input)
|
|
134
|
+
|
|
135
|
+
- **Android** — declare in your app's `AndroidManifest.xml`:
|
|
136
|
+
```xml
|
|
137
|
+
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
138
|
+
```
|
|
139
|
+
Starting in **0.5.0 the SDK requests the runtime permission automatically**
|
|
140
|
+
the first time the assistant opens. If the user denies it, the voice bar
|
|
141
|
+
shows "Mic blocked — allow microphone access in Settings" instead of
|
|
142
|
+
pretending to listen.
|
|
143
|
+
- **iOS** — add `NSMicrophoneUsageDescription` to `Info.plist` or the OS will
|
|
144
|
+
kill the app when recording starts.
|
|
145
|
+
|
|
146
|
+
#### Agent voice playback
|
|
147
|
+
|
|
148
|
+
The agent streams **int16 mono PCM at 16 kHz**. Starting in **0.5.0 the SDK
|
|
149
|
+
ships a built-in Android AudioTrack player** (part of the bundled native
|
|
150
|
+
module — rebuild the Android app after upgrading). It's used automatically
|
|
151
|
+
when you don't pass `assistantAudioPlayer`. On iOS (or to override), plug in
|
|
152
|
+
your own player:
|
|
134
153
|
|
|
135
154
|
```tsx
|
|
136
155
|
import type { PcmAudioPlayer } from "@akhilpulse/samadhaan-session-replay";
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
package com.akhilpulse.sessionreplay
|
|
2
|
+
|
|
3
|
+
import android.media.AudioAttributes
|
|
4
|
+
import android.media.AudioFormat
|
|
5
|
+
import android.media.AudioTrack
|
|
6
|
+
import android.os.Handler
|
|
7
|
+
import android.os.HandlerThread
|
|
8
|
+
import android.util.Base64
|
|
9
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
10
|
+
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
11
|
+
import com.facebook.react.bridge.ReactMethod
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Built-in streaming PCM player for the Shake-to-Assist agent voice
|
|
15
|
+
* (v0.5.0+). Plays int16 mono PCM chunks (base64 over the bridge) through
|
|
16
|
+
* a single reused AudioTrack in streaming mode, so host apps get agent
|
|
17
|
+
* audio out of the box without wiring their own PcmAudioPlayer.
|
|
18
|
+
*
|
|
19
|
+
* All AudioTrack work happens on a dedicated HandlerThread — never on the
|
|
20
|
+
* React native-modules thread — because AudioTrack.write() in blocking
|
|
21
|
+
* mode can stall for the duration of the buffered audio.
|
|
22
|
+
*/
|
|
23
|
+
class SessionReplayPcmPlayerModule(reactContext: ReactApplicationContext) :
|
|
24
|
+
ReactContextBaseJavaModule(reactContext) {
|
|
25
|
+
|
|
26
|
+
override fun getName() = "SessionReplayPcmPlayer"
|
|
27
|
+
|
|
28
|
+
private var track: AudioTrack? = null
|
|
29
|
+
private var thread: HandlerThread? = null
|
|
30
|
+
private var handler: Handler? = null
|
|
31
|
+
|
|
32
|
+
private fun ensureHandler(): Handler {
|
|
33
|
+
val alive = thread
|
|
34
|
+
val h = handler
|
|
35
|
+
if (alive != null && alive.isAlive && h != null) return h
|
|
36
|
+
val nt = HandlerThread("SessionReplayPcmPlayer")
|
|
37
|
+
nt.start()
|
|
38
|
+
thread = nt
|
|
39
|
+
val nh = Handler(nt.looper)
|
|
40
|
+
handler = nh
|
|
41
|
+
return nh
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private fun releaseTrack() {
|
|
45
|
+
try { track?.stop() } catch (_: Throwable) {}
|
|
46
|
+
try { track?.release() } catch (_: Throwable) {}
|
|
47
|
+
track = null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@ReactMethod
|
|
51
|
+
fun start(sampleRateIn: Int) {
|
|
52
|
+
ensureHandler().post {
|
|
53
|
+
try {
|
|
54
|
+
releaseTrack()
|
|
55
|
+
val sampleRate = if (sampleRateIn > 0) sampleRateIn else 16000
|
|
56
|
+
val minBuf = AudioTrack.getMinBufferSize(
|
|
57
|
+
sampleRate, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT)
|
|
58
|
+
// ~0.5 s of 16-bit mono headroom, never below the platform minimum.
|
|
59
|
+
val bufBytes = maxOf(minBuf, sampleRate)
|
|
60
|
+
val t = AudioTrack.Builder()
|
|
61
|
+
.setAudioAttributes(
|
|
62
|
+
AudioAttributes.Builder()
|
|
63
|
+
.setUsage(AudioAttributes.USAGE_MEDIA)
|
|
64
|
+
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
|
65
|
+
.build())
|
|
66
|
+
.setAudioFormat(
|
|
67
|
+
AudioFormat.Builder()
|
|
68
|
+
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
|
69
|
+
.setSampleRate(sampleRate)
|
|
70
|
+
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
|
71
|
+
.build())
|
|
72
|
+
.setBufferSizeInBytes(bufBytes)
|
|
73
|
+
.setTransferMode(AudioTrack.MODE_STREAM)
|
|
74
|
+
.build()
|
|
75
|
+
t.play()
|
|
76
|
+
track = t
|
|
77
|
+
} catch (_: Throwable) {
|
|
78
|
+
track = null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@ReactMethod
|
|
84
|
+
fun play(base64Pcm: String) {
|
|
85
|
+
ensureHandler().post {
|
|
86
|
+
val t = track ?: return@post
|
|
87
|
+
try {
|
|
88
|
+
val bytes = Base64.decode(base64Pcm, Base64.DEFAULT)
|
|
89
|
+
if (bytes.isNotEmpty()) t.write(bytes, 0, bytes.size)
|
|
90
|
+
} catch (_: Throwable) {}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Barge-in: drop any queued agent audio immediately. */
|
|
95
|
+
@ReactMethod
|
|
96
|
+
fun clear() {
|
|
97
|
+
ensureHandler().post {
|
|
98
|
+
val t = track ?: return@post
|
|
99
|
+
try {
|
|
100
|
+
t.pause()
|
|
101
|
+
t.flush()
|
|
102
|
+
t.play()
|
|
103
|
+
} catch (_: Throwable) {}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@ReactMethod
|
|
108
|
+
fun stop() {
|
|
109
|
+
ensureHandler().post { releaseTrack() }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
override fun invalidate() {
|
|
113
|
+
super.invalidate()
|
|
114
|
+
try { handler?.post { releaseTrack() } } catch (_: Throwable) {}
|
|
115
|
+
try { thread?.quitSafely() } catch (_: Throwable) {}
|
|
116
|
+
thread = null
|
|
117
|
+
handler = null
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -7,7 +7,10 @@ import com.facebook.react.uimanager.ViewManager
|
|
|
7
7
|
|
|
8
8
|
class SessionReplayPixelCopyPackage : ReactPackage {
|
|
9
9
|
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
|
10
|
-
listOf(
|
|
10
|
+
listOf(
|
|
11
|
+
SessionReplayPixelCopyModule(reactContext),
|
|
12
|
+
SessionReplayPcmPlayerModule(reactContext),
|
|
13
|
+
)
|
|
11
14
|
|
|
12
15
|
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
|
13
16
|
emptyList()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akhilpulse/samadhaan-session-replay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
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",
|
|
@@ -38,6 +38,8 @@ import { captureRef, captureScreen } from "react-native-view-shot";
|
|
|
38
38
|
import {
|
|
39
39
|
subscribeShake,
|
|
40
40
|
UltravoxVoiceSession,
|
|
41
|
+
createDefaultPcmPlayer,
|
|
42
|
+
type MicStatus,
|
|
41
43
|
type PcmAudioPlayer,
|
|
42
44
|
type VoiceState,
|
|
43
45
|
} from "./voice";
|
|
@@ -54,7 +56,7 @@ let pixelCopyDisabled = false; // turned true after repeated runtime failures to
|
|
|
54
56
|
let pixelCopyFailStreak = 0; // consecutive non-busy failures; resets on success
|
|
55
57
|
|
|
56
58
|
const STORAGE_KEY = "@akhilpulse/samadhaan-session-replay/sessionId";
|
|
57
|
-
const SDK_VERSION = "0.
|
|
59
|
+
const SDK_VERSION = "0.5.0";
|
|
58
60
|
|
|
59
61
|
// ---- Module-level session singleton --------------------------------------
|
|
60
62
|
// Multiple SessionReplayProvider mounts (intentional or via React StrictMode's
|
|
@@ -158,6 +160,7 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
158
160
|
|
|
159
161
|
// ---- Shake-to-Assist (AI voice guide) state ----
|
|
160
162
|
const [assistantPhase, setAssistantPhase] = useState<AssistantPhase>("idle");
|
|
163
|
+
const [assistMicStatus, setAssistMicStatus] = useState<MicStatus | null>(null);
|
|
161
164
|
const [assistantArrow, setAssistantArrow] = useState<
|
|
162
165
|
{ nx: number; ny: number; label?: string; instruction?: string } | null
|
|
163
166
|
>(null);
|
|
@@ -567,6 +570,7 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
567
570
|
try { voice?.stop(); } catch {}
|
|
568
571
|
setAssistantArrow(null);
|
|
569
572
|
setAssistantPhase("idle");
|
|
573
|
+
setAssistMicStatus(null);
|
|
570
574
|
if (asid && sessionId && ingestToken) {
|
|
571
575
|
fetch(`${serverUrl}/api/guided-support/assistant/end`, {
|
|
572
576
|
method: "POST",
|
|
@@ -610,15 +614,20 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
610
614
|
return;
|
|
611
615
|
}
|
|
612
616
|
assistantSessionIdRef.current = j.assistantSessionId;
|
|
617
|
+
setAssistMicStatus(null);
|
|
613
618
|
const voice = new UltravoxVoiceSession({
|
|
614
619
|
joinUrl: j.joinUrl,
|
|
615
620
|
sampleRate: 16000,
|
|
616
|
-
|
|
621
|
+
// Prefer the host-supplied player; otherwise fall back to the SDK's
|
|
622
|
+
// built-in Android AudioTrack player (v0.5.0+) so the agent's voice
|
|
623
|
+
// is audible out of the box.
|
|
624
|
+
audioPlayer: config.assistantAudioPlayer ?? createDefaultPcmPlayer() ?? undefined,
|
|
617
625
|
onState: (s) => {
|
|
618
626
|
// Natural socket close ("ended") tears the whole assistant down.
|
|
619
627
|
if (s === "ended") { endAssist(); return; }
|
|
620
628
|
setAssistantPhase(s);
|
|
621
629
|
},
|
|
630
|
+
onMicStatus: (status) => setAssistMicStatus(status),
|
|
622
631
|
onError: () => setAssistantPhase("error"),
|
|
623
632
|
});
|
|
624
633
|
voiceRef.current = voice;
|
|
@@ -903,7 +912,9 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
903
912
|
<View style={styles.assistDock} pointerEvents="box-none">
|
|
904
913
|
<View style={styles.assistDockCard}>
|
|
905
914
|
<VoiceWave phase={assistantPhase} />
|
|
906
|
-
<Text style={styles.assistStatusText}>
|
|
915
|
+
<Text style={styles.assistStatusText}>
|
|
916
|
+
{assistantStatusLabel(assistantPhase, assistMicStatus)}
|
|
917
|
+
</Text>
|
|
907
918
|
<Pressable
|
|
908
919
|
onPress={endAssist}
|
|
909
920
|
style={({ pressed }) => [styles.assistClose, pressed && styles.btnPressed]}
|
|
@@ -920,7 +931,15 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
920
931
|
}
|
|
921
932
|
|
|
922
933
|
// ---- Shake-to-Assist UI bits ----
|
|
923
|
-
function assistantStatusLabel(phase: AssistantPhase): string {
|
|
934
|
+
function assistantStatusLabel(phase: AssistantPhase, micStatus?: MicStatus | null): string {
|
|
935
|
+
// Be honest when we can't actually hear the user — a fake "Listening…"
|
|
936
|
+
// with a dead mic is the worst possible UX (bar stuck forever).
|
|
937
|
+
if (phase === "listening" && micStatus === "permission_denied") {
|
|
938
|
+
return "Mic blocked — allow microphone access in Settings";
|
|
939
|
+
}
|
|
940
|
+
if (phase === "listening" && micStatus === "unavailable") {
|
|
941
|
+
return "Mic unavailable — voice input is off";
|
|
942
|
+
}
|
|
924
943
|
switch (phase) {
|
|
925
944
|
case "connecting": return "Connecting…";
|
|
926
945
|
case "listening": return "Listening… ask me anything";
|
package/src/index.ts
CHANGED
package/src/voice.ts
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* matching server/ultravox.ts (serverWebSocket inputSampleRate/outputSampleRate).
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { NativeModules, PermissionsAndroid, Platform } from "react-native";
|
|
25
|
+
|
|
24
26
|
// Loads an optional native module without crashing Metro when it's absent.
|
|
25
27
|
function loadOptional<T = any>(name: string): T | null {
|
|
26
28
|
try {
|
|
@@ -31,6 +33,34 @@ function loadOptional<T = any>(name: string): T | null {
|
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
// ---- Mic permission --------------------------------------------------------
|
|
37
|
+
/**
|
|
38
|
+
* Android requires a runtime RECORD_AUDIO grant — react-native-live-audio-stream
|
|
39
|
+
* does NOT request it itself; without it the recorder silently captures nothing
|
|
40
|
+
* and the assistant can never hear the user. iOS shows its own prompt when the
|
|
41
|
+
* recorder starts (host app must have NSMicrophoneUsageDescription in Info.plist).
|
|
42
|
+
*/
|
|
43
|
+
async function ensureMicPermission(): Promise<boolean> {
|
|
44
|
+
if (Platform.OS !== "android") return true;
|
|
45
|
+
try {
|
|
46
|
+
const perm = PermissionsAndroid.PERMISSIONS.RECORD_AUDIO;
|
|
47
|
+
const already = await PermissionsAndroid.check(perm);
|
|
48
|
+
if (already) return true;
|
|
49
|
+
const res = await PermissionsAndroid.request(perm, {
|
|
50
|
+
title: "Microphone access",
|
|
51
|
+
message: "Needed so the voice assistant can hear you.",
|
|
52
|
+
buttonPositive: "Allow",
|
|
53
|
+
buttonNegative: "Not now",
|
|
54
|
+
});
|
|
55
|
+
return res === PermissionsAndroid.RESULTS.GRANTED;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Why voice input isn't working (surfaced so the UI can tell the user). */
|
|
62
|
+
export type MicStatus = "ok" | "permission_denied" | "unavailable";
|
|
63
|
+
|
|
34
64
|
// ---- Shake detection -----------------------------------------------------
|
|
35
65
|
/**
|
|
36
66
|
* Subscribe to phone-shake events. Returns an unsubscribe function.
|
|
@@ -106,6 +136,8 @@ export type StartVoiceOpts = {
|
|
|
106
136
|
/** Live transcript text (both user + agent), with role + final flag. */
|
|
107
137
|
onTranscript?: (t: { role: string; text: string; final: boolean }) => void;
|
|
108
138
|
onError?: (e: Error) => void;
|
|
139
|
+
/** Reports whether the microphone actually started (module + permission). */
|
|
140
|
+
onMicStatus?: (status: MicStatus) => void;
|
|
109
141
|
};
|
|
110
142
|
|
|
111
143
|
/**
|
|
@@ -140,7 +172,7 @@ export class UltravoxVoiceSession {
|
|
|
140
172
|
ws.onopen = () => {
|
|
141
173
|
if (this.stopped) return;
|
|
142
174
|
this.opts.audioPlayer?.start?.(this.sampleRate);
|
|
143
|
-
this.startMic();
|
|
175
|
+
void this.startMic(); // async: requests mic permission, reports onMicStatus
|
|
144
176
|
this.opts.onState?.("listening");
|
|
145
177
|
};
|
|
146
178
|
ws.onmessage = (ev: any) => {
|
|
@@ -184,9 +216,21 @@ export class UltravoxVoiceSession {
|
|
|
184
216
|
}
|
|
185
217
|
}
|
|
186
218
|
|
|
187
|
-
private startMic() {
|
|
219
|
+
private async startMic() {
|
|
188
220
|
const audioMod: any = loadOptional("react-native-live-audio-stream");
|
|
189
|
-
if (!audioMod)
|
|
221
|
+
if (!audioMod) {
|
|
222
|
+
// No mic module — assistant can still speak/guide, but tell the UI.
|
|
223
|
+
if (__DEV__) console.warn("[session-replay] react-native-live-audio-stream not installed — voice input disabled");
|
|
224
|
+
this.opts.onMicStatus?.("unavailable");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const granted = await ensureMicPermission();
|
|
228
|
+
if (this.stopped) return;
|
|
229
|
+
if (!granted) {
|
|
230
|
+
if (__DEV__) console.warn("[session-replay] RECORD_AUDIO permission denied — voice input disabled");
|
|
231
|
+
this.opts.onMicStatus?.("permission_denied");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
190
234
|
const LiveAudioStream = audioMod.default || audioMod;
|
|
191
235
|
try {
|
|
192
236
|
LiveAudioStream.init({
|
|
@@ -209,9 +253,12 @@ export class UltravoxVoiceSession {
|
|
|
209
253
|
LiveAudioStream.start();
|
|
210
254
|
this.audioStream = LiveAudioStream;
|
|
211
255
|
this.micActive = true;
|
|
256
|
+
this.opts.onMicStatus?.("ok");
|
|
212
257
|
} catch (e) {
|
|
213
258
|
// Mic failed — keep the session alive for agent audio + arrows.
|
|
259
|
+
if (__DEV__) console.warn("[session-replay] mic capture failed to start:", e);
|
|
214
260
|
this.audioStream = null;
|
|
261
|
+
this.opts.onMicStatus?.("unavailable");
|
|
215
262
|
}
|
|
216
263
|
}
|
|
217
264
|
|
|
@@ -241,4 +288,23 @@ export class UltravoxVoiceSession {
|
|
|
241
288
|
}
|
|
242
289
|
}
|
|
243
290
|
|
|
291
|
+
// ---- Built-in agent-voice player (Android) ---------------------------------
|
|
292
|
+
/**
|
|
293
|
+
* Returns a PcmAudioPlayer backed by the SDK's bundled Android AudioTrack
|
|
294
|
+
* module (SessionReplayPcmPlayer, v0.5.0+), or null when the native module
|
|
295
|
+
* isn't available (iOS, Expo Go, or host app not rebuilt after upgrading).
|
|
296
|
+
* Used automatically when the integrator doesn't pass `assistantAudioPlayer`.
|
|
297
|
+
*/
|
|
298
|
+
export function createDefaultPcmPlayer(): PcmAudioPlayer | null {
|
|
299
|
+
if (Platform.OS !== "android") return null;
|
|
300
|
+
const native: any = (NativeModules as any).SessionReplayPcmPlayer;
|
|
301
|
+
if (!native || typeof native.play !== "function") return null;
|
|
302
|
+
return {
|
|
303
|
+
start: (sampleRate: number) => { try { native.start(sampleRate); } catch {} },
|
|
304
|
+
play: (pcm: ArrayBuffer) => { try { native.play(arrayBufferToBase64(pcm)); } catch {} },
|
|
305
|
+
clear: () => { try { native.clear(); } catch {} },
|
|
306
|
+
stop: () => { try { native.stop(); } catch {} },
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
244
310
|
export { arrayBufferToBase64 };
|