@qafka/react-native 2.3.2 → 2.3.4

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.
@@ -1,2 +1,4 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <!-- Required for voice capture. Merged into the consumer app manifest. -->
3
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
2
4
  </manifest>
@@ -1,8 +1,11 @@
1
1
  package com.qafka.audio
2
2
 
3
3
  import android.Manifest
4
+ import android.content.Context
4
5
  import android.content.pm.PackageManager
6
+ import android.media.AudioAttributes
5
7
  import android.media.AudioFormat
8
+ import android.media.AudioManager
6
9
  import android.media.AudioRecord
7
10
  import android.media.AudioTrack
8
11
  import android.media.MediaRecorder
@@ -33,6 +36,11 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
33
36
  private var noiseSuppressor: NoiseSuppressor? = null
34
37
  private var automaticGainControl: AutomaticGainControl? = null
35
38
 
39
+ // Audio-mode state saved on startCapture and restored on stopCapture so the
40
+ // host app's audio routing is left exactly as we found it.
41
+ private var previousAudioMode: Int? = null
42
+ private var previousSpeakerphoneOn: Boolean? = null
43
+
36
44
  companion object {
37
45
  private const val TAG = "QafkaAudio"
38
46
  private const val CAPTURE_SAMPLE_RATE = 16000
@@ -110,6 +118,24 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
110
118
 
111
119
  val bufferSize = maxOf(minBufferSize, 4096)
112
120
 
121
+ // iOS VPIO equivalent (part 1): put the audio system into communication
122
+ // mode and route both capture and playback through the voice path.
123
+ // Without this the AudioTrack below plays on the media path, which the
124
+ // VOICE_COMMUNICATION capture's echo canceler has no reference to — so
125
+ // the AI's own playback leaks back into the mic and it talks to itself.
126
+ try {
127
+ val audioManager =
128
+ reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
129
+ previousAudioMode = audioManager.mode
130
+ previousSpeakerphoneOn = audioManager.isSpeakerphoneOn
131
+ audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
132
+ @Suppress("DEPRECATION")
133
+ audioManager.isSpeakerphoneOn = true
134
+ dlogI("✅ AudioManager mode=IN_COMMUNICATION, speakerphone=on")
135
+ } catch (e: Exception) {
136
+ dlogW("⚠️ Failed to set communication audio mode: ${e.message}")
137
+ }
138
+
113
139
  try {
114
140
  val record = AudioRecord(
115
141
  MediaRecorder.AudioSource.VOICE_COMMUNICATION,
@@ -164,7 +190,16 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
164
190
  )
165
191
  val playBufferSize = maxOf(playMinBuffer, 4096)
166
192
 
193
+ // iOS VPIO equivalent (part 2): play on the voice-communication path
194
+ // (USAGE_VOICE_COMMUNICATION + CONTENT_TYPE_SPEECH) so the echo
195
+ // canceler bound to the capture session can reference and cancel it.
167
196
  val track = AudioTrack.Builder()
197
+ .setAudioAttributes(
198
+ AudioAttributes.Builder()
199
+ .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
200
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
201
+ .build()
202
+ )
168
203
  .setAudioFormat(
169
204
  AudioFormat.Builder()
170
205
  .setSampleRate(PLAYBACK_SAMPLE_RATE)
@@ -247,6 +282,19 @@ class QafkaAudioModule(reactContext: ReactApplicationContext) :
247
282
  } catch (_: Exception) {}
248
283
  audioTrack = null
249
284
 
285
+ // Restore the host app's audio mode / routing exactly as we found it.
286
+ try {
287
+ val audioManager =
288
+ reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
289
+ previousAudioMode?.let { audioManager.mode = it }
290
+ previousSpeakerphoneOn?.let {
291
+ @Suppress("DEPRECATION")
292
+ audioManager.isSpeakerphoneOn = it
293
+ }
294
+ } catch (_: Exception) {}
295
+ previousAudioMode = null
296
+ previousSpeakerphoneOn = null
297
+
250
298
  promise.resolve(true)
251
299
  }
252
300
 
@@ -1,4 +1,11 @@
1
1
  export declare const QafkaAudio: {
2
+ /**
3
+ * Ensure microphone access before capturing. iOS prompts natively inside
4
+ * startCapture; on Android the RECORD_AUDIO runtime permission is requested
5
+ * here (the platform never auto-prompts), otherwise capture starts denied and
6
+ * the voice stream is silent. Resolves to whether mic access is available.
7
+ */
8
+ ensureRecordPermission: () => Promise<boolean>;
2
9
  startCapture: () => Promise<boolean>;
3
10
  stopCapture: () => Promise<boolean>;
4
11
  playAudioChunk: (base64Data: string) => Promise<boolean>;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.QafkaAudio = void 0;
4
4
  const react_native_1 = require("react-native");
5
+ const ensure_record_permission_1 = require("./ensure-record-permission");
5
6
  const { QafkaAudio: NativeQafkaAudio } = react_native_1.NativeModules;
6
7
  const emitter = NativeQafkaAudio ? new react_native_1.NativeEventEmitter(NativeQafkaAudio) : null;
7
8
  const MISSING_MODULE_MESSAGE = `QafkaAudio native module is not linked. ` +
@@ -15,6 +16,25 @@ const requireModule = () => {
15
16
  return NativeQafkaAudio;
16
17
  };
17
18
  exports.QafkaAudio = {
19
+ /**
20
+ * Ensure microphone access before capturing. iOS prompts natively inside
21
+ * startCapture; on Android the RECORD_AUDIO runtime permission is requested
22
+ * here (the platform never auto-prompts), otherwise capture starts denied and
23
+ * the voice stream is silent. Resolves to whether mic access is available.
24
+ */
25
+ ensureRecordPermission: () => (0, ensure_record_permission_1.ensureRecordPermission)({
26
+ os: react_native_1.Platform.OS,
27
+ check: () => react_native_1.PermissionsAndroid.check(react_native_1.PermissionsAndroid.PERMISSIONS.RECORD_AUDIO),
28
+ request: async () => {
29
+ const result = await react_native_1.PermissionsAndroid.request(react_native_1.PermissionsAndroid.PERMISSIONS.RECORD_AUDIO, {
30
+ title: 'Microphone permission',
31
+ message: 'Microphone access is required for voice conversations.',
32
+ buttonPositive: 'OK',
33
+ buttonNegative: 'Cancel',
34
+ });
35
+ return result === react_native_1.PermissionsAndroid.RESULTS.GRANTED;
36
+ },
37
+ }),
18
38
  startCapture: () => requireModule().startCapture(),
19
39
  stopCapture: () => requireModule().stopCapture(),
20
40
  playAudioChunk: (base64Data) => requireModule().playAudioChunk(base64Data),
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Resolve microphone permission before voice capture starts.
3
+ *
4
+ * iOS requests the permission as part of starting native capture, so this is a
5
+ * no-op there. On Android the platform never auto-prompts for the runtime
6
+ * RECORD_AUDIO permission — it must be requested explicitly, otherwise capture
7
+ * starts denied and the voice stream stays silent.
8
+ *
9
+ * Kept pure and dependency-injected so it can be unit-tested without the
10
+ * react-native runtime; {@link QafkaAudio} wires the real platform calls in.
11
+ */
12
+ export interface RecordPermissionDeps {
13
+ /** Platform.OS */
14
+ os: string;
15
+ /** Resolve whether RECORD_AUDIO is already granted. */
16
+ check: () => Promise<boolean>;
17
+ /** Prompt the user for RECORD_AUDIO; resolve whether it was granted. */
18
+ request: () => Promise<boolean>;
19
+ }
20
+ export declare function ensureRecordPermission(deps: RecordPermissionDeps): Promise<boolean>;
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ensureRecordPermission = ensureRecordPermission;
4
+ async function ensureRecordPermission(deps) {
5
+ // iOS handles the prompt natively inside startCapture.
6
+ if (deps.os !== 'android')
7
+ return true;
8
+ if (await deps.check())
9
+ return true;
10
+ return deps.request();
11
+ }
@@ -129,6 +129,17 @@ class RealtimeService {
129
129
  }
130
130
  async startAudioPipeline() {
131
131
  try {
132
+ // Android needs the RECORD_AUDIO runtime permission requested explicitly
133
+ // before capture (iOS prompts natively inside startCapture). Without this
134
+ // capture starts denied and the voice stream is silent.
135
+ const micGranted = await QafkaAudio_1.QafkaAudio.ensureRecordPermission();
136
+ if (!micGranted) {
137
+ this.eventHandler?.({
138
+ type: 'error',
139
+ message: 'Microphone permission denied',
140
+ });
141
+ return;
142
+ }
132
143
  // Attach listener BEFORE startCapture so the native tap
133
144
  // doesn't drop early buffers (hasListeners/listenerCount gate).
134
145
  this.attachAudioListener();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qafka/react-native",
3
- "version": "2.3.2",
3
+ "version": "2.3.4",
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",