@akhilpulse/samadhaan-session-replay 0.5.0 → 0.6.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 +15 -9
- package/SamadhaanSessionReplay.podspec +17 -0
- package/ios/SessionReplayPcmPlayer.h +11 -0
- package/ios/SessionReplayPcmPlayer.m +161 -0
- package/package.json +3 -1
- package/src/SessionReplayProvider.tsx +3 -3
- package/src/voice.ts +5 -5
package/README.md
CHANGED
|
@@ -123,7 +123,7 @@ if a module is missing that capability is simply skipped (no crash):
|
|
|
123
123
|
|---------------------------|------------------------------------|------------|
|
|
124
124
|
| Shake detection | `react-native-shake` | Use `triggerAssist()` manually |
|
|
125
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 |
|
|
126
|
+
| Agent voice playback | **built-in on Android (v0.5.0+) and iOS (v0.6.0+)**; custom `PcmAudioPlayer` optional | In Expo Go / stale builds without the native module, 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
|
|
@@ -145,11 +145,16 @@ cd ios && pod install
|
|
|
145
145
|
|
|
146
146
|
#### Agent voice playback
|
|
147
147
|
|
|
148
|
-
The agent streams **int16 mono PCM at 16 kHz**.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
148
|
+
The agent streams **int16 mono PCM at 16 kHz**. The SDK ships built-in native
|
|
149
|
+
players used automatically when you don't pass `assistantAudioPlayer`:
|
|
150
|
+
|
|
151
|
+
- **Android (0.5.0+)** — AudioTrack streaming player. Rebuild the app after
|
|
152
|
+
upgrading (`cd android && ./gradlew clean && cd .. && npx react-native run-android`).
|
|
153
|
+
- **iOS (0.6.0+)** — AVAudioEngine player (autolinked via the bundled
|
|
154
|
+
`SamadhaanSessionReplay` podspec). After upgrading run
|
|
155
|
+
`cd ios && pod install`, then rebuild the app.
|
|
156
|
+
|
|
157
|
+
To override with your own player:
|
|
153
158
|
|
|
154
159
|
```tsx
|
|
155
160
|
import type { PcmAudioPlayer } from "@akhilpulse/samadhaan-session-replay";
|
|
@@ -164,14 +169,15 @@ const myPlayer: PcmAudioPlayer = {
|
|
|
164
169
|
<SessionReplayProvider config={{ appId: "app_xxx", assistantAudioPlayer: myPlayer }}>
|
|
165
170
|
```
|
|
166
171
|
|
|
167
|
-
After installing native deps
|
|
168
|
-
|
|
172
|
+
After installing native deps, **rebuild the app** — Android:
|
|
173
|
+
`cd android && ./gradlew clean && cd .. && npx react-native run-android`;
|
|
174
|
+
iOS: `cd ios && pod install && cd .. && npx react-native run-ios`.
|
|
169
175
|
|
|
170
176
|
## What it does
|
|
171
177
|
|
|
172
178
|
- Creates a session against `POST /api/guided-support/sessions` and persists `sessionId` in AsyncStorage so it can be resumed after relaunch.
|
|
173
179
|
- Captures gestures (tap, swipe) using a passive PanResponder + onTouchEnd bubble listener — host taps still work.
|
|
174
|
-
- Captures screenshots every 10 s in background mode, every
|
|
180
|
+
- Captures screenshots every 10 s in background mode, every 500 ms (~2 fps) in live/assistant mode, via `react-native-view-shot`. Uploads via `POST /api/guided-support/storage/uploads/request-url` then PUTs to the returned URL.
|
|
175
181
|
- Auto-connects to `WS /ws/guided-support?sessionId=…&role=device&token=…` when the dashboard flips the session to `live`.
|
|
176
182
|
- Streams binary frames as `[ts u32 BE | width u16 BE | height u16 BE] + JPEG bytes`.
|
|
177
183
|
- Renders a remote-control overlay that visualizes incoming taps. Native touch dispatch (iOS UIEvent / Android AccessibilityService) is wired by the integrator via a small custom native module.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = "SamadhaanSessionReplay"
|
|
7
|
+
s.version = package["version"]
|
|
8
|
+
s.summary = package["description"]
|
|
9
|
+
s.homepage = package["homepage"]
|
|
10
|
+
s.license = { :type => package["license"] || "UNLICENSED" }
|
|
11
|
+
s.authors = { "Akhil / Pulse Energy" => "support@pulseenergy.io" }
|
|
12
|
+
s.platforms = { :ios => "12.0" }
|
|
13
|
+
s.source = { :git => "https://samadhaan.pulseenergy.io", :tag => s.version.to_s }
|
|
14
|
+
s.source_files = "ios/**/*.{h,m,mm}"
|
|
15
|
+
s.frameworks = "AVFoundation"
|
|
16
|
+
s.dependency "React-Core"
|
|
17
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#import <React/RCTBridgeModule.h>
|
|
2
|
+
#import <React/RCTInvalidating.h>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Streaming PCM16 player for the Shake-to-Assist agent voice (iOS).
|
|
6
|
+
* Mirrors the Android SessionReplayPcmPlayerModule: start(sampleRate),
|
|
7
|
+
* play(base64 int16 mono chunks), clear() for barge-in, stop().
|
|
8
|
+
* Backed by AVAudioEngine + AVAudioPlayerNode.
|
|
9
|
+
*/
|
|
10
|
+
@interface SessionReplayPcmPlayer : NSObject <RCTBridgeModule, RCTInvalidating>
|
|
11
|
+
@end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#import "SessionReplayPcmPlayer.h"
|
|
2
|
+
#import <React/RCTLog.h>
|
|
3
|
+
#import <AVFoundation/AVFoundation.h>
|
|
4
|
+
|
|
5
|
+
@implementation SessionReplayPcmPlayer {
|
|
6
|
+
AVAudioEngine *_engine;
|
|
7
|
+
AVAudioPlayerNode *_player;
|
|
8
|
+
AVAudioFormat *_format;
|
|
9
|
+
double _sampleRate;
|
|
10
|
+
BOOL _running;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
RCT_EXPORT_MODULE(SessionReplayPcmPlayer);
|
|
14
|
+
|
|
15
|
+
// All calls run on a dedicated serial queue so bridge threads are never blocked
|
|
16
|
+
// and engine state is never touched concurrently.
|
|
17
|
+
- (dispatch_queue_t)methodQueue
|
|
18
|
+
{
|
|
19
|
+
static dispatch_queue_t queue;
|
|
20
|
+
static dispatch_once_t onceToken;
|
|
21
|
+
dispatch_once(&onceToken, ^{
|
|
22
|
+
queue = dispatch_queue_create("com.akhilpulse.sessionreplay.pcmplayer", DISPATCH_QUEUE_SERIAL);
|
|
23
|
+
});
|
|
24
|
+
return queue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
+ (BOOL)requiresMainQueueSetup
|
|
28
|
+
{
|
|
29
|
+
return NO;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
- (void)setUpEngineWithSampleRate:(double)sampleRate
|
|
33
|
+
{
|
|
34
|
+
[self tearDownEngine];
|
|
35
|
+
|
|
36
|
+
// Voice assistant runs duplex (mic capture via react-native-live-audio-stream
|
|
37
|
+
// + agent playback here, simultaneously), so the session must stay
|
|
38
|
+
// record-capable. Only touch the category if it isn't already PlayAndRecord —
|
|
39
|
+
// and NEVER downgrade to a playback-only category, which would silently kill
|
|
40
|
+
// the mic while the UI still says "Listening".
|
|
41
|
+
AVAudioSession *session = [AVAudioSession sharedInstance];
|
|
42
|
+
if (![session.category isEqualToString:AVAudioSessionCategoryPlayAndRecord]) {
|
|
43
|
+
NSError *err = nil;
|
|
44
|
+
[session setCategory:AVAudioSessionCategoryPlayAndRecord
|
|
45
|
+
withOptions:(AVAudioSessionCategoryOptionDefaultToSpeaker |
|
|
46
|
+
AVAudioSessionCategoryOptionAllowBluetooth |
|
|
47
|
+
AVAudioSessionCategoryOptionMixWithOthers)
|
|
48
|
+
error:&err];
|
|
49
|
+
if (err) {
|
|
50
|
+
// Another library owns the session config — log and continue with
|
|
51
|
+
// whatever is active rather than fighting over it.
|
|
52
|
+
RCTLogWarn(@"[session-replay] could not set AVAudioSession category: %@", err);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
NSError *activeErr = nil;
|
|
56
|
+
[session setActive:YES error:&activeErr];
|
|
57
|
+
if (activeErr) {
|
|
58
|
+
RCTLogWarn(@"[session-replay] could not activate AVAudioSession: %@", activeErr);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_sampleRate = sampleRate > 0 ? sampleRate : 16000.0;
|
|
62
|
+
_engine = [[AVAudioEngine alloc] init];
|
|
63
|
+
_player = [[AVAudioPlayerNode alloc] init];
|
|
64
|
+
// Float32 non-interleaved mono — AVAudioPlayerNode's native scheduling format.
|
|
65
|
+
_format = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32
|
|
66
|
+
sampleRate:_sampleRate
|
|
67
|
+
channels:1
|
|
68
|
+
interleaved:NO];
|
|
69
|
+
[_engine attachNode:_player];
|
|
70
|
+
[_engine connect:_player to:_engine.mainMixerNode format:_format];
|
|
71
|
+
|
|
72
|
+
NSError *startErr = nil;
|
|
73
|
+
if (![_engine startAndReturnError:&startErr]) {
|
|
74
|
+
RCTLogWarn(@"[session-replay] PCM player engine failed to start: %@", startErr);
|
|
75
|
+
[self tearDownEngine];
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
[_player play];
|
|
79
|
+
_running = YES;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
- (void)tearDownEngine
|
|
83
|
+
{
|
|
84
|
+
if (_player) {
|
|
85
|
+
@try { [_player stop]; } @catch (__unused NSException *e) {}
|
|
86
|
+
}
|
|
87
|
+
if (_engine) {
|
|
88
|
+
@try { [_engine stop]; } @catch (__unused NSException *e) {}
|
|
89
|
+
}
|
|
90
|
+
_player = nil;
|
|
91
|
+
_engine = nil;
|
|
92
|
+
_format = nil;
|
|
93
|
+
_running = NO;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
RCT_EXPORT_METHOD(start:(double)sampleRate)
|
|
97
|
+
{
|
|
98
|
+
[self setUpEngineWithSampleRate:sampleRate];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
RCT_EXPORT_METHOD(play:(NSString *)base64Pcm)
|
|
102
|
+
{
|
|
103
|
+
if (!base64Pcm.length) return;
|
|
104
|
+
if (!_running) {
|
|
105
|
+
// Host called play() before start() (or engine failed) — try a lazy start
|
|
106
|
+
// at the default assistant rate rather than dropping audio silently.
|
|
107
|
+
[self setUpEngineWithSampleRate:(_sampleRate > 0 ? _sampleRate : 16000.0)];
|
|
108
|
+
if (!_running) return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
NSData *pcm = [[NSData alloc] initWithBase64EncodedString:base64Pcm
|
|
112
|
+
options:NSDataBase64DecodingIgnoreUnknownCharacters];
|
|
113
|
+
if (!pcm || pcm.length < 2) return;
|
|
114
|
+
|
|
115
|
+
AVAudioFrameCount frames = (AVAudioFrameCount)(pcm.length / 2);
|
|
116
|
+
AVAudioPCMBuffer *buffer = [[AVAudioPCMBuffer alloc] initWithPCMFormat:_format
|
|
117
|
+
frameCapacity:frames];
|
|
118
|
+
if (!buffer) return;
|
|
119
|
+
buffer.frameLength = frames;
|
|
120
|
+
|
|
121
|
+
const int16_t *src = (const int16_t *)pcm.bytes;
|
|
122
|
+
float *dst = buffer.floatChannelData[0];
|
|
123
|
+
for (AVAudioFrameCount i = 0; i < frames; i++) {
|
|
124
|
+
dst[i] = (float)src[i] / 32768.0f;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Engine can die on route changes (headphones unplugged) — restart once.
|
|
128
|
+
if (!_engine.isRunning) {
|
|
129
|
+
NSError *err = nil;
|
|
130
|
+
if (![_engine startAndReturnError:&err]) return;
|
|
131
|
+
[_player play];
|
|
132
|
+
}
|
|
133
|
+
[_player scheduleBuffer:buffer completionHandler:nil];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
RCT_EXPORT_METHOD(clear)
|
|
137
|
+
{
|
|
138
|
+
// Barge-in: drop everything queued, keep the node ready for new chunks.
|
|
139
|
+
if (!_running || !_player) return;
|
|
140
|
+
@try {
|
|
141
|
+
[_player stop]; // discards all scheduled buffers
|
|
142
|
+
if (_engine.isRunning) [_player play];
|
|
143
|
+
} @catch (__unused NSException *e) {}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
RCT_EXPORT_METHOD(stop)
|
|
147
|
+
{
|
|
148
|
+
[self tearDownEngine];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
- (void)invalidate
|
|
152
|
+
{
|
|
153
|
+
[self tearDownEngine];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
- (void)dealloc
|
|
157
|
+
{
|
|
158
|
+
[self tearDownEngine];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@end
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akhilpulse/samadhaan-session-replay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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",
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"src",
|
|
12
12
|
"android",
|
|
13
|
+
"ios",
|
|
14
|
+
"SamadhaanSessionReplay.podspec",
|
|
13
15
|
"react-native.config.js",
|
|
14
16
|
"README.md"
|
|
15
17
|
],
|
|
@@ -56,7 +56,7 @@ let pixelCopyDisabled = false; // turned true after repeated runtime failures to
|
|
|
56
56
|
let pixelCopyFailStreak = 0; // consecutive non-busy failures; resets on success
|
|
57
57
|
|
|
58
58
|
const STORAGE_KEY = "@akhilpulse/samadhaan-session-replay/sessionId";
|
|
59
|
-
const SDK_VERSION = "0.
|
|
59
|
+
const SDK_VERSION = "0.6.1";
|
|
60
60
|
|
|
61
61
|
// ---- Module-level session singleton --------------------------------------
|
|
62
62
|
// Multiple SessionReplayProvider mounts (intentional or via React StrictMode's
|
|
@@ -370,9 +370,9 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
370
370
|
// waste CPU/battery/network on periodic screenshots.
|
|
371
371
|
if (config.disableScreenshots || !sessionId || !streamActive) return;
|
|
372
372
|
let stopped = false;
|
|
373
|
-
// ~
|
|
373
|
+
// ~2 fps target — low device load while still feeling responsive. Capture
|
|
374
374
|
// takes time, so the loop self-paces: next tick is scheduled AFTER the previous capture completes.
|
|
375
|
-
const liveTargetMs =
|
|
375
|
+
const liveTargetMs = 500;
|
|
376
376
|
let lastUploadFrameAt = 0;
|
|
377
377
|
const tick = async () => {
|
|
378
378
|
if (stopped) return;
|
package/src/voice.ts
CHANGED
|
@@ -288,15 +288,15 @@ export class UltravoxVoiceSession {
|
|
|
288
288
|
}
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
-
// ---- Built-in agent-voice player (Android)
|
|
291
|
+
// ---- Built-in agent-voice player (Android + iOS) ----------------------------
|
|
292
292
|
/**
|
|
293
|
-
* Returns a PcmAudioPlayer backed by the SDK's bundled
|
|
294
|
-
*
|
|
295
|
-
* isn't available (
|
|
293
|
+
* Returns a PcmAudioPlayer backed by the SDK's bundled native module
|
|
294
|
+
* (SessionReplayPcmPlayer): AudioTrack on Android (v0.5.0+), AVAudioEngine on
|
|
295
|
+
* iOS (v0.6.0+). Returns null when the native module isn't available (Expo Go,
|
|
296
|
+
* or host app not rebuilt / `pod install` not run after upgrading).
|
|
296
297
|
* Used automatically when the integrator doesn't pass `assistantAudioPlayer`.
|
|
297
298
|
*/
|
|
298
299
|
export function createDefaultPcmPlayer(): PcmAudioPlayer | null {
|
|
299
|
-
if (Platform.OS !== "android") return null;
|
|
300
300
|
const native: any = (NativeModules as any).SessionReplayPcmPlayer;
|
|
301
301
|
if (!native || typeof native.play !== "function") return null;
|
|
302
302
|
return {
|