@qafka/react-native 2.3.9 → 2.4.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/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.4.0] — 2026-06-21
11
+
12
+ ### Added
13
+
14
+ - Full-duplex voice (barge-in): users can now interrupt the AI by speaking while it is talking. The microphone stays open during playback and the AI stops promptly when the user talks over it. On devices whose echo cancellation underperforms, the session automatically falls back to half-duplex so the AI's own audio is never mistaken for user speech.
15
+
16
+ ### Fixed
17
+
18
+ - Voice echo on both iOS and Android: the AI's playback no longer leaks back into the microphone (where it could be transcribed as user input). Audio is routed through the platform's voice-communication path — Apple VPIO on iOS, communication-mode capture with hardware echo cancellation on Android — and played on the loudspeaker.
19
+
10
20
  ## [2.3.8] — 2026-06-17
11
21
 
12
22
  ### Added
@@ -1,4 +1,9 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
2
  <!-- Required for voice capture. Merged into the consumer app manifest. -->
3
3
  <uses-permission android:name="android.permission.RECORD_AUDIO" />
4
+ <!-- Required to set MODE_IN_COMMUNICATION and route playback to the
5
+ loudspeaker (setCommunicationDevice / setSpeakerphoneOn) for hands-free
6
+ voice. Without it those calls are silently denied and audio stays on the
7
+ earpiece. This is a normal (install-time) permission. -->
8
+ <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
4
9
  </manifest>
@@ -5,6 +5,7 @@ import android.content.Context
5
5
  import android.content.pm.PackageManager
6
6
  import android.media.AudioAttributes
7
7
  import android.media.AudioFormat
8
+ import android.media.AudioDeviceInfo
8
9
  import android.media.AudioManager
9
10
  import android.media.AudioRecord
10
11
  import android.media.AudioTrack
@@ -12,6 +13,7 @@ import android.media.MediaRecorder
12
13
  import android.media.audiofx.AcousticEchoCanceler
13
14
  import android.media.audiofx.AutomaticGainControl
14
15
  import android.media.audiofx.NoiseSuppressor
16
+ import android.os.Build
15
17
  import android.util.Base64
16
18
  import android.util.Log
17
19
  import androidx.core.content.ContextCompat
@@ -29,6 +31,20 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
29
31
  @Volatile private var isCapturing = false
30
32
  private var listenerCount = 0
31
33
 
34
+ // Client-side barge-in. The server-side VAD interrupt is too slow to
35
+ // cut the AI promptly, but the on-device AEC keeps the mic clean (the user's
36
+ // voice is a clear energy spike vs near-zero silence), so we detect the
37
+ // barge-in locally and cut playback instantly. `suppressPlayback` then drops
38
+ // the rest of the now-cancelled turn until the next turn boundary, when JS
39
+ // calls resumePlayback().
40
+ @Volatile private var lastPlaybackWriteMs = 0L
41
+ @Volatile private var suppressPlayback = false
42
+ @Volatile private var bargeInAtMs = 0L
43
+ // Gates client-side barge-in detection. JS turns it OFF when it detects the
44
+ // device's AEC is failing (a burst of barge-ins = the AI's own echo, not a
45
+ // user) and downgrades to half-duplex, so the echo can't keep cutting the AI.
46
+ @Volatile private var bargeInEnabled = true
47
+
32
48
  // iOS VPIO equivalent — hardware AEC + NS + AGC (android.media.audiofx).
33
49
  // Enables barge-in by cancelling playback echo at mic level.
34
50
  // Device-dependent; isAvailable() + null checks keep it safe.
@@ -48,6 +64,17 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
48
64
  private const val CHANNEL_IN = AudioFormat.CHANNEL_IN_MONO
49
65
  private const val CHANNEL_OUT = AudioFormat.CHANNEL_OUT_MONO
50
66
  private const val AUDIO_ENCODING = AudioFormat.ENCODING_PCM_16BIT
67
+
68
+ // Barge-in detection tuning. Mic RMS is ~0 in silence (AEC removes the
69
+ // AI's echo) and ~2000-5000 when the user speaks, so the threshold sits
70
+ // comfortably between. Require a couple of consecutive ~128ms reads to
71
+ // reject transient clicks; only armed while the AI is actively playing.
72
+ private const val BARGE_IN_RMS_THRESHOLD = 1200.0
73
+ private const val BARGE_IN_SUSTAIN_READS = 2
74
+ private const val PLAYBACK_ACTIVE_WINDOW_MS = 350L
75
+ // Safety cap: if no turn-end signal arrives to lift suppression, resume
76
+ // anyway so a missed event can't permanently mute the next turn.
77
+ private const val SUPPRESS_MAX_MS = 6000L
51
78
  }
52
79
 
