@iternio/react-native-auto-play 0.4.11 → 0.5.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.
Files changed (61) hide show
  1. package/README.md +26 -0
  2. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridAutoPlay.kt +0 -89
  3. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridVoice.kt +97 -0
  4. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/VoiceInputManager.kt +294 -20
  5. package/android/src/main/java/com/margelo/nitro/swe/iternio/reactnativeautoplay/utils/ThreadUtil.kt +6 -13
  6. package/ios/hybrid/HybridAutoPlay.swift +2 -47
  7. package/ios/hybrid/HybridVoice.swift +65 -0
  8. package/ios/utils/VoiceInputManager.swift +151 -41
  9. package/lib/hybrid/HybridVoice.d.ts +52 -0
  10. package/lib/hybrid/HybridVoice.js +52 -0
  11. package/lib/index.d.ts +3 -1
  12. package/lib/index.js +2 -1
  13. package/lib/specs/AutoPlay.nitro.d.ts +0 -29
  14. package/lib/specs/Voice.nitro.d.ts +11 -0
  15. package/lib/specs/Voice.nitro.js +1 -0
  16. package/lib/types/Voice.d.ts +16 -0
  17. package/lib/types/Voice.js +1 -0
  18. package/nitro.json +10 -0
  19. package/nitrogen/generated/android/ReactNativeAutoPlay+autolinking.cmake +2 -0
  20. package/nitrogen/generated/android/ReactNativeAutoPlayOnLoad.cpp +18 -0
  21. package/nitrogen/generated/android/c++/JFunc_void_VoiceInputChunk.hpp +81 -0
  22. package/nitrogen/generated/android/c++/JHybridAutoPlaySpec.cpp +0 -43
  23. package/nitrogen/generated/android/c++/JHybridAutoPlaySpec.hpp +0 -4
  24. package/nitrogen/generated/android/c++/JHybridVoiceSpec.cpp +104 -0
  25. package/nitrogen/generated/android/c++/JHybridVoiceSpec.hpp +66 -0
  26. package/nitrogen/generated/android/c++/JVoiceInputChunk.hpp +64 -0
  27. package/nitrogen/generated/android/c++/JVoiceInputResult.hpp +64 -0
  28. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/Func_void_VoiceInputChunk.kt +80 -0
  29. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridAutoPlaySpec.kt +0 -17
  30. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/HybridVoiceSpec.kt +72 -0
  31. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/VoiceInputChunk.kt +56 -0
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/swe/iternio/reactnativeautoplay/VoiceInputResult.kt +56 -0
  33. package/nitrogen/generated/ios/ReactNativeAutoPlay-Swift-Cxx-Bridge.cpp +41 -16
  34. package/nitrogen/generated/ios/ReactNativeAutoPlay-Swift-Cxx-Bridge.hpp +201 -126
  35. package/nitrogen/generated/ios/ReactNativeAutoPlay-Swift-Cxx-Umbrella.hpp +11 -0
  36. package/nitrogen/generated/ios/ReactNativeAutoPlayAutolinking.mm +8 -0
  37. package/nitrogen/generated/ios/ReactNativeAutoPlayAutolinking.swift +12 -0
  38. package/nitrogen/generated/ios/c++/HybridAutoPlaySpecSwift.hpp +0 -34
  39. package/nitrogen/generated/ios/c++/HybridVoiceSpecSwift.cpp +11 -0
  40. package/nitrogen/generated/ios/c++/HybridVoiceSpecSwift.hpp +116 -0
  41. package/nitrogen/generated/ios/swift/Func_void_VoiceInputChunk.swift +46 -0
  42. package/nitrogen/generated/ios/swift/{Func_void_std__shared_ptr_ArrayBuffer_.swift → Func_void_VoiceInputResult.swift} +10 -10
  43. package/nitrogen/generated/ios/swift/Func_void_bool.swift +5 -5
  44. package/nitrogen/generated/ios/swift/HybridAutoPlaySpec.swift +0 -4
  45. package/nitrogen/generated/ios/swift/HybridAutoPlaySpec_cxx.swift +0 -82
  46. package/nitrogen/generated/ios/swift/HybridVoiceSpec.swift +58 -0
  47. package/nitrogen/generated/ios/swift/HybridVoiceSpec_cxx.swift +234 -0
  48. package/nitrogen/generated/ios/swift/VoiceInputChunk.swift +60 -0
  49. package/nitrogen/generated/ios/swift/VoiceInputResult.swift +60 -0
  50. package/nitrogen/generated/shared/c++/HybridAutoPlaySpec.cpp +0 -4
  51. package/nitrogen/generated/shared/c++/HybridAutoPlaySpec.hpp +0 -5
  52. package/nitrogen/generated/shared/c++/HybridVoiceSpec.cpp +24 -0
  53. package/nitrogen/generated/shared/c++/HybridVoiceSpec.hpp +73 -0
  54. package/nitrogen/generated/shared/c++/VoiceInputChunk.hpp +89 -0
  55. package/nitrogen/generated/shared/c++/VoiceInputResult.hpp +89 -0
  56. package/package.json +1 -1
  57. package/src/hybrid/HybridVoice.ts +79 -0
  58. package/src/index.ts +3 -1
  59. package/src/specs/AutoPlay.nitro.ts +0 -37
  60. package/src/specs/Voice.nitro.ts +16 -0
  61. package/src/types/Voice.ts +18 -0
