@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 +10 -0
- package/android/src/main/AndroidManifest.xml +5 -0
- package/android/src/main/java/com/qafka/audio/QafkaAudioModule.kt +161 -16
- package/dist/decide-mic-forwarding.d.ts +23 -0
- package/dist/decide-mic-forwarding.js +28 -0
- package/dist/native/QafkaAudio.d.ts +3 -0
- package/dist/native/QafkaAudio.js +13 -0
- package/dist/services/RealtimeService.d.ts +16 -0
- package/dist/services/RealtimeService.js +113 -4
- package/dist/utils/toolNarration.d.ts +12 -7
- package/dist/utils/toolNarration.js +14 -8
- package/ios/QafkaAudio.m +9 -0
- package/ios/QafkaAudio.swift +171 -1
- package/package.json +2 -2
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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
|
|
189
|
-
//
|
|
190
|
-
// audio
|
|
191
|
-
//
|
|
192
|
-
//
|
|
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); *
|
|
197
|
-
val jitterBufferBytes = PLAYBACK_SAMPLE_RATE * 2 *
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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.
|
|
296
|
+
this.micEnabled = false;
|
|
297
|
+
this.syncMicForwarding();
|
|
192
298
|
}
|
|
193
299
|
unmuteMic() {
|
|
194
|
-
this.
|
|
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.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
package/ios/QafkaAudio.swift
CHANGED
|
@@ -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
|
+
"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",
|