53
80
  /**
@@ -98,6 +125,11 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
98
125
  return
99
126
  }
100
127
 
128
+ // Fresh session starts full-duplex (barge-in armed) with no suppression;
129
+ // JS downgrades to half-duplex later if it detects the AEC is failing.
130
+ bargeInEnabled = true
131
+ suppressPlayback = false
132
+
101
133
  // Check permission
102
134
  val permission = ContextCompat.checkSelfPermission(
103
135
  reactApplicationContext,
@@ -127,13 +159,29 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
127
159
  val audioManager =
128
160
  reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
129
161
  previousAudioMode = audioManager.mode
130
- previousSpeakerphoneOn = audioManager.isSpeakerphoneOn
131
162
  audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
132
- @Suppress("DEPRECATION")
133
- audioManager.isSpeakerphoneOn = true
134
- dlogI("✅ AudioManager mode=IN_COMMUNICATION, speakerphone=on")
163
+ // Route playback to the LOUDSPEAKER for hands-free use. On API 31+
164
+ // setSpeakerphoneOn is deprecated and a no-op on many devices —
165
+ // setCommunicationDevice(BUILTIN_SPEAKER) is the supported path.
166
+ // Requires MODIFY_AUDIO_SETTINGS (declared in the manifest); without
167
+ // it these calls are silently denied and audio stays on the earpiece.
168
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
169
+ val speaker = audioManager.availableCommunicationDevices
170
+ .firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER }
171
+ if (speaker != null) {
172
+ val ok = audioManager.setCommunicationDevice(speaker)
173
+ dlogI("✅ mode=IN_COMMUNICATION, setCommunicationDevice(speaker)=$ok")
174
+ } else {
175
+ dlogW("⚠️ No BUILTIN_SPEAKER communication device available")
176
+ }
177
+ } else {
178
+ previousSpeakerphoneOn = audioManager.isSpeakerphoneOn
179
+ @Suppress("DEPRECATION")
180
+ audioManager.isSpeakerphoneOn = true
181
+ dlogI("✅ mode=IN_COMMUNICATION, speakerphone=on (legacy)")
182
+ }
135
183
  } catch (e: Exception) {
136
- dlogW("⚠️ Failed to set communication audio mode: ${e.message}")
184
+ dlogW("⚠️ Failed to set communication audio routing: ${e.message}")
137
185
  }
138
186
 
139
187
  try {
@@ -185,17 +233,21 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
185
233
  }
186
234
 
187
235
  // 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.
236
+ // The original ~0.4s jitter buffer (chosen for playback
237
+ // smoothness) pushes the echo-path delay far out and renders network
238
+ // audio in large, late, sometimes-underrunning bursts — which the
239
+ // OS echo canceler cannot align/reference, so the AI's playback
240
+ // leaks back into the mic. Shrink to ~150ms to pull the echo-path
241
+ // delay into the AEC's tracking window (and reduce the BUFFER
242
+ // TIMEOUT underruns seen in logcat). The maxOf floor still keeps it
243
+ // at/above the device minimum so it can't underflow outright.
193
244
  val playMinBuffer = AudioTrack.getMinBufferSize(
194
245
  PLAYBACK_SAMPLE_RATE, CHANNEL_OUT, AUDIO_ENCODING
195
246
  )
196
- // bytes/sec = sampleRate * 2 (16-bit mono); * 4 / 10 ≈ 0.4s
197
- val jitterBufferBytes = PLAYBACK_SAMPLE_RATE * 2 * 4 / 10
247
+ // bytes/sec = sampleRate * 2 (16-bit mono); * 15 / 100 ≈ 0.15s
248
+ val jitterBufferBytes = PLAYBACK_SAMPLE_RATE * 2 * 15 / 100
198
249
  val playBufferSize = maxOf(playMinBuffer * 2, jitterBufferBytes)
250
+ dlogI("🔧 playbackBuffer=$playBufferSize bytes (~${playBufferSize * 1000 / (PLAYBACK_SAMPLE_RATE * 2)}ms)")
199
251
 
200
252
  // iOS VPIO equivalent (part 2): play on the voice-communication path
201
253
  // (USAGE_VOICE_COMMUNICATION + CONTENT_TYPE_SPEECH) so the echo
@@ -214,6 +266,13 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
214
266
  .setEncoding(AUDIO_ENCODING)
215
267
  .build()
216
268
  )
269
+ // Bind playback to the SAME audio session as the capture that
270
+ // AcousticEchoCanceler is attached to. Without this the HAL
271
+ // cannot correlate the render (AI playback) and capture (mic)
272
+ // streams, so the echo canceler has no reference to subtract and
273
+ // silently no-ops — the likely reason hardware AEC "reports
274
+ // enabled but fails to cancel". Required for full-duplex barge-in.
275
+ .setSessionId(sessionId)
217
276
  .setBufferSizeInBytes(playBufferSize)
218
277
  .setTransferMode(AudioTrack.MODE_STREAM)
219
278
  .build()
@@ -229,10 +288,36 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
229
288
  // Start capture thread
230
289
  captureThread = Thread {
231
290
  val readBuffer = ShortArray(bufferSize / 2)
291
+ var highReads = 0
232
292
 
233
293
  while (isCapturing) {
234
294
  val shortsRead = record.read(readBuffer, 0, readBuffer.size)
235
295
  if (shortsRead > 0) {
296
+ // Client-side barge-in: while the AI is actively playing,
297
+ // a sustained mic-energy spike means the user is talking
298
+ // over it — cut locally instead of waiting for the
299
+ // server's slower interrupt signal.
300
+ val playbackActive =
301
+ (System.currentTimeMillis() - lastPlaybackWriteMs) < PLAYBACK_ACTIVE_WINDOW_MS
302
+ if (playbackActive && !suppressPlayback && bargeInEnabled) {
303
+ var sum = 0.0
304
+ for (i in 0 until shortsRead) {
305
+ val s = readBuffer[i].toDouble()
306
+ sum += s * s
307
+ }
308
+ val rms = Math.sqrt(sum / shortsRead)
309
+ if (rms > BARGE_IN_RMS_THRESHOLD) {
310
+ highReads++
311
+ if (highReads >= BARGE_IN_SUSTAIN_READS) {
312
+ highReads = 0
313
+ triggerBargeIn()
314
+ }
315
+ } else {
316
+ highReads = 0
317
+ }
318
+ } else {
319
+ highReads = 0
320
+ }
236
321
  // Convert shorts to bytes (little-endian PCM 16-bit)
237
322
  val byteBuffer = ByteArray(shortsRead * 2)
238
323
  for (i in 0 until shortsRead) {
@@ -293,11 +378,15 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
293
378
  try {
294
379
  val audioManager =
295
380
  reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
296
- previousAudioMode?.let { audioManager.mode = it }
297
- previousSpeakerphoneOn?.let {
298
- @Suppress("DEPRECATION")
299
- audioManager.isSpeakerphoneOn = it
381
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
382
+ audioManager.clearCommunicationDevice()
383
+ } else {
384
+ previousSpeakerphoneOn?.let {
385
+ @Suppress("DEPRECATION")
386
+ audioManager.isSpeakerphoneOn = it
387
+ }
300
388
  }
389
+ previousAudioMode?.let { audioManager.mode = it }
301
390
  } catch (_: Exception) {}
302
391
  previousAudioMode = null
303
392
  previousSpeakerphoneOn = null
@@ -309,6 +398,19 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
309
398
 
310
399
  @ReactMethod
311
400
  fun playAudioChunk(base64Data: String, promise: Promise) {
401
+ // Drop the AI's remaining audio after a local barge-in until the turn
402
+ // boundary lifts suppression (or the safety cap elapses). Without this
403
+ // the in-flight deltas of the just-cancelled turn would refill the buffer
404
+ // and the AI would audibly resume right after we cut it.
405
+ if (suppressPlayback) {
406
+ if (System.currentTimeMillis() - bargeInAtMs > SUPPRESS_MAX_MS) {
407
+ suppressPlayback = false
408
+ } else {
409
+ promise.resolve(true)
410
+ return
411
+ }
412
+ }
413
+
312
414
  val track = audioTrack
313
415
  if (track == null) {
314
416
  promise.reject("NOT_RUNNING", "Audio is not running. Call startCapture first.")
@@ -318,6 +420,7 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
318
420
  try {
319
421
  val audioBytes = Base64.decode(base64Data, Base64.DEFAULT)
320
422
  track.write(audioBytes, 0, audioBytes.size)
423
+ lastPlaybackWriteMs = System.currentTimeMillis()
321
424
  promise.resolve(true)
322
425
  } catch (e: Exception) {
323
426
  promise.reject("PLAYBACK_ERROR", "Failed to play audio chunk: ${e.message}", e)
@@ -328,6 +431,8 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
328
431
 
329
432
  @ReactMethod
330
433
  fun stopPlayback(promise: Promise) {
434
+ // Purges queued playback when the server signals the user interrupted
435
+ // (response.interrupted) so the cancelled turn stops promptly.
331
436
  val track = audioTrack ?: run {
332
437
  promise.resolve(true)
333
438
  return
@@ -342,4 +447,44 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
342
447
  promise.reject("PLAYBACK_ERROR", "Failed to stop playback: ${e.message}", e)
343
448
  }
344
449
  }
450
+
451
+ // MARK: - Barge-in
452
+
453
+ /**
454
+ * Called from the capture thread when a sustained mic-energy spike is
455
+ * detected while the AI is playing. Cuts playback instantly and starts
456
+ * dropping the cancelled turn's remaining audio; JS lifts suppression at the
457
+ * next turn boundary via resumePlayback().
458
+ */
459
+ private fun triggerBargeIn() {
460
+ suppressPlayback = true
461
+ bargeInAtMs = System.currentTimeMillis()
462
+ try {
463
+ audioTrack?.let {
464
+ it.pause()
465
+ it.flush()
466
+ it.play()
467
+ }
468
+ } catch (_: Exception) {}
469
+ dlogI("⚡ Local barge-in — cut playback, suppressing cancelled turn")
470
+ sendEvent("onBargeIn", Arguments.createMap())
471
+ }
472
+
473
+ @ReactMethod
474
+ fun resumePlayback(promise: Promise) {
475
+ suppressPlayback = false
476
+ promise.resolve(true)
477
+ }
478
+
479
+ /**
480
+ * Arm/disarm client-side barge-in. JS disarms it after detecting the device's
481
+ * echo cancellation is failing (downgrade to half-duplex), so the AI's own
482
+ * leaked echo can't keep triggering a cut. Disarming also clears suppression.
483
+ */
484
+ @ReactMethod
485
+ fun setBargeInEnabled(enabled: Boolean, promise: Promise) {
486
+ bargeInEnabled = enabled
487
+ if (!enabled) suppressPlayback = false
488
+ promise.resolve(true)
489
+ }
345
490
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Decides whether mic audio should be forwarded to the server.
3
+ *
4
+ * Gates, in order of precedence:
5
+ * - `micEnabled`: the user/system mute state (false = muted). Always wins.
6
+ * - `fullDuplex`: when true, the native OS echo canceler
7
+ * (MODE_IN_COMMUNICATION + AcousticEchoCanceler bound to the shared capture
8
+ * session, see QafkaAudioModule) is trusted to strip the AI's playback out
9
+ * of the mic, so the mic stays open while the AI speaks — this is what
10
+ * enables voice barge-in. Defaults to false.
11
+ * - `aiSpeaking` is false: half-duplex gate, used only when `fullDuplex` is
12
+ * off. While the AI is speaking we stop forwarding so its own playback,
13
+ * echoed into the mic, isn't transcribed as a user turn (there is no
14
+ * server-side echo cancellation upstream).
15
+ *
16
+ * Pure function so the gating logic is unit-testable without the native-bound
17
+ * RealtimeService.
18
+ */
19
+ export declare function shouldForwardMicAudio(state: {
20
+ micEnabled: boolean;
21
+ aiSpeaking: boolean;
22
+ fullDuplex?: boolean;
23
+ }): boolean;
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.shouldForwardMicAudio = shouldForwardMicAudio;
4
+ /**
5
+ * Decides whether mic audio should be forwarded to the server.
6
+ *
7
+ * Gates, in order of precedence:
8
+ * - `micEnabled`: the user/system mute state (false = muted). Always wins.
9
+ * - `fullDuplex`: when true, the native OS echo canceler
10
+ * (MODE_IN_COMMUNICATION + AcousticEchoCanceler bound to the shared capture
11
+ * session, see QafkaAudioModule) is trusted to strip the AI's playback out
12
+ * of the mic, so the mic stays open while the AI speaks — this is what
13
+ * enables voice barge-in. Defaults to false.
14
+ * - `aiSpeaking` is false: half-duplex gate, used only when `fullDuplex` is
15
+ * off. While the AI is speaking we stop forwarding so its own playback,
16
+ * echoed into the mic, isn't transcribed as a user turn (there is no
17
+ * server-side echo cancellation upstream).
18
+ *
19
+ * Pure function so the gating logic is unit-testable without the native-bound
20
+ * RealtimeService.
21
+ */
22
+ function shouldForwardMicAudio(state) {
23
+ if (!state.micEnabled)
24
+ return false;
25
+ if (state.fullDuplex)
26
+ return true;
27
+ return !state.aiSpeaking;
28
+ }
@@ -10,6 +10,8 @@ export declare const QafkaAudio: {
10
10
  stopCapture: () => Promise<boolean>;
11
11
  playAudioChunk: (base64Data: string) => Promise<boolean>;
12
12
  stopPlayback: () => Promise<boolean>;
13
+ resumePlayback: () => Promise<boolean>;
14
+ setBargeInEnabled: (enabled: boolean) => Promise<boolean>;
13
15
  onAudioData: (callback: (data: {
14
16
  audio: string;
15
17
  }) => void) => () => void | undefined;
@@ -17,5 +19,6 @@ export declare const QafkaAudio: {
17
19
  level: number;
18
20
  source: "mic" | "speaker";
19
21
  }) => void) => () => void | undefined;
