@qafka/react-native 2.3.5 → 2.3.7
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.
|
@@ -358,12 +358,21 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
|
|
|
358
358
|
}
|
|
359
359
|
}, []);
|
|
360
360
|
const connect = (0, react_1.useCallback)(async () => {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
361
|
+
// A service already exists for this voice session — do NOT create a second.
|
|
362
|
+
// The scroll handler fires connect() repeatedly while the pager settles on
|
|
363
|
+
// the voice page; the previous `isConnected` guard passed for two near-
|
|
364
|
+
// simultaneous calls (the first WS hadn't opened yet), so each spawned a
|
|
365
|
+
// RealtimeService and the first was orphaned but kept streaming mic + audio
|
|
366
|
+
// (two interleaved audio streams = choppy playback; the orphan still heard
|
|
367
|
+
// the mic = "mute didn't work"). Guard on existence, set synchronously
|
|
368
|
+
// below before any await, to close that race.
|
|
369
|
+
if (serviceRef.current) {
|
|
370
|
+
if (serviceRef.current.isConnected) {
|
|
371
|
+
// Re-entering the voice page after a swipe to chat (which calls pauseMic
|
|
372
|
+
// and stops native capture). The socket is still open — resume capture
|
|
373
|
+
// so the user can be heard again.
|
|
374
|
+
await serviceRef.current.resumeMic();
|
|
375
|
+
}
|
|
367
376
|
return;
|
|
368
377
|
}
|
|
369
378
|
setState('connecting');
|
|
@@ -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
|
-
|
|
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