@qafka/react-native 2.3.4 → 2.3.6

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.
@@ -184,11 +184,18 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
184
184
  dlogW("⚠️ AutomaticGainControl NOT available on this device")
185
185
  }
186
186
 
187
- // Initialize AudioTrack for playback
187
+ // Initialize AudioTrack for playback.
188
+ // The voice-communication output path favors low latency with a
189
+ // small default buffer; that underruns on jittery streamed network
190
+ // audio and makes playback choppy ("skipping"). Size the buffer for
191
+ // ~0.4s of audio to absorb jitter — smoothness matters more than
192
+ // latency for an assistant reply.
188
193
  val playMinBuffer = AudioTrack.getMinBufferSize(
189
194
  PLAYBACK_SAMPLE_RATE, CHANNEL_OUT, AUDIO_ENCODING
190
195
  )
191
- val playBufferSize = maxOf(playMinBuffer, 4096)
196
+ // bytes/sec = sampleRate * 2 (16-bit mono); * 4 / 10 ≈ 0.4s
197
+ val jitterBufferBytes = PLAYBACK_SAMPLE_RATE * 2 * 4 / 10
198
+ val playBufferSize = maxOf(playMinBuffer * 2, jitterBufferBytes)
192
199
 
193
200
  // iOS VPIO equivalent (part 2): play on the voice-communication path
194
201
  // (USAGE_VOICE_COMMUNICATION + CONTENT_TYPE_SPEECH) so the echo
@@ -3,7 +3,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.QafkaAudio = void 0;
4
4
  const react_native_1 = require("react-native");
5
5
  const ensure_record_permission_1 = require("./ensure-record-permission");
6
+ const serial_lock_1 = require("./serial-lock");
6
7
  const { QafkaAudio: NativeQafkaAudio } = react_native_1.NativeModules;
8
+ // Serialize the native audio lifecycle. The native module is a process-wide
9
+ // singleton with shared mutable state (capture thread, AudioRecord/AudioTrack).
10
+ // On a quick leave-and-reenter of voice, the previous session's stopCapture is
11
+ // fired un-awaited and would otherwise race the next startCapture, corrupting
12
+ // the new session's playback (choppy / overlapping audio). One queue for both
13
+ // guarantees a stop fully completes before the next start begins.
14
+ const lifecycleQueue = (0, serial_lock_1.createSerialQueue)();
7
15
  const emitter = NativeQafkaAudio ? new react_native_1.NativeEventEmitter(NativeQafkaAudio) : null;
8
16
  const MISSING_MODULE_MESSAGE = `QafkaAudio native module is not linked. ` +
9
17
  `The JS bundle was updated but the native binary was not rebuilt. ` +
@@ -35,8 +43,8 @@ exports.QafkaAudio = {
35
43
  return result === react_native_1.PermissionsAndroid.RESULTS.GRANTED;
36
44
  },
37
45
  }),
38
- startCapture: () => requireModule().startCapture(),
39
- stopCapture: () => requireModule().stopCapture(),
46
+ startCapture: () => lifecycleQueue(() => requireModule().startCapture()),
47
+ stopCapture: () => lifecycleQueue(() => requireModule().stopCapture()),
40
48
  playAudioChunk: (base64Data) => requireModule().playAudioChunk(base64Data),
41
49
  stopPlayback: () => requireModule().stopPlayback(),
42
50
  onAudioData: (callback) => {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Create a serial execution queue: each submitted operation runs only after the
3
+ * previous one has fully settled, so two operations never overlap.
4
+ *
5
+ * Used to serialize native audio lifecycle calls (startCapture / stopCapture).
6
+ * The native module is a process-wide singleton with shared mutable state; when
7
+ * the user leaves and quickly re-enters voice, an un-awaited teardown
8
+ * (stopCapture) can otherwise race the next startCapture and corrupt the new
9
+ * session's audio track. Routing both through one queue removes that race.
10
+ *
11
+ * A failing operation does not break the chain — the next one still runs.
12
+ */
13
+ export declare function createSerialQueue(): <T>(fn: () => Promise<T>) => Promise<T>;
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createSerialQueue = createSerialQueue;
4
+ /**
5
+ * Create a serial execution queue: each submitted operation runs only after the
6
+ * previous one has fully settled, so two operations never overlap.
7
+ *
8
+ * Used to serialize native audio lifecycle calls (startCapture / stopCapture).
9
+ * The native module is a process-wide singleton with shared mutable state; when
10
+ * the user leaves and quickly re-enters voice, an un-awaited teardown
11
+ * (stopCapture) can otherwise race the next startCapture and corrupt the new
12
+ * session's audio track. Routing both through one queue removes that race.
13
+ *
14
+ * A failing operation does not break the chain — the next one still runs.
15
+ */
16
+ function createSerialQueue() {
17
+ let tail = Promise.resolve();
18
+ return function run(fn) {
19
+ // Chain off the tail regardless of whether it resolved or rejected, so one
20
+ // failure can't wedge the queue.
21
+ const result = tail.then(fn, fn);
22
+ tail = result.then(() => undefined, () => undefined);
23
+ return result;
24
+ };
25
+ }
@@ -51,6 +51,7 @@ export declare class RealtimeService {
51
51
  private audioChunksSent;
52
52
  private sessionTokenGetter;
53
53
  private appVersion;
54
+ private disposed;
54
55
  constructor(apiUrl: string, apiKey: string);
55
56
  setSessionTokenGetter(fn: () => Promise<string | null>): void;
56
57
  connect(onEvent: RealtimeEventHandler, options?: {
@@ -12,6 +12,11 @@ class RealtimeService {
12
12
  audioChunksSent = 0;
13
13
  sessionTokenGetter = null;
14
14
  appVersion = null;
15
+ // Set once disconnect() has torn this session down. Guards against the ws
16
+ // `onclose` (fired by our own ws.close()) running a SECOND cleanup/stopCapture
17
+ // — which, after the user has reopened voice, would tear down the NEW
18
+ // session's shared native audio engine.
19
+ disposed = false;
15
20
  constructor(apiUrl, apiKey) {
16
21
  this.apiUrl = apiUrl;
17
22
  this.apiKey = apiKey;
@@ -95,7 +100,12 @@ class RealtimeService {
95
100
  reject(new Error('WebSocket connection failed'));
96
101
  };
97
102
  this.ws.onclose = (event) => {
98
- this.cleanup();
103
+ // On a graceful disconnect() we've already cleaned up; skip the redundant
104
+ // teardown so it can't stop a newer session's shared native audio engine.
105
+ // On an UNEXPECTED close (network drop) disposed is false → still clean up.
106
+ if (!this.disposed) {
107
+ this.cleanup();
108
+ }
99
109
  this.eventHandler?.({
100
110
  type: 'session.closed',
101
111
  reason: event.reason || 'CONNECTION_CLOSED',
@@ -190,6 +200,9 @@ class RealtimeService {
190
200
  }));
191
201
  }
192
202
  async disconnect() {
203
+ // Mark disposed BEFORE closing the ws so the onclose handler (which our
204
+ // ws.close() triggers) skips its redundant cleanup/stopCapture.
205
+ this.disposed = true;
193
206
  await this.cleanup();
194
207
  if (this.ws?.readyState === WebSocket.OPEN) {
195
208
  this.ws.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qafka/react-native",
3
- "version": "2.3.4",
3
+ "version": "2.3.6",
4
4
  "description": "Drop-in AI assistant for React Native: chat, voice, and tool execution with screen-aware navigation.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",