22
+ onBargeIn: (callback: () => void) => () => void | undefined;
20
23
  isAvailable: () => boolean;
21
24
  };
@@ -47,6 +47,12 @@ exports.QafkaAudio = {
47
47
  stopCapture: () => lifecycleQueue(() => requireModule().stopCapture()),
48
48
  playAudioChunk: (base64Data) => requireModule().playAudioChunk(base64Data),
49
49
  stopPlayback: () => requireModule().stopPlayback(),
50
+ // Lift the post-barge-in playback suppression at a turn boundary so the AI's
51
+ // next turn can play. No-op if nothing is suppressed.
52
+ resumePlayback: () => requireModule().resumePlayback(),
53
+ // Arm/disarm native client-side barge-in. JS disarms it after detecting the
54
+ // device's echo cancellation is failing (downgrade to half-duplex).
55
+ setBargeInEnabled: (enabled) => requireModule().setBargeInEnabled(enabled),
50
56
  onAudioData: (callback) => {
51
57
  const subscription = emitter?.addListener('onAudioData', callback);
52
58
  return () => subscription?.remove();
@@ -55,5 +61,12 @@ exports.QafkaAudio = {
55
61
  const subscription = emitter?.addListener('onAmplitude', callback);
56
62
  return () => subscription?.remove();
57
63
  },
64
+ // Fired by the native capture thread when it detects the user talking over the
65
+ // AI (client-side barge-in). Native has already cut playback; the SDK uses this
66
+ // to return the UI to listening without waiting for the slow server interrupt.
67
+ onBargeIn: (callback) => {
68
+ const subscription = emitter?.addListener('onBargeIn', () => callback());
69
+ return () => subscription?.remove();
70
+ },
58
71
  isAvailable: () => NativeQafkaAudio != null,
59
72
  };
@@ -49,6 +49,12 @@ export declare class RealtimeService {
49
49
  private apiKey;
50
50
  private ws;
51
51
  private audioUnsubscribe;
52
+ private bargeInUnsubscribe;
53
+ private bargeInTimes;
54
+ private echoDowngraded;
55
+ private micEnabled;
56
+ private aiSpeaking;
57
+ private fullDuplex;
52
58
  private eventHandler;
53
59
  private audioChunksSent;
54
60
  private sessionTokenGetter;
@@ -64,8 +70,18 @@ export declare class RealtimeService {
64
70
  }): Promise<void>;
65
71
  private audioDeltasReceived;
66
72
  private handleEvent;
73
+ /**
74
+ * Native detected the user talking over the AI (client-side barge-in) — it has
75
+ * already cut playback locally and is suppressing the cancelled turn. Mirror a
76
+ * server interrupt so the consumer UI returns to listening immediately; the
77
+ * gateway's own (slower) interrupt follows and lifts the suppression.
78
+ */
79
+ private handleLocalBargeIn;
80
+ private guardAgainstEcho;
67
81
  private attachAudioListener;
68
82
  private detachAudioListener;
83
+ private syncMicForwarding;
84
+ private setAiSpeaking;
69
85
  private startAudioPipeline;
70
86
  /**
71
87
  * Fully stop the native audio engine (capture + playback). Use when leaving
@@ -4,11 +4,40 @@ exports.RealtimeService = void 0;
4
4
  const QafkaAudio_1 = require("../native/QafkaAudio");
5
5
  const QafkaAttestation_1 = require("../native/QafkaAttestation");
6
6
  const decide_interrupt_1 = require("../decide-interrupt");
7
+ const decide_mic_forwarding_1 = require("../decide-mic-forwarding");
8
+ // Full-duplex voice: keep the mic open while the AI speaks and rely on the
9
+ // native OS echo canceler to strip the AI's playback out of the mic, so the
10
+ // user can interrupt by voice (barge-in). When false, the SDK falls back to
11
+ // half-duplex (mic gated while the AI speaks). A failing on-device echo
12
+ // canceler auto-downgrades the session to half-duplex at runtime (see the
13
+ // echo guard below).
14
+ const DEFAULT_FULL_DUPLEX = true;
15
+ // Echo guard: if the device's AEC is failing, the AI's own voice leaks into the
16
+ // mic and the barge-in detector reads it as the user — firing repeatedly. A real
17
+ // user never barges in this often, so this many barge-ins within the window means
18
+ // the AEC isn't working: downgrade the session to half-duplex.
19
+ const ECHO_GUARD_BARGE_INS = 3;
20
+ const ECHO_GUARD_WINDOW_MS = 12000;
7
21
  class RealtimeService {
8
22
  apiUrl;
9
23
  apiKey;
10
24
  ws = null;
11
25
  audioUnsubscribe = null;
26
+ // Native client-side barge-in subscription (mic-energy spike while AI speaks).
27
+ bargeInUnsubscribe = null;
28
+ // Echo guard — recent barge-in timestamps + whether this session downgraded.
29
+ bargeInTimes = [];
30
+ echoDowngraded = false;
31
+ // Half-duplex mic gating. `micEnabled` reflects the user/system mute state;
32
+ // `aiSpeaking` is true while the AI's audio is playing. Mic audio is
33
+ // forwarded to the server only when both allow it — this stops the AI's own
34
+ // voice (echoed into the mic) from being transcribed as a user turn, since
35
+ // there is no server-side echo cancellation upstream.
36
+ micEnabled = true;
37
+ aiSpeaking = false;
38
+ // Full-duplex (keep mic open while AI speaks, rely on native hardware AEC).
39
+ // When false, half-duplex mic gating is used. See DEFAULT_FULL_DUPLEX.
40
+ fullDuplex = DEFAULT_FULL_DUPLEX;
12
41
  eventHandler = null;
13
42
  audioChunksSent = 0;
14
43
  sessionTokenGetter = null;
@@ -51,6 +80,12 @@ class RealtimeService {
51
80
  Object.keys(options.endUserData).length > 0) {
52
81
  params.set('endUserData', JSON.stringify(options.endUserData));
53
82
  }
83
+ // Tell the gateway this session is full-duplex so it enables the server-side
84
+ // barge-in VAD config ONLY for us. Half-duplex builds omit it → the gateway
85
+ // keeps their exact prior behavior.
86
+ if (this.fullDuplex) {
87
+ params.set('fullDuplex', '1');
88
+ }
54
89
  const qs = params.toString();
55
90
  const fullUrl = `${wsUrl}/v1/realtime${qs ? `?${qs}` : ''}`;
56
91
  // Start the audio engine FIRST so the player is ready to schedule buffers
@@ -118,6 +153,9 @@ class RealtimeService {
118
153
  handleEvent(event) {
119
154
  if (event.type === 'response.audio.delta' && 'delta' in event) {
120
155
  this.audioDeltasReceived++;
156
+ // Half-duplex: the AI is speaking — stop forwarding mic audio so its own
157
+ // voice can't echo back into the STT as a user turn.
158
+ this.setAiSpeaking(true);
121
159
  QafkaAudio_1.QafkaAudio.playAudioChunk(event.delta).catch(() => {
122
160
  // playback errors are surfaced to the consumer via eventHandler
123
161
  });
@@ -125,11 +163,53 @@ class RealtimeService {
125
163
  else if ((0, decide_interrupt_1.shouldStopPlayback)(event)) {
126
164
  // Barge-in: the server signalled the user interrupted. Purge native
127
165
  // playback so queued audio stops; the WS is ordered, so no further
128
- // audio delta arrives after this for the cancelled turn.
166
+ // audio delta arrives after this for the cancelled turn. Also lift any
167
+ // local-barge-in suppression so the AI's NEXT turn can play.
168
+ this.setAiSpeaking(false);
129
169
  QafkaAudio_1.QafkaAudio.stopPlayback().catch(() => { });
170
+ QafkaAudio_1.QafkaAudio.resumePlayback().catch(() => { });
171
+ }
172
+ else if (event.type === 'response.done') {
173
+ // AI finished its turn — reopen mic forwarding for the user and clear any
174
+ // lingering local-barge-in suppression.
175
+ this.setAiSpeaking(false);
176
+ QafkaAudio_1.QafkaAudio.resumePlayback().catch(() => { });
130
177
  }
131
178
  this.eventHandler?.(event);
132
179
  }
180
+ /**
181
+ * Native detected the user talking over the AI (client-side barge-in) — it has
182
+ * already cut playback locally and is suppressing the cancelled turn. Mirror a
183
+ * server interrupt so the consumer UI returns to listening immediately; the
184
+ * gateway's own (slower) interrupt follows and lifts the suppression.
185
+ */
186
+ handleLocalBargeIn() {
187
+ if (!this.aiSpeaking)
188
+ return; // only meaningful while the AI is talking
189
+ this.setAiSpeaking(false);
190
+ this.eventHandler?.({ type: 'response.interrupted' });
191
+ this.guardAgainstEcho();
192
+ }
193
+ // A device whose AEC fails leaks the AI's own voice into the mic, which the
194
+ // barge-in detector reads as the user talking — firing repeatedly. A real user
195
+ // never barges in this often, so a burst means the AEC isn't working: downgrade
196
+ // THIS session to half-duplex (mic gated while the AI speaks + native barge-in
197
+ // disarmed) so the echo can't keep cutting the AI. Per-session — a fresh
198
+ // session re-tries full-duplex.
199
+ guardAgainstEcho() {
200
+ if (this.echoDowngraded)
201
+ return;
202
+ const now = Date.now();
203
+ this.bargeInTimes.push(now);
204
+ this.bargeInTimes = this.bargeInTimes.filter((t) => now - t <= ECHO_GUARD_WINDOW_MS);
205
+ if (this.bargeInTimes.length >= ECHO_GUARD_BARGE_INS) {
206
+ this.echoDowngraded = true;
207
+ this.fullDuplex = false;
208
+ QafkaAudio_1.QafkaAudio.setBargeInEnabled(false).catch(() => { });
209
+ QafkaAudio_1.QafkaAudio.resumePlayback().catch(() => { });
210
+ this.syncMicForwarding();
211
+ }
212
+ }
133
213
  attachAudioListener() {
134
214
  if (this.audioUnsubscribe)
135
215
  return; // already attached
@@ -144,6 +224,26 @@ class RealtimeService {
144
224
  this.audioUnsubscribe?.();
145
225
  this.audioUnsubscribe = null;
146
226
  }
227
+ // Attach/detach mic→server forwarding from the combined mute + AI-speaking
228
+ // state. Both underlying calls are idempotent, so this is safe to call often.
229
+ syncMicForwarding() {
230
+ if ((0, decide_mic_forwarding_1.shouldForwardMicAudio)({
231
+ micEnabled: this.micEnabled,
232
+ aiSpeaking: this.aiSpeaking,
233
+ fullDuplex: this.fullDuplex,
234
+ })) {
235
+ this.attachAudioListener();
236
+ }
237
+ else {
238
+ this.detachAudioListener();
239
+ }
240
+ }
241
+ setAiSpeaking(speaking) {
242
+ if (this.aiSpeaking === speaking)
243
+ return;
244
+ this.aiSpeaking = speaking;
245
+ this.syncMicForwarding();
246
+ }
147
247
  async startAudioPipeline() {
148
248
  try {
149
249
  // Android needs the RECORD_AUDIO runtime permission requested explicitly
@@ -159,7 +259,12 @@ class RealtimeService {
159
259
  }
160
260
  // Attach listener BEFORE startCapture so the native tap
161
261
  // doesn't drop early buffers (hasListeners/listenerCount gate).
162
- this.attachAudioListener();
262
+ // Fresh capture: the AI isn't speaking yet; sync respects any mute state.
263
+ this.aiSpeaking = false;
264
+ this.syncMicForwarding();
265
+ if (!this.bargeInUnsubscribe) {
266
+ this.bargeInUnsubscribe = QafkaAudio_1.QafkaAudio.onBargeIn(() => this.handleLocalBargeIn());
267
+ }
163
268
  await QafkaAudio_1.QafkaAudio.startCapture();
164
269
  }
165
270
  catch (error) {
@@ -188,10 +293,12 @@ class RealtimeService {
188
293
  * tool flows where the user shouldn't interrupt but the AI may still speak.
189
294
  */
190
295
  muteMic() {
191
- this.detachAudioListener();
296
+ this.micEnabled = false;
297
+ this.syncMicForwarding();
192
298
  }
193
299
  unmuteMic() {
194
- this.attachAudioListener();
300
+ this.micEnabled = true;
301
+ this.syncMicForwarding();
195
302
  }
196
303
  sendToolResult(payload) {
197
304
  if (this.ws?.readyState !== WebSocket.OPEN) {
@@ -220,6 +327,8 @@ class RealtimeService {
220
327
  async cleanup() {
221
328
  this.audioUnsubscribe?.();
222
329
  this.audioUnsubscribe = null;
330
+ this.bargeInUnsubscribe?.();
331
+ this.bargeInUnsubscribe = null;
223
332
  try {
224
333
  await QafkaAudio_1.QafkaAudio.stopCapture();
225
334
  }
@@ -4,13 +4,18 @@
4
4
  *
5
5
  * custom-with-ai tools answer in two streamed parts: the model's first reply
6
6
  * (the "bridge", produced alongside the tool call) and a turn-2 narration of
7
- * the tool result. When the tool renders its result as a CARD, the bridge
8
- * already carries the conversational text and the card shows the data, so the
9
- * turn-2 text narration is redundant — it just repeats the bridge in a second
10
- * message. We skip it for those tools.
7
+ * the tool result. Which one carries the real content is per-tool:
8
+ * - **File-input tools** (e.g. an interview driven by an uploaded CV) are
9
+ * bridge-primary: the model writes the full reply in the bridge (it responds
10
+ * to the post-upload continuation), so the turn-2 narration just repeats it.
11
+ * Skip turn-2 for these.
12
+ * - **Non-file tools** (e.g. a date-availability check) are turn-2-primary:
13
+ * the bridge is a short "checking…" filler and the SUBSTANTIVE result lives
14
+ * only in the turn-2 narration. Skipping it would leave the flow hanging, so
15
+ * turn-2 must still run.
11
16
  *
12
- * Tools WITHOUT a card are left untouched: there the substantive answer often
13
- * lives only in the turn-2 narration (e.g. a lookup whose bridge is a short
14
- * "one moment" filler), so it must still run.
17
+ * We therefore gate on `hasFileInput` in addition to custom-with-ai + card.
18
+ * (A per-tool flag would be cleaner long-term; this heuristic distinguishes the
19
+ * current bridge-primary vs turn-2-primary tools.)
15
20
  */
16
21
  export declare function shouldSkipTurn2Narration(tool: any): boolean;
@@ -7,16 +7,22 @@ exports.shouldSkipTurn2Narration = shouldSkipTurn2Narration;
7
7
  *
8
8
  * custom-with-ai tools answer in two streamed parts: the model's first reply
9
9
  * (the "bridge", produced alongside the tool call) and a turn-2 narration of
10
- * the tool result. When the tool renders its result as a CARD, the bridge
11
- * already carries the conversational text and the card shows the data, so the
12
- * turn-2 text narration is redundant — it just repeats the bridge in a second
13
- * message. We skip it for those tools.
10
+ * the tool result. Which one carries the real content is per-tool:
11
+ * - **File-input tools** (e.g. an interview driven by an uploaded CV) are
12
+ * bridge-primary: the model writes the full reply in the bridge (it responds
13
+ * to the post-upload continuation), so the turn-2 narration just repeats it.
14
+ * Skip turn-2 for these.
15
+ * - **Non-file tools** (e.g. a date-availability check) are turn-2-primary:
16
+ * the bridge is a short "checking…" filler and the SUBSTANTIVE result lives
17
+ * only in the turn-2 narration. Skipping it would leave the flow hanging, so
18
+ * turn-2 must still run.
14
19
  *
15
- * Tools WITHOUT a card are left untouched: there the substantive answer often
16
- * lives only in the turn-2 narration (e.g. a lookup whose bridge is a short
17
- * "one moment" filler), so it must still run.
20
+ * We therefore gate on `hasFileInput` in addition to custom-with-ai + card.
21
+ * (A per-tool flag would be cleaner long-term; this heuristic distinguishes the
22
+ * current bridge-primary vs turn-2-primary tools.)
18
23
  */
19
24
  function shouldSkipTurn2Narration(tool) {
20
25
  return (tool?.executionMode === 'custom-with-ai' &&
21
- tool?.uiConfig?.responseType === 'card');
26
+ tool?.uiConfig?.responseType === 'card' &&
27
+ tool?.modules?.hasFileInput === true);
22
28
  }
package/ios/QafkaAudio.m CHANGED
@@ -20,4 +20,13 @@ RCT_EXTERN_METHOD(stopPlayback:
20
20
  (RCTPromiseResolveBlock)resolve
21
21
  rejecter:(RCTPromiseRejectBlock)reject)
22
22
 
23
+ RCT_EXTERN_METHOD(resumePlayback:
24
+ (RCTPromiseResolveBlock)resolve
25
+ rejecter:(RCTPromiseRejectBlock)reject)
26
+
27
+ RCT_EXTERN_METHOD(setBargeInEnabled:
28
+ (BOOL)enabled
29
+ resolver:(RCTPromiseResolveBlock)resolve
30
+ rejecter:(RCTPromiseRejectBlock)reject)
31
+
23
32
  @end
@@ -1,6 +1,7 @@
1
1
  import Foundation
2
2
  import AVFoundation
3
3
  import React
4
+ import os
4
5
 
5
6
  #if DEBUG
6
7
  private func qafkaLog(_ message: String) { NSLog("%@", message) }
@@ -18,6 +19,58 @@ class QafkaAudio: RCTEventEmitter {
18
19
  private var configChangeObserver: NSObjectProtocol?
19
20
  private var playChunkCount = 0
20
21
 
22
+ // MARK: - Client-side barge-in state
23
+ //
24
+ // While the AI is playing, a sustained mic-energy spike means the user is
25
+ // talking over it — cut playback locally and emit `onBargeIn` instead of
26
+ // waiting for the server's slower interrupt signal. VPIO (Apple AEC) keeps
27
+ // the mic near-silent during playback, so a spike is a clear user signal.
28
+ // `suppressPlayback` then drops the rest of the cancelled turn until JS lifts
29
+ // it at the next turn boundary (resumePlayback) or the safety cap elapses.
30
+ //
31
+ // These scalars are read/written from both the capture-tap thread and the
32
+ // bridge thread (playAudioChunk / resumePlayback / setBargeInEnabled), so all
33
+ // access goes through `stateLock`. (Android uses @Volatile for the same set.)
34
+ private let stateLock: os_unfair_lock_t = {
35
+ let lock = os_unfair_lock_t.allocate(capacity: 1)
36
+ lock.initialize(to: os_unfair_lock())
37
+ return lock
38
+ }()
39
+ private var lastPlaybackWriteMs: Double = 0
40
+ private var suppressPlayback = false
41
+ private var bargeInAtMs: Double = 0
42
+ // Gates client-side barge-in detection. JS disarms it when it detects the
43
+ // device's AEC is failing (a burst of barge-ins = the AI's own echo, not a
44
+ // user) and downgrades to half-duplex, so the echo can't keep cutting the AI.
45
+ private var bargeInEnabled = true
46
+ // Consecutive over-threshold reads; only touched on the capture-tap thread.
47
+ private var bargeInHighReads = 0
48
+
49
+ // Barge-in tuning. iOS mic RMS is normalized to [0,1] (sample / Int16.max),
50
+ // unlike Android's raw Int16 (its threshold 1200/32767 ≈ 0.035). Require a
51
+ // couple of consecutive reads to reject transient clicks; only armed while the
52
+ // AI is actively playing. Tune on device.
53
+ private static let bargeInRmsThreshold: Float = 0.035
54
+ private static let bargeInSustainReads = 2
55
+ private static let playbackActiveWindowMs: Double = 350
56
+ // Safety cap: if no turn-end signal lifts suppression, resume anyway so a
57
+ // missed event can't permanently mute the next turn.
58
+ private static let suppressMaxMs: Double = 6000
59
+
60
+ @inline(__always)
61
+ private func withStateLock<T>(_ body: () -> T) -> T {
62
+ os_unfair_lock_lock(stateLock)
63
+ defer { os_unfair_lock_unlock(stateLock) }
64
+ return body()
65
+ }
66
+
67
+ // Monotonic milliseconds for the playback-active window and suppression cap
68
+ // (wall-clock jumps must not affect timing). Deltas only.
69
+ @inline(__always)
70
+ private func nowMs() -> Double {
71
+ return Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000.0
72
+ }
73
+
21
74
  // MARK: - RCTEventEmitter overrides
22
75
 
23
76
  override static func requiresMainQueueSetup() -> Bool {
@@ -25,7 +78,7 @@ class QafkaAudio: RCTEventEmitter {
25
78
  }
26
79
 
27
80
  override func supportedEvents() -> [String]! {
28
- return ["onAudioData", "onAmplitude"]
81
+ return ["onAudioData", "onAmplitude", "onBargeIn"]
29
82
  }
30
83
 
31
84
  override func startObserving() {
@@ -106,6 +159,16 @@ class QafkaAudio: RCTEventEmitter {
106
159
  ) {
107
160
  qafkaLog("[QafkaAudio] === doStartCapture begin ===")
108
161
 
162
+ // Fresh session starts full-duplex (barge-in armed) with no suppression;
163
+ // JS downgrades to half-duplex later if it detects the AEC is failing.
164
+ withStateLock {
165
+ bargeInEnabled = true
166
+ suppressPlayback = false
167
+ lastPlaybackWriteMs = 0
168
+ bargeInAtMs = 0
169
+ }
170
+ bargeInHighReads = 0
171
+
109
172
  // Defensive startup cleanup — recover from any leaked VPIO/audio-unit state
110
173
  // left by a prior crashed session. Apple Forums 721535 confirms the combo
111
174
  // of (deactivate) + (force category) + (output-port toggle) is needed to
@@ -215,6 +278,29 @@ class QafkaAudio: RCTEventEmitter {
215
278
  let rms = sqrt(sumSquares / Float(sampleCount))
216
279
  let level = min(rms * 3.0, 1.0)
217
280
  self.sendEvent(withName: "onAmplitude", body: ["level": level, "source": "mic"])
281
+
282
+ // Client-side barge-in: while the AI is actively playing, a sustained
283
+ // mic-energy spike means the user is talking over it — cut locally
284
+ // instead of waiting for the server's slower interrupt signal.
285
+ // Reuse the raw normalized `rms` (not the *3 display level).
286
+ let now = self.nowMs()
287
+ let (suppress, enabled, lastWrite) = self.withStateLock {
288
+ (self.suppressPlayback, self.bargeInEnabled, self.lastPlaybackWriteMs)
289
+ }
290
+ let playbackActive = (now - lastWrite) < QafkaAudio.playbackActiveWindowMs
291
+ if playbackActive && !suppress && enabled {
292
+ if rms > QafkaAudio.bargeInRmsThreshold {
293
+ self.bargeInHighReads += 1
294
+ if self.bargeInHighReads >= QafkaAudio.bargeInSustainReads {
295
+ self.bargeInHighReads = 0
296
+ self.triggerBargeIn()
297
+ }
298
+ } else {
299
+ self.bargeInHighReads = 0
300
+ }
301
+ } else {
302
+ self.bargeInHighReads = 0
303
+ }
218
304
  }
219
305
 
220
306
  let base64String = audioData.base64EncodedString()
@@ -394,6 +480,8 @@ class QafkaAudio: RCTEventEmitter {
394
480
  engine.reset()
395
481
  try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
396
482
  }
483
+ stateLock.deinitialize(count: 1)
484
+ stateLock.deallocate()
397
485
  }
398
486
 
399
487
  // MARK: - Playback
@@ -406,6 +494,30 @@ class QafkaAudio: RCTEventEmitter {
406
494
  playChunkCount += 1
407
495
  let isLoggedChunk = playChunkCount <= 3 || playChunkCount % 50 == 0
408
496
 
497
+ // Drop the AI's remaining audio after a local barge-in until the turn
498
+ // boundary lifts suppression (or the safety cap elapses). Without this the
499
+ // in-flight deltas of the just-cancelled turn would refill the player and
500
+ // the AI would audibly resume right after we cut it.
501
+ let now = nowMs()
502
+ var autoLifted = false
503
+ let drop: Bool = withStateLock {
504
+ guard suppressPlayback else { return false }
505
+ if now - bargeInAtMs > QafkaAudio.suppressMaxMs {
506
+ suppressPlayback = false
507
+ autoLifted = true
508
+ return false
509
+ }
510
+ return true
511
+ }
512
+ if autoLifted {
513
+ qafkaLog("[QafkaAudio] ⏱️ playAudioChunk #\(playChunkCount): suppression auto-lifted (6s safety cap)")
514
+ }
515
+ if drop {
516
+ qafkaLog("[QafkaAudio] 🚫 playAudioChunk #\(playChunkCount): dropped — barge-in suppression active")
517
+ resolve(true)
518
+ return
519
+ }
520
+
409
521
  guard let player = playerNode, let engine = audioEngine else {
410
522
  qafkaLog("[QafkaAudio] ❌ playAudioChunk #\(playChunkCount): engine not running")
411
523
  reject("NOT_RUNNING", "Audio engine is not running. Call startCapture first.", nil)
@@ -493,6 +605,9 @@ class QafkaAudio: RCTEventEmitter {
493
605
  }
494
606
 
495
607
  player.scheduleBuffer(outputBuffer, completionHandler: nil)
608
+ // Mark the playback as active so the capture tap's barge-in detector only
609
+ // arms while the AI is genuinely streaming audio (see playbackActiveWindowMs).
610
+ withStateLock { lastPlaybackWriteMs = nowMs() }
496
611
  if isLoggedChunk {
497
612
  qafkaLog("[QafkaAudio] 🔊 playAudioChunk #\(playChunkCount): scheduled \(outputBuffer.frameLength)f @ \(mixerFormat.sampleRate)Hz playerPlaying=\(player.isPlaying) engineRunning=\(engine.isRunning)")
498
613
  }
@@ -516,4 +631,59 @@ class QafkaAudio: RCTEventEmitter {
516
631
 
517
632
  resolve(true)
518
633
  }
634
+
635
+ // MARK: - Barge-in
636
+
637
+ /// Called from the capture tap when a sustained mic-energy spike is detected
638
+ /// while the AI is playing. Cuts playback instantly (purging scheduled
639
+ /// buffers) and starts dropping the cancelled turn's remaining audio; JS lifts
640
+ /// suppression at the next turn boundary via resumePlayback().
641
+ private func triggerBargeIn() {
642
+ withStateLock {
643
+ suppressPlayback = true
644
+ bargeInAtMs = nowMs()
645
+ }
646
+ // Tell JS immediately (UI → listening). sendEvent from the tap thread is
647
+ // already used above for onAudioData/onAmplitude.
648
+ sendEvent(withName: "onBargeIn", body: [String: Any]())
649
+ // Purge the cancelled turn's queued buffers OFF the capture-tap thread.
650
+ // AVAudioPlayerNode.stop() called from the tap (audio IO) callback can stall
651
+ // the IO thread (sluggish cut + choppy mic) and leave the node not rendering
652
+ // afterwards (the next turn schedules buffers that never sound — "stuck in
653
+ // listening"). The suppress flag above already drops NEW audio synchronously,
654
+ // so the cut still feels instant; deferring only the buffer purge to the main
655
+ // queue (where the config-change handler also does engine ops) is safe. Same
656
+ // stop()+play() pattern as stopPlayback, just off the IO thread.
657
+ DispatchQueue.main.async { [weak self] in
658
+ guard let self = self else { return }
659
+ self.playerNode?.stop()
660
+ self.playerNode?.play()
661
+ qafkaLog("[QafkaAudio] ⚡ Local barge-in cut applied (playerPlaying=\(self.playerNode?.isPlaying ?? false) engineRunning=\(self.audioEngine?.isRunning ?? false))")
662
+ }
663
+ }
664
+
665
+ @objc func resumePlayback(
666
+ _ resolve: @escaping RCTPromiseResolveBlock,
667
+ rejecter reject: @escaping RCTPromiseRejectBlock
668
+ ) {
669
+ withStateLock { suppressPlayback = false }
670
+ qafkaLog("[QafkaAudio] ▶️ resumePlayback — suppression lifted")
671
+ resolve(true)
672
+ }
673
+
674
+ /// Arm/disarm client-side barge-in. JS disarms it after detecting the device's
675
+ /// echo cancellation is failing (downgrade to half-duplex), so the AI's own
676
+ /// leaked echo can't keep triggering a cut. Disarming also clears suppression.
677
+ @objc func setBargeInEnabled(
678
+ _ enabled: Bool,
679
+ resolver resolve: @escaping RCTPromiseResolveBlock,
680
+ rejecter reject: @escaping RCTPromiseRejectBlock
681
+ ) {
682
+ withStateLock {
683
+ bargeInEnabled = enabled
684
+ if !enabled { suppressPlayback = false }
685
+ }
686
+ qafkaLog("[QafkaAudio] setBargeInEnabled=\(enabled)")
687
+ resolve(true)
688
+ }
519
689
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qafka/react-native",
3
- "version": "2.3.9",
3
+ "version": "2.4.0",
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",
@@ -15,7 +15,7 @@
15
15
  "react-native.config.js",
16
16
  "qafka.config.js",
17
17
  "qafka.config.d.ts",
18
- "docs",
18
+ "docs/qafka-tool-authoring-guide.md",
19
19
  "README.md",
20
20
  "CHANGELOG.md",
21
21
  "CONTRIBUTING.md",