@qafka/react-native 2.3.8 → 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/components/ChatPage.js +5 -5
- package/dist/decide-interrupt.d.ts +9 -0
- package/dist/decide-interrupt.js +12 -0
- package/dist/decide-mic-forwarding.d.ts +23 -0
- package/dist/decide-mic-forwarding.js +28 -0
- package/dist/hooks/useChatMessages.js +73 -22
- package/dist/hooks/useVoiceChat.js +16 -0
- package/dist/native/QafkaAudio.d.ts +3 -0
- package/dist/native/QafkaAudio.js +13 -0
- package/dist/services/RealtimeService.d.ts +18 -0
- package/dist/services/RealtimeService.js +119 -3
- package/dist/utils/extractionContinuation.d.ts +32 -0
- package/dist/utils/extractionContinuation.js +48 -0
- package/dist/utils/joinNarration.d.ts +15 -0
- package/dist/utils/joinNarration.js +25 -0
- package/dist/utils/toolNarration.d.ts +21 -0
- package/dist/utils/toolNarration.js +28 -0
- 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
|
}
|
|
@@ -60,7 +60,9 @@ function ChatPage({ displayMessages, isTyping, isSending, streamingMessage, tool
|
|
|
60
60
|
</react_native_1.View>)}
|
|
61
61
|
|
|
62
62
|
{/* Message List */}
|
|
63
|
-
<react_native_1.FlatList ref={flatListRef} data={displayMessages} renderItem={renderMessage} keyExtractor={(item) => item.id} style={{ flex: 1, flexGrow: 1, flexShrink: 1 }} contentContainerStyle={styles.messageListStyle} ListEmptyComponent={renderEmpty} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled"
|
|
63
|
+
<react_native_1.FlatList ref={flatListRef} data={displayMessages} renderItem={renderMessage} keyExtractor={(item) => item.id} style={{ flex: 1, flexGrow: 1, flexShrink: 1 }} contentContainerStyle={styles.messageListStyle} ListEmptyComponent={renderEmpty} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" ListFooterComponent={toolStatus ? (<react_native_1.View style={{ paddingHorizontal: 8, paddingTop: 4, alignItems: 'flex-start' }}>
|
|
64
|
+
<ToolStatusPill_1.ToolStatusPill message={toolStatus.message} theme={theme}/>
|
|
65
|
+
</react_native_1.View>) : null} onContentSizeChange={() => {
|
|
64
66
|
if (displayMessages.length > 0) {
|
|
65
67
|
flatListRef.current?.scrollToEnd({ animated: true });
|
|
66
68
|
}
|
|
@@ -71,10 +73,8 @@ function ChatPage({ displayMessages, isTyping, isSending, streamingMessage, tool
|
|
|
71
73
|
<TypingIndicator_1.TypingIndicator theme={theme}/>
|
|
72
74
|
</react_native_1.View>)}
|
|
73
75
|
|
|
74
|
-
{/* Tool Status Pill (
|
|
75
|
-
|
|
76
|
-
<ToolStatusPill_1.ToolStatusPill message={toolStatus.message} theme={theme}/>
|
|
77
|
-
</react_native_1.View>)}
|
|
76
|
+
{/* Tool Status Pill now renders inside the list (ListFooterComponent)
|
|
77
|
+
so it sits directly under the last message and scrolls with it. */}
|
|
78
78
|
|
|
79
79
|
{/* Input Area — hidden until SDK is ready so users can't type a message
|
|
80
80
|
that would be rejected by an un-attested send. TypingIndicator in the
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true when a realtime event signals a server-side interruption
|
|
3
|
+
* (user barge-in), so the caller can stop audio playback and let the user's
|
|
4
|
+
* new turn take over. Kept as a pure function so the decision is unit-testable
|
|
5
|
+
* without loading the native-bound RealtimeService.
|
|
6
|
+
*/
|
|
7
|
+
export declare function shouldStopPlayback(event: {
|
|
8
|
+
type?: string;
|
|
9
|
+
} | null | undefined): boolean;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.shouldStopPlayback = shouldStopPlayback;
|
|
4
|
+
/**
|
|
5
|
+
* Returns true when a realtime event signals a server-side interruption
|
|
6
|
+
* (user barge-in), so the caller can stop audio playback and let the user's
|
|
7
|
+
* new turn take over. Kept as a pure function so the decision is unit-testable
|
|
8
|
+
* without loading the native-bound RealtimeService.
|
|
9
|
+
*/
|
|
10
|
+
function shouldStopPlayback(event) {
|
|
11
|
+
return event?.type === 'response.interrupted';
|
|
12
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -4,6 +4,9 @@ exports.useChatMessages = void 0;
|
|
|
4
4
|
const react_1 = require("react");
|
|
5
5
|
const QafkaSDK_1 = require("../QafkaSDK");
|
|
6
6
|
const Qafka_utils_1 = require("../components/Qafka.utils");
|
|
7
|
+
const joinNarration_1 = require("../utils/joinNarration");
|
|
8
|
+
const extractionContinuation_1 = require("../utils/extractionContinuation");
|
|
9
|
+
const toolNarration_1 = require("../utils/toolNarration");
|
|
7
10
|
/**
|
|
8
11
|
* Custom hook for managing chat messages and sending
|
|
9
12
|
*/
|
|
@@ -32,29 +35,47 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
|
|
|
32
35
|
const lastConversationIdRef = (0, react_1.useRef)(null);
|
|
33
36
|
// guard against double-fire of postToolResult per toolKey
|
|
34
37
|
const inFlightPostToolResultRef = (0, react_1.useRef)(new Set());
|
|
38
|
+
// The most recent suggested tool that is BOTH multi-step and file-input.
|
|
39
|
+
// Uploading + extracting a file does not start a new model turn on its own,
|
|
40
|
+
// so when extraction completes we fire a hidden continuation turn (tools
|
|
41
|
+
// enabled) to let the model advance to the next step. Captured here at
|
|
42
|
+
// suggestion time because the extraction callback only carries the result,
|
|
43
|
+
// not the tool definition.
|
|
44
|
+
const activeMultiStepFileToolRef = (0, react_1.useRef)(null);
|
|
45
|
+
// Always-current `context` so a deferred send (the post-extraction
|
|
46
|
+
// continuation) reads the consumer's freshly-updated context (e.g. a CV
|
|
47
|
+
// summary it set in onExtractionResult) rather than a stale closure value.
|
|
48
|
+
const contextRef = (0, react_1.useRef)(context);
|
|
49
|
+
contextRef.current = context;
|
|
35
50
|
const scrollToBottom = (animated = true) => {
|
|
36
51
|
setTimeout(() => {
|
|
37
52
|
flatListRef.current?.scrollToEnd({ animated });
|
|
38
53
|
}, 100);
|
|
39
54
|
};
|
|
40
|
-
|
|
55
|
+
// `hidden` turns send a message to the backend WITHOUT rendering a user
|
|
56
|
+
// bubble — used to auto-advance a tool flow (e.g. after a file upload) where
|
|
57
|
+
// the "message" is a system continuation the end user never typed.
|
|
58
|
+
const handleSend = async (message, options) => {
|
|
41
59
|
if (isSending)
|
|
42
60
|
return;
|
|
61
|
+
const hidden = options?.hidden === true;
|
|
43
62
|
setIsSending(true);
|
|
44
63
|
setIsTyping(true);
|
|
45
64
|
setStreamingMessage('');
|
|
46
65
|
finalChunkBufferRef.current = '';
|
|
47
|
-
// Add user message
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
66
|
+
// Add user message (skipped for hidden continuation turns)
|
|
67
|
+
if (!hidden) {
|
|
68
|
+
const userMessage = {
|
|
69
|
+
id: Date.now().toString(),
|
|
70
|
+
role: 'user',
|
|
71
|
+
text: message,
|
|
72
|
+
content: message,
|
|
73
|
+
timestamp: new Date(),
|
|
74
|
+
};
|
|
75
|
+
setMessages((prev) => [...prev, userMessage]);
|
|
76
|
+
onMessageSent?.(message);
|
|
77
|
+
scrollToBottom();
|
|
78
|
+
}
|
|
58
79
|
const messageToSendAI = contextDescription
|
|
59
80
|
? `${contextDescription}\n\n${message}`
|
|
60
81
|
: message;
|
|
@@ -179,7 +200,7 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
|
|
|
179
200
|
// onError
|
|
180
201
|
(err) => {
|
|
181
202
|
handleError(err);
|
|
182
|
-
},
|
|
203
|
+
}, contextRef.current, contextDescription,
|
|
183
204
|
// onToolSuggested
|
|
184
205
|
async (tools, conversationId, messageId) => {
|
|
185
206
|
if (conversationId) {
|
|
@@ -188,6 +209,12 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
|
|
|
188
209
|
if (messageId) {
|
|
189
210
|
lastMessageIdRef.current = messageId;
|
|
190
211
|
}
|
|
212
|
+
// Remember a multi-step file tool so a completed extraction can
|
|
213
|
+
// auto-advance the flow (see the onExtractionResult handler below).
|
|
214
|
+
const multiStepFileTool = (tools || []).find(extractionContinuation_1.isMultiStepFileTool);
|
|
215
|
+
if (multiStepFileTool) {
|
|
216
|
+
activeMultiStepFileToolRef.current = multiStepFileTool;
|
|
217
|
+
}
|
|
191
218
|
if (onToolSuggested) {
|
|
192
219
|
// Tracks tools the consumer's `onToolSuggested` handler explicitly
|
|
193
220
|
// called `addResponse` for. The auto-fire loop below uses this — NOT
|
|
@@ -251,7 +278,13 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
|
|
|
251
278
|
// In managed follow-up mode the SDK should POST the
|
|
252
279
|
// tool result back to the backend, which runs a second LLM turn
|
|
253
280
|
// and streams a `final_chunk` reply.
|
|
254
|
-
|
|
281
|
+
//
|
|
282
|
+
// Skipped for card-rendering tools: there the model's first reply
|
|
283
|
+
// (bridge) already carries the conversation and the card shows the
|
|
284
|
+
// data, so the turn-2 narration only repeats it in a second message.
|
|
285
|
+
if (commits &&
|
|
286
|
+
tool?.executionMode === 'custom-with-ai' &&
|
|
287
|
+
!(0, toolNarration_1.shouldSkipTurn2Narration)(tool)) {
|
|
255
288
|
const conversationId = lastConversationIdRef.current;
|
|
256
289
|
const messageId = lastMessageIdRef.current;
|
|
257
290
|
if (conversationId && messageId) {
|
|
@@ -288,7 +321,10 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
|
|
|
288
321
|
setMessages((prev) => {
|
|
289
322
|
const lastMsg = prev[prev.length - 1];
|
|
290
323
|
if (lastMsg && lastMsg.role === 'assistant') {
|
|
291
|
-
|
|
324
|
+
// Turn-2 (post file-upload) narration appends to the same bubble; keep a
|
|
325
|
+
// paragraph break so it doesn't glue onto turn-1 ("..yeterli!Harika!").
|
|
326
|
+
// Matches the streaming path's separator and the backend persisted join.
|
|
327
|
+
const nextText = (0, joinNarration_1.joinNarration)(lastMsg.text || '', content);
|
|
292
328
|
return [
|
|
293
329
|
...prev.slice(0, -1),
|
|
294
330
|
{
|
|
@@ -401,8 +437,26 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
|
|
|
401
437
|
}
|
|
402
438
|
onFileUploadRequest?.(request);
|
|
403
439
|
},
|
|
404
|
-
// onExtractionResult
|
|
405
|
-
|
|
440
|
+
// onExtractionResult — forward to consumer, then auto-advance multi-step
|
|
441
|
+
// file flows. A completed extraction does not start a new model turn by
|
|
442
|
+
// itself, so we fire a hidden continuation turn (tools enabled) carrying
|
|
443
|
+
// the extracted fields, letting the model proceed to the next step.
|
|
444
|
+
(result) => {
|
|
445
|
+
onExtractionResult?.(result);
|
|
446
|
+
const tool = activeMultiStepFileToolRef.current;
|
|
447
|
+
if ((0, extractionContinuation_1.shouldAutoContinueAfterExtraction)({
|
|
448
|
+
isMultiStepFileTool: !!tool,
|
|
449
|
+
extractionStatus: result?.status,
|
|
450
|
+
})) {
|
|
451
|
+
activeMultiStepFileToolRef.current = null;
|
|
452
|
+
// A completed extraction does not start a new model turn by itself; fire a
|
|
453
|
+
// hidden continuation (tools enabled) carrying the extracted fields inline
|
|
454
|
+
// so the model reliably advances to the next step.
|
|
455
|
+
void handleSend((0, extractionContinuation_1.buildExtractionContinuationMessage)(result?.data), {
|
|
456
|
+
hidden: true,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
},
|
|
406
460
|
// onToolStatus: show pill
|
|
407
461
|
(status) => {
|
|
408
462
|
setToolStatus(status);
|
|
@@ -439,15 +493,12 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
|
|
|
439
493
|
setToolStatus(null);
|
|
440
494
|
finalChunkBufferRef.current +=
|
|
441
495
|
(finalChunkBufferRef.current ? '\n\n' : '') + content;
|
|
442
|
-
setStreamingMessage((prev) =>
|
|
443
|
-
const sep = prev && !prev.endsWith('\n') ? '\n\n' : '';
|
|
444
|
-
return prev + sep + content;
|
|
445
|
-
});
|
|
496
|
+
setStreamingMessage((prev) => (0, joinNarration_1.joinNarration)(prev, content));
|
|
446
497
|
});
|
|
447
498
|
}
|
|
448
499
|
else {
|
|
449
500
|
// Non-streaming
|
|
450
|
-
const response = await sdk.sendMessage(message,
|
|
501
|
+
const response = await sdk.sendMessage(message, contextRef.current, contextDescription);
|
|
451
502
|
const aiMessage = {
|
|
452
503
|
id: response.id,
|
|
453
504
|
role: 'assistant',
|
|
@@ -131,6 +131,22 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
|
|
|
131
131
|
}
|
|
132
132
|
return;
|
|
133
133
|
}
|
|
134
|
+
case 'response.interrupted': {
|
|
135
|
+
// Barge-in: the user cut in. RealtimeService already stopped native
|
|
136
|
+
// playback; finalize the partial AI turn (matching the server, which
|
|
137
|
+
// persists what was already streamed) and return to listening.
|
|
138
|
+
const partialAi = transcriptRef.current;
|
|
139
|
+
if (partialAi && partialAi.trim().length > 0) {
|
|
140
|
+
setTranscriptHistory((h) => [
|
|
141
|
+
...h,
|
|
142
|
+
{ id: `a_${Date.now()}_${h.length}`, role: 'assistant', text: partialAi },
|
|
143
|
+
]);
|
|
144
|
+
}
|
|
145
|
+
transcriptRef.current = '';
|
|
146
|
+
setTranscript('');
|
|
147
|
+
setState('listening');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
134
150
|
case 'session.closed':
|
|
135
151
|
setState('idle');
|
|
136
152
|
setNavigationSuggestion(null);
|
|
@@ -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
|
};
|