package/README.md CHANGED
@@ -314,6 +314,32 @@ The library does **not** bundle any icon font — the consuming app must provide
314
314
 
315
315
  For cross-platform compatibility use **lowercase names with underscores only** (e.g. `material_symbols`).
316
316
 
317
+ **or**
318
+
319
+ 1. use expo-font
320
+ ```js
321
+ [
322
+ 'expo-font',
323
+ {
324
+ android: {
325
+ fonts: [
326
+ {
327
+ fontFamily: 'MaterialSymbols',
328
+ fontDefinitions: [
329
+ {
330
+ path: './assets/fonts/material_symbols.ttf',
331
+ weight: 800,
332
+ },
333
+ ],
334
+ },
335
+ ],
336
+ },
337
+ ios: ['./assets/fonts/material_symbols.ttf'],
338
+ },
339
+ ],
340
+ ```
341
+ For cross-platform compatibility use **lowercase names with underscores only** (e.g. `material_symbols`).
342
+
317
343
  2. Register the font and an optional glyph map at startup:
318
344
 
319
345
  ```ts
@@ -1,19 +1,11 @@
1
1
  package com.margelo.nitro.swe.iternio.reactnativeautoplay
2
2
 
3
- import android.content.pm.PackageManager
4
3
  import android.os.Build
5
- import androidx.core.content.ContextCompat
6
4
  import com.facebook.react.bridge.UiThreadUtil
7
- import com.facebook.react.modules.core.PermissionAwareActivity
8
- import com.facebook.react.modules.core.PermissionListener
9
- import com.margelo.nitro.NitroModules
10
- import com.margelo.nitro.core.ArrayBuffer
11
5
  import com.margelo.nitro.core.Promise
12
6
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.template.AndroidAutoTemplate
13
7
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.template.MessageTemplate
14
8
  import com.margelo.nitro.swe.iternio.reactnativeautoplay.utils.ThreadUtil
15
- import kotlinx.coroutines.suspendCancellableCoroutine
16
- import java.nio.ByteBuffer
17
9
  import java.util.concurrent.ConcurrentHashMap
18
10
  import java.util.concurrent.CopyOnWriteArrayList
19
11
  import kotlin.coroutines.resume
@@ -255,84 +247,6 @@ class HybridAutoPlay : HybridAutoPlaySpec() {
255
247
  }
256
248
  }
257
249
 
258
- override fun hasVoiceInputPermission(): Boolean {
259
- val context = NitroModules.applicationContext ?: return false
260
- return ContextCompat.checkSelfPermission(
261
- context, android.Manifest.permission.RECORD_AUDIO
262
- ) == PackageManager.PERMISSION_GRANTED
263
- }
264
-
265
- override fun requestVoiceInputPermission(): Promise<Boolean> {
266
- return Promise.async {
267
- if (hasVoiceInputPermission()) {
268
- return@async true
269
- }
270
-
271
- val carContext = AndroidAutoSession.getRootContext()
272
-
273
- if (carContext != null) {
274
- suspendCancellableCoroutine {
275
- carContext.requestPermissions(listOf(android.Manifest.permission.RECORD_AUDIO)) { approved, _ ->
276
- it.resume(approved.contains(android.Manifest.permission.RECORD_AUDIO))
277
- }
278
- }
279
- } else {
280
- val context = NitroModules.applicationContext ?: return@async false
281
- val activity =
282
- context.currentActivity as? PermissionAwareActivity ?: return@async false
283
- val code = (Math.random() * 10000).toInt()
284
-
285
- suspendCancellableCoroutine {
286
- activity.requestPermissions(
287
- arrayOf(android.Manifest.permission.RECORD_AUDIO),
288
- code,
289
- PermissionListener { requestCode, _, grantResults ->
290
- if (requestCode != code) {
291
- return@PermissionListener false
292
- }
293
-
294
- val granted =
295
- grantResults.isNotEmpty() && grantResults.first() == PackageManager.PERMISSION_GRANTED
296
-
297
- it.resume(granted)
298
-
299
- return@PermissionListener true
300
- })
301
- }
302
- }
303
- }
304
- }
305
-
306
- override fun startVoiceInput(
307
- silenceThresholdMs: Double?, maxDurationMs: Double?, listeningText: String?
308
- ): Promise<ArrayBuffer> {
309
- return Promise.async {
310
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
311
- throw UnsupportedOperationException("startVoiceInput requires at least API level ${Build.VERSION_CODES.O}")
312
- }
313
-
314
- val manager = VoiceInputManager(AndroidAutoSession.getRootContext())
315
- voiceInputManager = manager
316
-
317
- try {
318
- val pcmBytes = manager.start(
319
- silenceThresholdMs = silenceThresholdMs?.toLong() ?: 1_500L,
320
- maxDurationMs = maxDurationMs?.toLong() ?: 10_000L,
321
- )
322
- val directBuffer =
323
- ByteBuffer.allocateDirect(pcmBytes.size).put(pcmBytes).rewind() as ByteBuffer
324
- ArrayBuffer.wrap(directBuffer)
325
- } finally {
326
- voiceInputManager = null
327
- manager.dispose()
328
- }
329
- }
330
- }
331
-
332
- override fun stopVoiceInput() {
333
- voiceInputManager?.stop()
334
- }
335
-
336
250
  companion object {
337
251
  const val TAG = "HybridAutoPlay"
338
252
 
@@ -343,9 +257,6 @@ class HybridAutoPlay : HybridAutoPlaySpec() {
343
257
 
344
258
  private val voiceInputListeners = CopyOnWriteArrayList<(Location?, String?) -> Unit>()
345
259
 
346
- @Volatile
347
- private var voiceInputManager: VoiceInputManager? = null
348
-
349
260
  private val safeAreaInsetsListeners =
350
261
  ConcurrentHashMap<String, CopyOnWriteArrayList<(SafeAreaInsets) -> Unit>>()
351
262
 
@@ -0,0 +1,97 @@
1
+ package com.margelo.nitro.swe.iternio.reactnativeautoplay
2
+
3
+ import android.content.pm.PackageManager
4
+ import android.os.Build
5
+ import androidx.core.content.ContextCompat
6
+ import com.facebook.react.modules.core.PermissionAwareActivity
7
+ import com.facebook.react.modules.core.PermissionListener
8
+ import com.margelo.nitro.NitroModules
9
+ import com.margelo.nitro.core.Promise
10
+ import kotlinx.coroutines.suspendCancellableCoroutine
11
+ import kotlin.coroutines.resume
12
+
13
+ class HybridVoice : HybridVoiceSpec() {
14
+ @Volatile
15
+ private var voiceInputManager: VoiceInputManager? = null
16
+
17
+ override fun hasVoiceInputPermission(): Boolean {
18
+ return VoiceInputManager.hasVoiceInputPermission()
19
+ }
20
+
21
+ override fun requestVoiceInputPermission(): Promise<Boolean> {
22
+ return Promise.async {
23
+ if (hasVoiceInputPermission()) {
24
+ return@async true
25
+ }
26
+
27
+ val carContext = AndroidAutoSession.getRootContext()
28
+
29
+ if (carContext != null) {
30
+ suspendCancellableCoroutine { cont ->
31
+ carContext.requestPermissions(
32
+ listOf(android.Manifest.permission.RECORD_AUDIO)
33
+ ) { approved, _ ->
34
+ cont.resume(approved.contains(android.Manifest.permission.RECORD_AUDIO))
35
+ }
36
+ }
37
+ } else {
38
+ val context = NitroModules.applicationContext ?: return@async false
39
+ val activity =
40
+ context.currentActivity as? PermissionAwareActivity ?: return@async false
41
+ val code = (Math.random() * 10000).toInt()
42
+
43
+ suspendCancellableCoroutine { cont ->
44
+ activity.requestPermissions(
45
+ arrayOf(android.Manifest.permission.RECORD_AUDIO),
46
+ code,
47
+ PermissionListener { requestCode, _, grantResults ->
48
+ if (requestCode != code) {
49
+ return@PermissionListener false
50
+ }
51
+ cont.resume(
52
+ grantResults.isNotEmpty() &&
53
+ grantResults.first() == PackageManager.PERMISSION_GRANTED
54
+ )
55
+ true
56
+ }
57
+ )
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ override fun startVoiceInput(
64
+ silenceThresholdMs: Double?,
65
+ maxDurationMs: Double?,
66
+ listeningText: String?,
67
+ preferSpeechToText: Boolean?,
68
+ onChunk: ((chunk: VoiceInputChunk) -> Unit)?,
69
+ language: String?
70
+ ): Promise<VoiceInputResult> {
71
+ return Promise.async {
72
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
73
+ throw UnsupportedOperationException("startVoiceInput requires at least API level ${Build.VERSION_CODES.O}")
74
+ }
75
+
76
+ val manager = VoiceInputManager(AndroidAutoSession.getRootContext())
77
+ voiceInputManager = manager
78
+
79
+ try {
80
+ manager.start(
81
+ silenceThresholdMs = silenceThresholdMs?.toLong() ?: 1_500L,
82
+ maxDurationMs = maxDurationMs?.toLong() ?: 10_000L,
83
+ preferSpeechToText = preferSpeechToText ?: false,
84
+ onChunk = onChunk,
85
+ language = language
86
+ )
87
+ } finally {
88
+ voiceInputManager = null
89
+ manager.dispose()
90
+ }
91
+ }
92
+ }
93
+
94
+ override fun stopVoiceInput() {
95
+ voiceInputManager?.stop()
96
+ }
97
+ }
@@ -1,5 +1,9 @@
1
1
  package com.margelo.nitro.swe.iternio.reactnativeautoplay
2
2
 
3
+ import android.Manifest
4
+ import android.annotation.SuppressLint
5
+ import android.content.Context
6
+ import android.content.Intent
3
7
  import android.content.pm.PackageManager
4
8
  import android.media.AudioAttributes
5
9
  import android.media.AudioFocusRequest
@@ -8,18 +12,28 @@ import android.media.AudioManager
8
12
  import android.media.AudioRecord
9
13
  import android.media.MediaRecorder
10
14
  import android.os.Build
15
+ import android.os.Bundle
16
+ import android.os.ParcelFileDescriptor
17
+ import android.speech.RecognitionListener
18
+ import android.speech.RecognizerIntent
19
+ import android.speech.SpeechRecognizer
11
20
  import androidx.annotation.RequiresApi
12
21
  import androidx.car.app.CarContext
13
22
  import androidx.car.app.media.CarAudioRecord
14
23
  import androidx.core.content.ContextCompat
24
+ import com.facebook.react.bridge.UiThreadUtil
15
25
  import com.margelo.nitro.NitroModules
26
+ import com.margelo.nitro.core.ArrayBuffer
27
+ import com.margelo.nitro.swe.iternio.reactnativeautoplay.utils.ThreadUtil
16
28
  import kotlinx.coroutines.CoroutineScope
17
29
  import kotlinx.coroutines.Dispatchers
18
30
  import kotlinx.coroutines.Job
31
+ import kotlinx.coroutines.async
19
32
  import kotlinx.coroutines.cancel
20
33
  import kotlinx.coroutines.launch
21
34
  import kotlinx.coroutines.suspendCancellableCoroutine
22
35
  import java.io.ByteArrayOutputStream
36
+ import java.nio.ByteBuffer
23
37
  import kotlin.coroutines.Continuation
24
38
  import kotlin.coroutines.resume
25
39
  import kotlin.coroutines.resumeWithException
@@ -27,43 +41,280 @@ import kotlin.math.abs
27
41
 
28
42
  /**
29
43
  * Captures 16-bit PCM audio (16 kHz, mono).
30
- * When [carContext] is provided uses CarAudioRecord (Android Auto/Automotive).
31
- * When [carContext] is null falls back to standard AudioRecord (phone-only).
44
+ * When [carContext] is provided uses CarAudioRecord (Android Auto/Automotive),
45
+ * otherwise falls back to standard AudioRecord.
46
+ *
47
+ * When preferSpeechToText is true and SpeechRecognizer is available, it owns
48
+ * the microphone and streams partial results; the PCM path is not used.
49
+ * When SpeechRecognizer is unavailable the manager falls back to PCM recording.
32
50
  */
33
51
  class VoiceInputManager(
34
52
  private val carContext: CarContext?,
35
53
  ) {
54
+ // PCM recording state
36
55
  private var carAudioRecord: CarAudioRecord? = null
37
56
  private var audioRecord: AudioRecord? = null
38
57
  private var audioFocusRequest: AudioFocusRequest? = null
39
58
  private var recordingJob: Job? = null
40
- private var continuation: Continuation<ByteArray>? = null
59
+ private var pcmContinuation: Continuation<ByteArray>? = null
41
60
  private val scope = CoroutineScope(Dispatchers.IO)
42
61
 
43
62
  @Volatile
44
63
  private var isRecording = false
45
64
 
46
- /**
47
- * Acquires audio focus, starts recording, and suspends until stopped.
48
- * Stops automatically after [silenceThresholdMs] of silence or [maxDurationMs] total.
49
- * Returns the complete raw PCM buffer (Int16 LE, 16 kHz, mono).
50
- */
65
+ // STT state — only set when SpeechRecognizer owns the mic
66
+ @Volatile
67
+ private var activeSpeechRecognizer: SpeechRecognizer? = null
68
+
51
69
  @RequiresApi(Build.VERSION_CODES.O)
52
70
  suspend fun start(
53
71
  silenceThresholdMs: Long = 1_500,
54
72
  maxDurationMs: Long = 10_000,
73
+ preferSpeechToText: Boolean = false,
74
+ onChunk: ((chunk: VoiceInputChunk) -> Unit)? = null,
75
+ language: String? = null
76
+ ): VoiceInputResult {
77
+ if (preferSpeechToText) {
78
+ val context = NitroModules.applicationContext ?: throw IllegalArgumentException()
79
+ if (SpeechRecognizer.isRecognitionAvailable(context)) {
80
+ if (carContext != null) {
81
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
82
+ return startSTTFromCarAudio(silenceThresholdMs, maxDurationMs, onChunk, language)
83
+ }
84
+ // Car connected but API < 33: EXTRA_AUDIO_SOURCE unavailable, fall back to PCM
85
+ return startPCM(silenceThresholdMs, maxDurationMs, onChunk)
86
+ }
87
+ return ThreadUtil.postOnUiAndAwait { startSTT(context, onChunk, language) }.getOrThrow()
88
+ }
89
+ }
90
+ return startPCM(silenceThresholdMs, maxDurationMs, onChunk)
91
+ }
92
+
93
+ // MARK: - STT path (SpeechRecognizer owns the mic)
94
+
95
+ private suspend fun startSTT(
96
+ context: Context,
97
+ onChunk: ((chunk: VoiceInputChunk) -> Unit)?,
98
+ language: String?
99
+ ): VoiceInputResult = suspendCancellableCoroutine { cont ->
100
+ val recognizer = SpeechRecognizer.createSpeechRecognizer(context)
101
+ activeSpeechRecognizer = recognizer
102
+
103
+ recognizer.setRecognitionListener(object : RecognitionListener {
104
+ override fun onResults(results: Bundle?) {
105
+ activeSpeechRecognizer = null
106
+ recognizer.destroy()
107
+ val text =
108
+ results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull()
109
+ cont.resume(VoiceInputResult(transcription = text, audio = null))
110
+ }
111
+
112
+ override fun onError(error: Int) {
113
+ activeSpeechRecognizer = null
114
+ recognizer.destroy()
115
+ cont.resumeWithException(RuntimeException("SpeechRecognizer error $error"))
116
+ }
117
+
118
+ override fun onPartialResults(partialResults: Bundle?) {
119
+ val text = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
120
+ ?.firstOrNull()
121
+ if (!text.isNullOrEmpty()) {
122
+ onChunk?.invoke(VoiceInputChunk(partial = text, audio = null))
123
+ }
124
+ }
125
+
126
+ override fun onReadyForSpeech(params: Bundle?) {}
127
+ override fun onBeginningOfSpeech() {}
128
+ override fun onRmsChanged(rmsdB: Float) {}
129
+ override fun onBufferReceived(buffer: ByteArray?) {}
130
+ override fun onEndOfSpeech() {}
131
+ override fun onEvent(eventType: Int, params: Bundle?) {}
132
+ })
133
+
134
+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
135
+ putExtra(
136
+ RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
137
+ )
138
+ putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
139
+ putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
140
+ language?.let {
141
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE, it)
142
+ }
143
+ }
144
+
145
+ recognizer.startListening(intent)
146
+
147
+ cont.invokeOnCancellation {
148
+ activeSpeechRecognizer = null
149
+ recognizer.destroy()
150
+ }
151
+ }
152
+
153
+ // MARK: - STT path fed from CarAudioRecord via a pipe (API 33+)
154
+ @SuppressLint("MissingPermission")
155
+ @RequiresApi(Build.VERSION_CODES.TIRAMISU)
156
+ private suspend fun startSTTFromCarAudio(
157
+ silenceThresholdMs: Long,
158
+ maxDurationMs: Long,
159
+ onChunk: ((chunk: VoiceInputChunk) -> Unit)?,
160
+ language: String?
161
+ ): VoiceInputResult {
162
+ if (!hasVoiceInputPermission()) {
163
+ throw SecurityException("RECORD_AUDIO permission not granted")
164
+ }
165
+
166
+ val appContext = NitroModules.applicationContext ?: throw IllegalArgumentException()
167
+ val pipes = ParcelFileDescriptor.createPipe()
168
+ val readFd = pipes[0]
169
+ val pipeOut = ParcelFileDescriptor.AutoCloseOutputStream(pipes[1])
170
+
171
+ val sttDeferred = scope.async {
172
+ ThreadUtil.postOnUiAndAwait {
173
+ startSTTWithSource(appContext, readFd, silenceThresholdMs, onChunk, language)
174
+ }.getOrThrow()
175
+ }
176
+
177
+ var pcmBytes: ByteArray
178
+ try {
179
+ pcmBytes = recordPCM(silenceThresholdMs, maxDurationMs) { chunk ->
180
+ chunk.audio?.let { ab ->
181
+ try {
182
+ pipeOut.write(ab.toByteArray())
183
+ } catch (_: Exception) {
184
+ isRecording = false
185
+ }
186
+ }
187
+ }
188
+ } finally {
189
+ try {
190
+ pipeOut.close()
191
+ } catch (_: Exception) {
192
+ }
193
+ try {
194
+ readFd.close()
195
+ } catch (_: Exception) {
196
+ }
197
+ }
198
+
199
+ return try {
200
+ sttDeferred.await()
201
+ } catch (_: Exception) {
202
+ val directBuffer = ByteBuffer.allocateDirect(pcmBytes.size).put(pcmBytes).rewind() as ByteBuffer
203
+ VoiceInputResult(transcription = null, audio = ArrayBuffer.wrap(directBuffer))
204
+ }
205
+ }
206
+
207
+ @RequiresApi(Build.VERSION_CODES.TIRAMISU)
208
+ private suspend fun startSTTWithSource(
209
+ context: Context,
210
+ audioSource: ParcelFileDescriptor,
211
+ silenceThresholdMs: Long,
212
+ onChunk: ((chunk: VoiceInputChunk) -> Unit)?,
213
+ language: String?
214
+ ): VoiceInputResult = suspendCancellableCoroutine { cont ->
215
+ val recognizer = SpeechRecognizer.createSpeechRecognizer(context)
216
+ activeSpeechRecognizer = recognizer
217
+ // When EXTRA_AUDIO_SOURCE is used, onResults always returns an empty list — the actual
218
+ // transcription only arrives via onPartialResults. Track the last partial here.
219
+ var lastPartial: String? = null
220
+
221
+ recognizer.setRecognitionListener(object : RecognitionListener {
222
+ override fun onResults(results: Bundle?) {
223
+ activeSpeechRecognizer = null
224
+ recognizer.destroy()
225
+ val text =
226
+ results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull()
227
+ ?: lastPartial
228
+ cont.resume(VoiceInputResult(transcription = text, audio = null))
229
+ }
230
+
231
+ override fun onError(error: Int) {
232
+ activeSpeechRecognizer = null
233
+ recognizer.destroy()
234
+ cont.resumeWithException(RuntimeException("SpeechRecognizer error $error"))
235
+ }
236
+
237
+ override fun onPartialResults(partialResults: Bundle?) {
238
+ val text = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
239
+ ?.firstOrNull()
240
+ if (!text.isNullOrEmpty()) {
241
+ lastPartial = text
242
+ onChunk?.invoke(VoiceInputChunk(partial = text, audio = null))
243
+ }
244
+ }
245
+
246
+ override fun onReadyForSpeech(params: Bundle?) {}
247
+ override fun onBeginningOfSpeech() {}
248
+ override fun onRmsChanged(rmsdB: Float) {}
249
+ override fun onBufferReceived(buffer: ByteArray?) {}
250
+ override fun onEndOfSpeech() {}
251
+ override fun onEvent(eventType: Int, params: Bundle?) {}
252
+ })
253
+
254
+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
255
+ putExtra(
256
+ RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
257
+ )
258
+ putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
259
+ putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
260
+ language?.let {
261
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE, it)
262
+ }
263
+ putExtra(RecognizerIntent.EXTRA_AUDIO_SOURCE, audioSource)
264
+ putExtra(RecognizerIntent.EXTRA_AUDIO_SOURCE_CHANNEL_COUNT, 1)
265
+ putExtra(RecognizerIntent.EXTRA_AUDIO_SOURCE_ENCODING, AudioFormat.ENCODING_PCM_16BIT)
266
+ putExtra(RecognizerIntent.EXTRA_AUDIO_SOURCE_SAMPLING_RATE, SAMPLE_RATE)
267
+ putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, WARMUP_MS)
268
+ putExtra(
269
+ RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS,
270
+ silenceThresholdMs
271
+ )
272
+ putExtra(
273
+ RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS,
274
+ silenceThresholdMs / 2,
275
+ )
276
+ }
277
+
278
+ recognizer.startListening(intent)
279
+
280
+ cont.invokeOnCancellation {
281
+ activeSpeechRecognizer = null
282
+ recognizer.destroy()
283
+ }
284
+ }
285
+
286
+ // MARK: - PCM path
287
+
288
+ @RequiresApi(Build.VERSION_CODES.O)
289
+ private suspend fun startPCM(
290
+ silenceThresholdMs: Long,
291
+ maxDurationMs: Long,
292
+ onChunk: ((chunk: VoiceInputChunk) -> Unit)?,
293
+ ): VoiceInputResult {
294
+ val pcmBytes = recordPCM(silenceThresholdMs, maxDurationMs, onChunk)
295
+ val directBuffer =
296
+ ByteBuffer.allocateDirect(pcmBytes.size).put(pcmBytes).rewind() as ByteBuffer
297
+ return VoiceInputResult(transcription = null, audio = ArrayBuffer.wrap(directBuffer))
298
+ }
299
+
300
+ @SuppressLint("MissingPermission")
301
+ @RequiresApi(Build.VERSION_CODES.O)
302
+ private suspend fun recordPCM(
303
+ silenceThresholdMs: Long,
304
+ maxDurationMs: Long,
305
+ onChunk: ((chunk: VoiceInputChunk) -> Unit)?,
55
306
  ): ByteArray = suspendCancellableCoroutine { cont ->
56
- val appContext = NitroModules.applicationContext
57
- if (appContext == null || ContextCompat.checkSelfPermission(
58
- appContext,
59
- android.Manifest.permission.RECORD_AUDIO,
60
- ) != PackageManager.PERMISSION_GRANTED
61
- ) {
307
+ if (!hasVoiceInputPermission()) {
62
308
  cont.resumeWithException(SecurityException("RECORD_AUDIO permission not granted"))
63
309
  return@suspendCancellableCoroutine
64
310
  }
65
311
 
66
- continuation = cont
312
+ val appContext = NitroModules.applicationContext ?: run {
313
+ cont.resumeWithException(SecurityException("Missing application context"))
314
+ return@suspendCancellableCoroutine
315
+ }
316
+
317
+ pcmContinuation = cont
67
318
 
68
319
  val audioManager = appContext.getSystemService(AudioManager::class.java)
69
320
 
@@ -80,7 +331,7 @@ class VoiceInputManager(
80
331
  }.build()
81
332
 
82
333
  if (audioManager.requestAudioFocus(focusRequest) != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
83
- continuation = null
334
+ pcmContinuation = null
84
335
  cont.resumeWithException(IllegalStateException("Audio focus request denied"))
85
336
  return@suspendCancellableCoroutine
86
337
  }
@@ -136,6 +387,13 @@ class VoiceInputManager(
136
387
  if (read > 0) {
137
388
  outputStream.write(buffer, 0, read)
138
389
 
390
+ onChunk?.let { cb ->
391
+ val chunk = ByteArray(read) { buffer[it] }
392
+ val direct =
393
+ ByteBuffer.allocateDirect(read).put(chunk).rewind() as ByteBuffer
394
+ cb(VoiceInputChunk(partial = null, audio = ArrayBuffer.wrap(direct)))
395
+ }
396
+
139
397
  val now = System.currentTimeMillis()
140
398
  val elapsedMs = now - recordingStart
141
399
 
@@ -151,7 +409,9 @@ class VoiceInputManager(
151
409
  val sample =
152
410
  (buffer[i].toInt() and 0xFF) or (buffer[i + 1].toInt() shl 8)
153
411
  val absSample = abs(sample.toShort().toInt())
154
- if (absSample > peak) peak = absSample
412
+ if (absSample > peak) {
413
+ peak = absSample
414
+ }
155
415
  i += 2
156
416
  }
157
417
 
@@ -170,14 +430,21 @@ class VoiceInputManager(
170
430
  }
171
431
  } finally {
172
432
  releaseResources()
173
- val capturedContinuation = continuation
174
- continuation = null
175
- capturedContinuation?.resume(outputStream.toByteArray())
433
+ val captured = pcmContinuation
434
+ pcmContinuation = null
435
+ captured?.resume(outputStream.toByteArray())
176
436
  }
177
437
  }
178
438
  }
179
439
 
180
440
  fun stop() {
441
+ // STT path: stopListening() triggers onResults/onError which resolves the continuation
442
+ activeSpeechRecognizer?.let { recognizer ->
443
+ UiThreadUtil.runOnUiThread {
444
+ recognizer.stopListening()
445
+ }
446
+ }
447
+ // PCM path and car-audio STT pump
181
448
  isRecording = false
182
449
  carAudioRecord?.stopRecording()
183
450
  audioRecord?.stop()
@@ -210,5 +477,12 @@ class VoiceInputManager(
210
477
  private const val WARMUP_MS = 500L
211
478
  private const val SAMPLE_RATE = 16_000
212
479
  private const val PHONE_BUFFER_SIZE = 3_200 // ~100ms at 16kHz/16-bit/mono
480
+
481
+ fun hasVoiceInputPermission(): Boolean {
482
+ val context = NitroModules.applicationContext ?: return false
483
+ return ContextCompat.checkSelfPermission(
484
+ context, Manifest.permission.RECORD_AUDIO
485
+ ) == PackageManager.PERMISSION_GRANTED
486
+ }
213
487
  }
214
488
  }
@@ -1,19 +1,12 @@
1
1
  package com.margelo.nitro.swe.iternio.reactnativeautoplay.utils
2
2
 
3
- import com.facebook.react.bridge.UiThreadUtil
4
- import kotlinx.coroutines.suspendCancellableCoroutine
5
- import kotlin.coroutines.resume
3
+ import kotlinx.coroutines.Dispatchers
4
+ import kotlinx.coroutines.withContext
6
5
 
7
6
  object ThreadUtil {
8
- suspend fun <T> postOnUiAndAwait(block: () -> T): Result<T> =
9
- suspendCancellableCoroutine { cont ->
10
- UiThreadUtil.runOnUiThread {
11
- try {
12
- val result = block()
13
- cont.resume(Result.success(result))
14
- } catch (e: Exception) {
15
- cont.resume(Result.failure(e))
16
- }
17
- }
7
+ suspend fun <T> postOnUiAndAwait(block: suspend () -> T): Result<T> = runCatching {
8
+ withContext(Dispatchers.Main) {
9
+ block()
18
10
  }
11
+ }
19
12
  }