@akhilpulse/samadhaan-session-replay 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 | On iOS without a custom player, agent audio is dropped (arrows + transcript still work) |
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**. 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:
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,8 +169,9 @@ const myPlayer: PcmAudioPlayer = {
164
169
  <SessionReplayProvider config={{ appId: "app_xxx", assistantAudioPlayer: myPlayer }}>
165
170
  ```
166
171
 
167
- After installing native deps on Android, **rebuild the app**
168
- (`cd android && ./gradlew clean && cd .. && npx react-native run-android`).
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
 
@@ -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.5.0",
3
+ "version": "0.6.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",
@@ -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.5.0";
59
+ const SDK_VERSION = "0.6.0";
60
60
 
61
61
  // ---- Module-level session singleton --------------------------------------
62
62
  // Multiple SessionReplayProvider mounts (intentional or via React StrictMode's
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 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).
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 {