@akhilpulse/samadhaan-session-replay 0.4.2 → 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 +23 -3
- package/android/src/main/java/com/akhilpulse/sessionreplay/SessionReplayPcmPlayerModule.kt +119 -0
- package/android/src/main/java/com/akhilpulse/sessionreplay/SessionReplayPixelCopyModule.kt +182 -38
- package/android/src/main/java/com/akhilpulse/sessionreplay/SessionReplayPixelCopyPackage.kt +4 -1
- package/package.json +1 -1
- package/src/SessionReplayProvider.tsx +53 -10
- 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";
|
|
@@ -167,6 +186,7 @@ After installing native deps on Android, **rebuild the app**
|
|
|
167
186
|
4. Pause screenshot capture while the iOS keyboard is visible.
|
|
168
187
|
5. Auto-mount the guided-tour overlay inside the provider, and singleton-guard it.
|
|
169
188
|
6. **Android elevated views & screenshots.** `react-native-view-shot` uses a software canvas draw on Android (`view.draw(canvas)`) which silently skips any view promoted to a hardware compositor layer (anything with `elevation > 0` or `zIndex > 0` — RN implements `zIndex` via `setElevation` on Android). To work around this, **starting in 0.3.0 the SDK ships its own native module that captures via Android's `PixelCopy` API** (real on-screen pixels, including hardware layers, modals, FABs, and native `SurfaceView`s like `react-native-maps`). This module is autolinked — **after upgrading the SDK on Android you must rebuild the app** (`cd android && ./gradlew clean && cd .. && npx react-native run-android`) for the new screenshot path to activate. If the native module isn't present at runtime (older build, Expo Go), the SDK transparently falls back to `captureScreen` and the elevation limitation re-applies. Requires Android 7.0+ (API 24).
|
|
189
|
+
7. **GPU-busy crash hardening (0.4.3).** On some Adreno devices, queueing PixelCopy readbacks while the GPU is saturated (maps / navigation animations) can crash the host app inside the vendor driver (`vulkan.adreno.so`) — a native fault that cannot be caught from Java or JS. Starting in 0.4.3 the native module: captures directly into a small (target-width) reused bitmap instead of a full-screen one (~20x smaller GPU readback per frame); reuses a single handler thread instead of spawning one per frame; allows only ONE capture in flight at a time (a busy driver makes the JS loop skip frames via `E_BUSY` rather than pile up readbacks); and keeps a crash-loop breaker — if the process previously died mid-capture twice, PixelCopy is permanently disabled on that device (`E_DISABLED`) and the SDK falls back to view-shot instead of crashing the app again. **Rebuild the Android app after upgrading** for these fixes to take effect.
|
|
170
190
|
|
|
171
191
|
## Publishing
|
|
172
192
|
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package com.akhilpulse.sessionreplay
|
|
2
2
|
|
|
3
3
|
import android.app.Activity
|
|
4
|
+
import android.content.Context
|
|
4
5
|
import android.graphics.Bitmap
|
|
5
6
|
import android.os.Build
|
|
6
7
|
import android.os.Handler
|
|
@@ -25,6 +26,27 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|
|
25
26
|
*
|
|
26
27
|
* Requires API 24 (Android 7.0). Falls back to view-shot in JS otherwise.
|
|
27
28
|
*
|
|
29
|
+
* Efficiency / crash-hardening notes (v0.4.3):
|
|
30
|
+
* - PixelCopy scales into the destination bitmap, so we capture DIRECTLY
|
|
31
|
+
* into a small (targetWidth-sized) bitmap instead of a full-screen
|
|
32
|
+
* ARGB_8888 one (~10 MB/frame at 1080p, 4 fps). This cuts the GPU
|
|
33
|
+
* readback size ~20x, which both saves battery and dramatically narrows
|
|
34
|
+
* the window in which a busy GPU driver (maps, nav animations) can fault.
|
|
35
|
+
* - The destination bitmap and the HandlerThread are reused across frames
|
|
36
|
+
* instead of being allocated/started/torn down 4x per second.
|
|
37
|
+
* - Only ONE capture may be in flight at a time. If JS asks for a frame
|
|
38
|
+
* while the previous PixelCopy is still queued in the driver (typical
|
|
39
|
+
* when the GPU is saturated), we reject with E_BUSY and JS simply skips
|
|
40
|
+
* that frame — queueing readbacks against a saturated Adreno driver is
|
|
41
|
+
* the known crash scenario (native fault in vulkan.adreno.so, which
|
|
42
|
+
* cannot be caught from Java/Kotlin).
|
|
43
|
+
* - Crash-loop breaker: a SharedPreferences sentinel is set while a copy
|
|
44
|
+
* is in flight and cleared when it settles. If the process died mid-copy
|
|
45
|
+
* (i.e. the driver crashed the app), the next launch sees the sentinel,
|
|
46
|
+
* increments a crash counter, and after MAX_DRIVER_CRASHES the module
|
|
47
|
+
* permanently rejects with E_DISABLED on this device so JS uses the
|
|
48
|
+
* safe view-shot fallback instead of crashing the host app again.
|
|
49
|
+
*
|
|
28
50
|
* Note: Dialog windows (react-native `Modal`, react-native-modal, many
|
|
29
51
|
* bottom-sheet libraries) live on a separate Window and are not included
|
|
30
52
|
* in this capture. A future version will composite those in via reflection.
|
|
@@ -32,89 +54,211 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|
|
32
54
|
class SessionReplayPixelCopyModule(reactContext: ReactApplicationContext) :
|
|
33
55
|
ReactContextBaseJavaModule(reactContext) {
|
|
34
56
|
|
|
57
|
+
companion object {
|
|
58
|
+
private const val PREFS = "session_replay_pixelcopy"
|
|
59
|
+
private const val KEY_IN_FLIGHT = "capture_in_flight"
|
|
60
|
+
private const val KEY_DRIVER_CRASHES = "driver_crash_count"
|
|
61
|
+
private const val MAX_DRIVER_CRASHES = 2
|
|
62
|
+
private const val TIMEOUT_MS = 1200L
|
|
63
|
+
}
|
|
64
|
+
|
|
35
65
|
override fun getName(): String = "SessionReplayPixelCopy"
|
|
36
66
|
|
|
67
|
+
private val inFlight = AtomicBoolean(false)
|
|
68
|
+
|
|
69
|
+
// Lazily-created, reused across frames. Never torn down per capture.
|
|
70
|
+
@Volatile private var handlerThread: HandlerThread? = null
|
|
71
|
+
@Volatile private var handler: Handler? = null
|
|
72
|
+
|
|
73
|
+
// Reused destination bitmap (small: targetWidth x scaled height).
|
|
74
|
+
// Guarded by bitmapLock — touched from the UI thread (obtain), the capture
|
|
75
|
+
// handler thread (timeout orphaning) and arbitrary threads (invalidate).
|
|
76
|
+
private val bitmapLock = Any()
|
|
77
|
+
private var reusableBitmap: Bitmap? = null
|
|
78
|
+
|
|
79
|
+
// Cached crash-history verdict (null = not checked yet this process).
|
|
80
|
+
@Volatile private var disabledOnThisDevice: Boolean? = null
|
|
81
|
+
|
|
82
|
+
private fun prefs() =
|
|
83
|
+
reactApplicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Returns true when PixelCopy must not be used on this device because a
|
|
87
|
+
* previous process died while a copy was in flight (native driver crash).
|
|
88
|
+
*/
|
|
89
|
+
private fun isDisabledByCrashHistory(): Boolean {
|
|
90
|
+
disabledOnThisDevice?.let { return it }
|
|
91
|
+
synchronized(this) {
|
|
92
|
+
disabledOnThisDevice?.let { return it }
|
|
93
|
+
val p = prefs()
|
|
94
|
+
var crashes = p.getInt(KEY_DRIVER_CRASHES, 0)
|
|
95
|
+
if (p.getBoolean(KEY_IN_FLIGHT, false)) {
|
|
96
|
+
// Previous process died mid-capture — treat as a driver crash.
|
|
97
|
+
crashes += 1
|
|
98
|
+
p.edit()
|
|
99
|
+
.putInt(KEY_DRIVER_CRASHES, crashes)
|
|
100
|
+
.putBoolean(KEY_IN_FLIGHT, false)
|
|
101
|
+
.apply()
|
|
102
|
+
}
|
|
103
|
+
val disabled = crashes >= MAX_DRIVER_CRASHES
|
|
104
|
+
disabledOnThisDevice = disabled
|
|
105
|
+
return disabled
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private fun captureHandler(): Handler {
|
|
110
|
+
handler?.let { return it }
|
|
111
|
+
synchronized(this) {
|
|
112
|
+
handler?.let { return it }
|
|
113
|
+
val t = HandlerThread("SessionReplayPixelCopy")
|
|
114
|
+
t.start()
|
|
115
|
+
handlerThread = t
|
|
116
|
+
val h = Handler(t.looper)
|
|
117
|
+
handler = h
|
|
118
|
+
return h
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private fun obtainBitmap(w: Int, h: Int): Bitmap {
|
|
123
|
+
synchronized(bitmapLock) {
|
|
124
|
+
val existing = reusableBitmap
|
|
125
|
+
if (existing != null && !existing.isRecycled && existing.width == w && existing.height == h) {
|
|
126
|
+
return existing
|
|
127
|
+
}
|
|
128
|
+
existing?.recycle()
|
|
129
|
+
val fresh = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
|
130
|
+
reusableBitmap = fresh
|
|
131
|
+
return fresh
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Drop the cached bitmap WITHOUT recycling (a late driver write may still land in it). */
|
|
136
|
+
private fun orphanBitmap() {
|
|
137
|
+
synchronized(bitmapLock) { reusableBitmap = null }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private fun markInFlight(active: Boolean) {
|
|
141
|
+
try {
|
|
142
|
+
prefs().edit().putBoolean(KEY_IN_FLIGHT, active).apply()
|
|
143
|
+
} catch (_: Throwable) {}
|
|
144
|
+
}
|
|
145
|
+
|
|
37
146
|
@ReactMethod
|
|
38
147
|
fun capture(targetWidth: Double, quality: Double, promise: Promise) {
|
|
39
148
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
|
40
149
|
promise.reject("E_API", "PixelCopy requires Android 7.0+ (API 24)")
|
|
41
150
|
return
|
|
42
151
|
}
|
|
152
|
+
if (isDisabledByCrashHistory()) {
|
|
153
|
+
promise.reject(
|
|
154
|
+
"E_DISABLED",
|
|
155
|
+
"PixelCopy disabled on this device after previous GPU driver crashes")
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
// Single in-flight capture: if the driver is still working on the last
|
|
159
|
+
// readback (GPU busy — maps, animations), skip this frame instead of
|
|
160
|
+
// queueing more work against a saturated driver.
|
|
161
|
+
if (!inFlight.compareAndSet(false, true)) {
|
|
162
|
+
promise.reject("E_BUSY", "Previous capture still in flight")
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
43
166
|
val activity: Activity? = currentActivity
|
|
44
167
|
if (activity == null) {
|
|
168
|
+
inFlight.set(false)
|
|
45
169
|
promise.reject("E_NO_ACTIVITY", "No current activity")
|
|
46
170
|
return
|
|
47
171
|
}
|
|
48
172
|
|
|
49
173
|
activity.runOnUiThread {
|
|
50
174
|
val settled = AtomicBoolean(false)
|
|
51
|
-
val
|
|
52
|
-
handlerThread.start()
|
|
53
|
-
val handler = Handler(handlerThread.looper)
|
|
175
|
+
val h = captureHandler()
|
|
54
176
|
|
|
55
|
-
|
|
56
|
-
// bug on some vendor ROMs), reject after 1.5s instead of leaving the
|
|
57
|
-
// JS promise pending forever and freezing the capture loop.
|
|
58
|
-
handler.postDelayed({
|
|
177
|
+
fun settle(block: () -> Unit) {
|
|
59
178
|
if (settled.compareAndSet(false, true)) {
|
|
60
|
-
try {
|
|
61
|
-
|
|
179
|
+
try {
|
|
180
|
+
block()
|
|
181
|
+
} catch (_: Throwable) {}
|
|
182
|
+
markInFlight(false)
|
|
183
|
+
inFlight.set(false)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Safety net: if PixelCopy never invokes its callback (rare driver
|
|
188
|
+
// bug on some vendor ROMs), reject instead of leaving the JS promise
|
|
189
|
+
// pending forever and freezing the capture loop.
|
|
190
|
+
h.postDelayed({
|
|
191
|
+
settle {
|
|
192
|
+
// The driver may still write into the destination bitmap after a
|
|
193
|
+
// timeout — orphan it (without recycling) so the next capture
|
|
194
|
+
// allocates a fresh one instead of racing the late writer.
|
|
195
|
+
orphanBitmap()
|
|
196
|
+
promise.reject("E_TIMEOUT", "PixelCopy timed out")
|
|
62
197
|
}
|
|
63
|
-
},
|
|
198
|
+
}, TIMEOUT_MS)
|
|
64
199
|
|
|
65
200
|
try {
|
|
66
201
|
val window = activity.window
|
|
67
202
|
val decor = window.decorView
|
|
68
203
|
val srcW = decor.width
|
|
69
204
|
val srcH = decor.height
|
|
70
|
-
if (srcW <= 0 || srcH <= 0) {
|
|
71
|
-
|
|
72
|
-
promise.reject("E_NO_SIZE", "Decor view has zero size")
|
|
73
|
-
handlerThread.quitSafely()
|
|
74
|
-
}
|
|
205
|
+
if (srcW <= 0 || srcH <= 0 || !decor.isAttachedToWindow) {
|
|
206
|
+
settle { promise.reject("E_NO_SIZE", "Decor view not ready") }
|
|
75
207
|
return@runOnUiThread
|
|
76
208
|
}
|
|
77
|
-
|
|
209
|
+
|
|
210
|
+
// Capture directly at the downscaled size — PixelCopy scales the
|
|
211
|
+
// window contents to fit the destination bitmap, so there is no
|
|
212
|
+
// need for a full-resolution intermediate bitmap + scale pass.
|
|
213
|
+
val tw = targetWidth.toInt().coerceAtLeast(1).coerceAtMost(srcW)
|
|
214
|
+
val th = (srcH.toDouble() / srcW.toDouble() * tw).toInt().coerceAtLeast(1)
|
|
215
|
+
val bitmap = obtainBitmap(tw, th)
|
|
216
|
+
|
|
217
|
+
// Sentinel: if the process dies inside the driver during this copy,
|
|
218
|
+
// the next launch will see it and count a crash (see
|
|
219
|
+
// isDisabledByCrashHistory).
|
|
220
|
+
markInFlight(true)
|
|
78
221
|
|
|
79
222
|
PixelCopy.request(
|
|
80
223
|
window,
|
|
81
224
|
bitmap,
|
|
82
225
|
{ result ->
|
|
83
|
-
|
|
84
|
-
try {
|
|
226
|
+
settle {
|
|
85
227
|
if (result == PixelCopy.SUCCESS) {
|
|
86
|
-
val tw = targetWidth.toInt().coerceAtLeast(1)
|
|
87
|
-
val scaled = if (tw < srcW) {
|
|
88
|
-
val th = (srcH.toDouble() / srcW.toDouble() * tw).toInt().coerceAtLeast(1)
|
|
89
|
-
Bitmap.createScaledBitmap(bitmap, tw, th, true)
|
|
90
|
-
} else bitmap
|
|
91
|
-
|
|
92
228
|
val q = (quality * 100).toInt().coerceIn(1, 100)
|
|
93
229
|
val baos = ByteArrayOutputStream()
|
|
94
|
-
|
|
95
|
-
if (scaled !== bitmap) scaled.recycle()
|
|
96
|
-
bitmap.recycle()
|
|
97
|
-
|
|
230
|
+
bitmap.compress(Bitmap.CompressFormat.JPEG, q, baos)
|
|
98
231
|
val b64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
|
|
99
232
|
promise.resolve(b64)
|
|
100
233
|
} else {
|
|
101
|
-
bitmap.recycle()
|
|
102
234
|
promise.reject("E_PIXELCOPY", "PixelCopy failed: $result")
|
|
103
235
|
}
|
|
104
|
-
} catch (e: Throwable) {
|
|
105
|
-
promise.reject("E_INNER", e)
|
|
106
|
-
} finally {
|
|
107
|
-
handlerThread.quitSafely()
|
|
108
236
|
}
|
|
109
237
|
},
|
|
110
|
-
|
|
238
|
+
h
|
|
111
239
|
)
|
|
112
240
|
} catch (e: Throwable) {
|
|
113
|
-
|
|
114
|
-
promise.reject("E_OUTER", e)
|
|
115
|
-
handlerThread.quitSafely()
|
|
116
|
-
}
|
|
241
|
+
settle { promise.reject("E_OUTER", e) }
|
|
117
242
|
}
|
|
118
243
|
}
|
|
119
244
|
}
|
|
245
|
+
|
|
246
|
+
override fun invalidate() {
|
|
247
|
+
// Called when the React instance is torn down — release resources.
|
|
248
|
+
// Clear the crash sentinel: a teardown mid-capture is a CLEAN shutdown,
|
|
249
|
+
// not a driver crash, and must not count toward the crash-loop breaker.
|
|
250
|
+
if (inFlight.getAndSet(false)) {
|
|
251
|
+
markInFlight(false)
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
handlerThread?.quitSafely()
|
|
255
|
+
} catch (_: Throwable) {}
|
|
256
|
+
handlerThread = null
|
|
257
|
+
handler = null
|
|
258
|
+
synchronized(bitmapLock) {
|
|
259
|
+
// Don't recycle — a late driver write could still land in it.
|
|
260
|
+
reusableBitmap = null
|
|
261
|
+
}
|
|
262
|
+
super.invalidate()
|
|
263
|
+
}
|
|
120
264
|
}
|
|
@@ -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";
|
|
@@ -50,10 +52,11 @@ import {
|
|
|
50
52
|
type PixelCopyNative = { capture: (targetWidth: number, quality: number) => Promise<string> };
|
|
51
53
|
const SessionReplayPixelCopy: PixelCopyNative | undefined =
|
|
52
54
|
Platform.OS === "android" ? (NativeModules as any).SessionReplayPixelCopy : undefined;
|
|
53
|
-
let pixelCopyDisabled = false; // turned true after
|
|
55
|
+
let pixelCopyDisabled = false; // turned true after repeated runtime failures to avoid retrying every frame
|
|
56
|
+
let pixelCopyFailStreak = 0; // consecutive non-busy failures; resets on success
|
|
54
57
|
|
|
55
58
|
const STORAGE_KEY = "@akhilpulse/samadhaan-session-replay/sessionId";
|
|
56
|
-
const SDK_VERSION = "0.
|
|
59
|
+
const SDK_VERSION = "0.5.0";
|
|
57
60
|
|
|
58
61
|
// ---- Module-level session singleton --------------------------------------
|
|
59
62
|
// Multiple SessionReplayProvider mounts (intentional or via React StrictMode's
|
|
@@ -157,6 +160,7 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
157
160
|
|
|
158
161
|
// ---- Shake-to-Assist (AI voice guide) state ----
|
|
159
162
|
const [assistantPhase, setAssistantPhase] = useState<AssistantPhase>("idle");
|
|
163
|
+
const [assistMicStatus, setAssistMicStatus] = useState<MicStatus | null>(null);
|
|
160
164
|
const [assistantArrow, setAssistantArrow] = useState<
|
|
161
165
|
{ nx: number; ny: number; label?: string; instruction?: string } | null
|
|
162
166
|
>(null);
|
|
@@ -392,15 +396,32 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
392
396
|
result: "base64" as const,
|
|
393
397
|
width: 360,
|
|
394
398
|
};
|
|
395
|
-
let uri: string;
|
|
399
|
+
let uri: string | null = null;
|
|
396
400
|
if (Platform.OS === "android") {
|
|
397
401
|
if (SessionReplayPixelCopy && !pixelCopyDisabled) {
|
|
398
402
|
try {
|
|
399
403
|
uri = await SessionReplayPixelCopy.capture(360, 0.25);
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
+
pixelCopyFailStreak = 0;
|
|
405
|
+
} catch (err: any) {
|
|
406
|
+
const code = err?.code;
|
|
407
|
+
if (code === "E_BUSY") {
|
|
408
|
+
// Previous capture still in flight — the GPU is saturated
|
|
409
|
+
// (maps, nav animations). Skip this frame entirely rather
|
|
410
|
+
// than piling more readback work onto a busy driver.
|
|
411
|
+
uri = null;
|
|
412
|
+
} else {
|
|
413
|
+
pixelCopyFailStreak++;
|
|
414
|
+
if (code === "E_DISABLED" || code === "E_API" || pixelCopyFailStreak >= 3) {
|
|
415
|
+
// Native module says never use PixelCopy on this device
|
|
416
|
+
// (previous GPU driver crashes / API too old), or it has
|
|
417
|
+
// failed repeatedly — stop trying.
|
|
418
|
+
if (__DEV__) console.warn("[session-replay] PixelCopy disabled, using view-shot:", err);
|
|
419
|
+
pixelCopyDisabled = true;
|
|
420
|
+
} else if (__DEV__) {
|
|
421
|
+
console.warn("[session-replay] PixelCopy failed, falling back to view-shot for this frame:", err);
|
|
422
|
+
}
|
|
423
|
+
uri = await captureScreen(captureOpts);
|
|
424
|
+
}
|
|
404
425
|
}
|
|
405
426
|
} else {
|
|
406
427
|
uri = await captureScreen(captureOpts);
|
|
@@ -408,6 +429,12 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
408
429
|
} else {
|
|
409
430
|
uri = await captureRef(ref as any, captureOpts);
|
|
410
431
|
}
|
|
432
|
+
if (!uri) {
|
|
433
|
+
// Frame intentionally skipped (GPU busy) — try again next tick.
|
|
434
|
+
const elapsedSkip = Date.now() - startedAt;
|
|
435
|
+
setTimeout(tick, Math.max(0, liveTargetMs - elapsedSkip));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
411
438
|
const blob = base64ToUint8Array(uri);
|
|
412
439
|
// Stream binary over WS — no REST upload in the hot path.
|
|
413
440
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
@@ -543,6 +570,7 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
543
570
|
try { voice?.stop(); } catch {}
|
|
544
571
|
setAssistantArrow(null);
|
|
545
572
|
setAssistantPhase("idle");
|
|
573
|
+
setAssistMicStatus(null);
|
|
546
574
|
if (asid && sessionId && ingestToken) {
|
|
547
575
|
fetch(`${serverUrl}/api/guided-support/assistant/end`, {
|
|
548
576
|
method: "POST",
|
|
@@ -586,15 +614,20 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
586
614
|
return;
|
|
587
615
|
}
|
|
588
616
|
assistantSessionIdRef.current = j.assistantSessionId;
|
|
617
|
+
setAssistMicStatus(null);
|
|
589
618
|
const voice = new UltravoxVoiceSession({
|
|
590
619
|
joinUrl: j.joinUrl,
|
|
591
620
|
sampleRate: 16000,
|
|
592
|
-
|
|
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,
|
|
593
625
|
onState: (s) => {
|
|
594
626
|
// Natural socket close ("ended") tears the whole assistant down.
|
|
595
627
|
if (s === "ended") { endAssist(); return; }
|
|
596
628
|
setAssistantPhase(s);
|
|
597
629
|
},
|
|
630
|
+
onMicStatus: (status) => setAssistMicStatus(status),
|
|
598
631
|
onError: () => setAssistantPhase("error"),
|
|
599
632
|
});
|
|
600
633
|
voiceRef.current = voice;
|
|
@@ -879,7 +912,9 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
879
912
|
<View style={styles.assistDock} pointerEvents="box-none">
|
|
880
913
|
<View style={styles.assistDockCard}>
|
|
881
914
|
<VoiceWave phase={assistantPhase} />
|
|
882
|
-
<Text style={styles.assistStatusText}>
|
|
915
|
+
<Text style={styles.assistStatusText}>
|
|
916
|
+
{assistantStatusLabel(assistantPhase, assistMicStatus)}
|
|
917
|
+
</Text>
|
|
883
918
|
<Pressable
|
|
884
919
|
onPress={endAssist}
|
|
885
920
|
style={({ pressed }) => [styles.assistClose, pressed && styles.btnPressed]}
|
|
@@ -896,7 +931,15 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
896
931
|
}
|
|
897
932
|
|
|
898
933
|
// ---- Shake-to-Assist UI bits ----
|
|
899
|
-
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
|
+
}
|
|
900
943
|
switch (phase) {
|
|
901
944
|
case "connecting": return "Connecting…";
|
|
902
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 };
|