+[](https://npmjs.com/package/@react-native-voice/voice)
-
-
+🎤 React Native Voice Recognition library for iOS and Android (Online and Offline Support)
+
+## Features
+
+- ✅ **New Architecture Support** - Works with Fabric and TurboModules
+- ✅ **Bridgeless Mode** - Full support for React Native's Bridgeless mode
+- ✅ **React Native 0.76+** - Tested and working with the latest RN versions
+- ✅ **Cross-platform** - Works on both iOS and Android
+- ✅ **Online and Offline** - Supports both online and offline speech recognition
+
+## Installation
```sh
yarn add @react-native-voice/voice
# or
-npm i @react-native-voice/voice --save
+npm install @react-native-voice/voice --save
```
-Link the iOS package
+### iOS Setup
```sh
-npx pod-install
+cd ios && pod install
```
-## Table of contents
+### Android Setup
-- [Linking](#linking)
- - [Manually Link Android](#manually-link-android)
- - [Manually Link iOS](#manually-link-ios)
-- [Prebuild Plugin](#prebuild-plugin)
-- [Usage](#usage)
- - [Example](#example)
-- [API](#api)
-- [Events](#events)
-- [Permissions](#permissions)
- - [Android](#android)
- - [iOS](#ios)
-- [Contributors](#contributors)
+No additional setup required - autolinking handles everything.
-
Linking
+## Usage
-
Manually or automatically link the NativeModule
+```javascript
+import Voice from '@react-native-voice/voice';
-```sh
-react-native link @react-native-voice/voice
-```
+// Set up event handlers
+Voice.onSpeechStart = () => console.log('Speech started');
+Voice.onSpeechEnd = () => console.log('Speech ended');
+Voice.onSpeechResults = (e) => console.log('Results:', e.value);
+Voice.onSpeechPartialResults = (e) => console.log('Partial:', e.value);
+Voice.onSpeechError = (e) => console.log('Error:', e.error);
-### Manually Link Android
+// Start listening
+await Voice.start('en-US');
-- In `android/setting.gradle`
+// Stop listening
+await Voice.stop();
-```gradle
-...
-include ':@react-native-voice_voice', ':app'
-project(':@react-native-voice_voice').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-voice/voice/android')
+// Clean up
+await Voice.destroy();
```
-- In `android/app/build.gradle`
+### Full Example
-```gradle
-...
-dependencies {
- ...
- compile project(':@react-native-voice_voice')
-}
-```
+```javascript
+import React, { useEffect, useState, useCallback } from 'react';
+import { View, Text, Button } from 'react-native';
+import Voice from '@react-native-voice/voice';
-- In `MainApplication.java`
-
-```java
-
-import android.app.Application;
-import com.facebook.react.ReactApplication;
-import com.facebook.react.ReactPackage;
-...
-import com.wenkesj.voice.VoicePackage; // <------ Add this!
-...
-
-public class MainActivity extends Activity implements ReactApplication {
-...
- @Override
- protected List getPackages() {
- return Arrays.asList(
- new MainReactPackage(),
- new VoicePackage() // <------ Add this!
- );
+function SpeechToText() {
+ const [results, setResults] = useState([]);
+ const [isListening, setIsListening] = useState(false);
+
+ useEffect(() => {
+ Voice.onSpeechStart = () => setIsListening(true);
+ Voice.onSpeechEnd = () => setIsListening(false);
+ Voice.onSpeechResults = (e) => setResults(e.value ?? []);
+ Voice.onSpeechError = (e) => console.error(e.error);
+
+ return () => {
+ Voice.destroy().then(Voice.removeAllListeners);
+ };
+ }, []);
+
+ const startListening = async () => {
+ try {
+ await Voice.start('en-US');
+ } catch (e) {
+ console.error(e);
}
+ };
+
+ const stopListening = async () => {
+ try {
+ await Voice.stop();
+ } catch (e) {
+ console.error(e);
+ }
+ };
+
+ return (
+
+ {isListening ? '🎤 Listening...' : 'Press Start'}
+ {results.join(' ')}
+
+
+
+ );
}
```
-### Manually Link iOS
-
-- Drag the Voice.xcodeproj from the @react-native-voice/voice/ios folder to the Libraries group on Xcode in your poject. [Manual linking](https://reactnative.dev/docs/linking-libraries-ios.html)
+## API
+
+| Method | Description | Platform |
+|--------|-------------|----------|
+| `Voice.isAvailable()` | Check if speech recognition is available | Android, iOS |
+| `Voice.start(locale)` | Start listening for speech | Android, iOS |
+| `Voice.stop()` | Stop listening | Android, iOS |
+| `Voice.cancel()` | Cancel speech recognition | Android, iOS |
+| `Voice.destroy()` | Destroy the recognizer instance | Android, iOS |
+| `Voice.removeAllListeners()` | Remove all event listeners | Android, iOS |
+| `Voice.isRecognizing()` | Check if currently recognizing | Android, iOS |
+| `Voice.getSpeechRecognitionServices()` | Get available speech engines | Android only |
+
+## Events
+
+| Event | Description | Data |
+|-------|-------------|------|
+| `onSpeechStart` | Speech recognition started | `{ error: false }` |
+| `onSpeechEnd` | Speech recognition ended | `{ error: false }` |
+| `onSpeechResults` | Final results received | `{ value: ['recognized text'] }` |
+| `onSpeechPartialResults` | Partial results (live) | `{ value: ['partial text'] }` |
+| `onSpeechError` | Error occurred | `{ error: { code, message } }` |
+| `onSpeechVolumeChanged` | Volume/pitch changed | `{ value: number }` |
+| `onSpeechRecognized` | Speech was recognized | `{ isFinal: boolean }` |
+
+## Permissions
-- Click on your main project file (the one that represents the .xcodeproj) select Build Phases and drag the static library, lib.Voice.a, from the Libraries/Voice.xcodeproj/Products folder to Link Binary With Libraries
-
-
Prebuild Plugin
-
-> This package cannot be used in the "Expo Go" app because [it requires custom native code](https://docs.expo.io/workflow/customizing/).
+### Android
-After installing this npm package, add the [config plugin](https://docs.expo.io/guides/config-plugins/) to the [`plugins`](https://docs.expo.io/versions/latest/config/app/#plugins) array of your `app.json` or `app.config.js`:
+Add to `AndroidManifest.xml`:
-```json
-{
- "expo": {
- "plugins": ["@react-native-voice/voice"]
- }
-}
+```xml
+
```
-Next, rebuild your app as described in the ["Adding custom native code"](https://docs.expo.io/workflow/customizing/) guide.
+The library automatically requests permission when starting recognition.
-### Props
+### iOS
-The plugin provides props for extra customization. Every time you change the props or plugins, you'll need to rebuild (and `prebuild`) the native app. If no extra properties are added, defaults will be used.
+Add to `Info.plist`:
-- `speechRecognition` (_string | false_): Sets the message for the `NSSpeechRecognitionUsageDescription` key in the `Info.plist` message. When undefined, a default permission message will be used. When `false`, the permission will be skipped.
-- `microphone` (_string | false_): Sets the message for the `NSMicrophoneUsageDescription` key in the `Info.plist`. When undefined, a default permission message will be used. When `false`, the `android.permission.RECORD_AUDIO` will not be added to the `AndroidManifest.xml` and the iOS permission will be skipped.
+```xml
+NSMicrophoneUsageDescription
+This app needs microphone access for speech recognition
+NSSpeechRecognitionUsageDescription
+This app needs speech recognition access
+```
-### Example
+## Platform Notes
-```json
-{
- "plugins": [
- [
- "@react-native-voice/voice",
- {
- "microphonePermission": "CUSTOM: Allow $(PRODUCT_NAME) to access the microphone",
- "speechRecognitionPermission": "CUSTOM: Allow $(PRODUCT_NAME) to securely recognize user speech"
- }
- ]
- ]
-}
-```
+### Android
+- Auto-stops after user stops speaking
+- Requires Google Search app for speech recognition on most devices
+- Check available services with `Voice.getSpeechRecognitionServices()`
-
Usage
+### iOS
+- Does NOT auto-stop - call `Voice.stop()` when done
+- Speech recognition only works on **physical devices** (not simulators)
+- Requires iOS 10+
-
+## Expo Support
-### Example
+This library requires custom native code and cannot be used with Expo Go. Use a [development build](https://docs.expo.dev/develop/development-builds/introduction/) or eject.
-```javascript
-import Voice from '@react-native-voice/voice';
-import React, {Component} from 'react';
+Add to your `app.json`:
-class VoiceTest extends Component {
- constructor(props) {
- Voice.onSpeechStart = this.onSpeechStartHandler.bind(this);
- Voice.onSpeechEnd = this.onSpeechEndHandler.bind(this);
- Voice.onSpeechResults = this.onSpeechResultsHandler.bind(this);
- }
- onStartButtonPress(e){
- Voice.start('en-US');
+```json
+{
+ "expo": {
+ "plugins": ["@react-native-voice/voice"]
}
- ...
}
```
-
API
-
-
Static access to the Voice API.
-
-**All methods _now_ return a `new Promise` for `async/await` compatibility.**
+## Troubleshooting
-| Method Name | Description | Platform |
-| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
-| Voice.isAvailable() | Checks whether a speech recognition service is available on the system. | Android, iOS |
-| Voice.start(locale) | Starts listening for speech for a specific locale. Returns null if no error occurs. | Android, iOS |
-| Voice.stop() | Stops listening for speech. Returns null if no error occurs. | Android, iOS |
-| Voice.cancel() | Cancels the speech recognition. Returns null if no error occurs. | Android, iOS |
-| Voice.destroy() | Destroys the current SpeechRecognizer instance. Returns null if no error occurs. | Android, iOS |
-| Voice.removeAllListeners() | Cleans/nullifies overridden `Voice` static methods. | Android, iOS |
-| Voice.isRecognizing() | Return if the SpeechRecognizer is recognizing. | Android, iOS |
-| Voice.getSpeechRecognitionServices() | Returns a list of the speech recognition engines available on the device. (Example: `['com.google.android.googlequicksearchbox']` if Google is the only one available.) | Android |
+### Module Resolution Error: `react-is` cannot be resolved
-
Events
+If you see an error about `react-is` module resolution:
-
Callbacks that are invoked when a native event emitted.
-
-| Event Name | Description | Event | Platform |
-| ----------------------------------- | ------------------------------------------------------ | ----------------------------------------------- | ------------ |
-| Voice.onSpeechStart(event) | Invoked when `.start()` is called without error. | `{ error: false }` | Android, iOS |
-| Voice.onSpeechRecognized(event) | Invoked when speech is recognized. | `{ error: false }` | Android, iOS |
-| Voice.onSpeechEnd(event) | Invoked when SpeechRecognizer stops recognition. | `{ error: false }` | Android, iOS |
-| Voice.onSpeechError(event) | Invoked when an error occurs. | `{ error: Description of error as string }` | Android, iOS |
-| Voice.onSpeechResults(event) | Invoked when SpeechRecognizer is finished recognizing. | `{ value: [..., 'Speech recognized'] }` | Android, iOS |
-| Voice.onSpeechPartialResults(event) | Invoked when any results are computed. | `{ value: [..., 'Partial speech recognized'] }` | Android, iOS |
-| Voice.onSpeechVolumeChanged(event) | Invoked when pitch that is recognized changed. | `{ value: pitch in dB }` | Android |
-
-
Permissions
-
-
Arguably the most important part.
-
-### Android
-
-While the included `VoiceTest` app works without explicit permissions checks and requests, it may be necessary to add a permission request for `RECORD_AUDIO` for some configurations.
-Since Android M (6.0), [user need to grant permission at runtime (and not during app installation)](https://developer.android.com/training/permissions/requesting.html).
-By default, calling the `startSpeech` method will invoke `RECORD AUDIO` permission popup to the user. This can be disabled by passing `REQUEST_PERMISSIONS_AUTO: true` in the options argument.
-
-If you're running an ejected expo/expokit app, you may run into issues with permissions on Android and get the following error `host.exp.exponent.MainActivity cannot be cast to com.facebook.react.ReactActivity startSpeech`. This can be resolved by prompting for permssion using the `expo-permission` package before starting recognition.
-
-```js
-import { Permissions } from "expo";
-async componentDidMount() {
- const { status, expires, permissions } = await Permissions.askAsync(
- Permissions.AUDIO_RECORDING
- );
- if (status !== "granted") {
- //Permissions not granted. Don't show the start recording button because it will cause problems if it's pressed.
- this.setState({showRecordButton: false});
- } else {
- this.setState({showRecordButton: true});
- }
-}
+```bash
+# Install react-is explicitly
+yarn add react-is
+# or
+npm install react-is
```
-**Notes on Android**
-
-Even after all the permissions are correct in Android, there is one last thing to make sure this libray is working fine on Android. Please make sure the device has Google Speech Recognizing Engine such as `com.google.android.googlequicksearchbox` by calling `Voice.getSpeechRecognitionServices()`. Since Android phones can be configured with so many options, even if a device has googlequicksearchbox engine, it could be configured to use other services. You can check which service is used for Voice Assistive App in following steps for most Android phones:
+Then clear Metro cache:
+```bash
+npx react-native start --reset-cache
+```
-`Settings > App Management > Default App > Assistive App and Voice Input > Assistive App`
+See [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) for more details.
-Above flow can vary depending on the Android models and manufactures. For Huawei phones, there might be a chance that the device cannot install Google Services.
+### Android: No speech recognition services found
+Install the [Google Search app](https://play.google.com/store/apps/details?id=com.google.android.googlequicksearchbox) from Play Store.
-**How can I get `com.google.android.googlequicksearchbox` in the device?**
+### iOS: Speech recognition not working on simulator
+Use a physical iOS device - simulators don't support speech recognition.
-Please ask users to install [Google Search App](https://play.google.com/store/apps/details?id=com.google.android.googlequicksearchbox&hl=en).
+### Events not firing
+Make sure you set up event handlers **before** calling `Voice.start()`.
-### iOS
+## Contributors
-Need to include permissions for `NSMicrophoneUsageDescription` and `NSSpeechRecognitionUsageDescription` inside Info.plist for iOS. See the included `VoiceTest` for how to handle these cases.
+* @asafron
+* @BrendanFDMoore
+* @brudny
+* @chitezh
+* @ifsnow
+* @jamsch
+* @misino
+* @Noitidart
+* @ohtangza & @hayanmind
+* @rudiedev6
+* @tdonia
+* @wenkesj
-```xml
-
- ...
- NSMicrophoneUsageDescription
- Description of why you require the use of the microphone
- NSSpeechRecognitionUsageDescription
- Description of why you require the use of the speech recognition
- ...
-
-```
+## License
-Please see the documentation provided by ReactNative for this: [PermissionsAndroid](https://reactnative.dev/docs/permissionsandroid.html)
-
-[npm]: https://img.shields.io/npm/v/@react-native-voice/voice.svg?style=flat-square
-[npm-url]: https://npmjs.com/package/@react-native-voice/voice
-[circle-ci-badge]: https://img.shields.io/circleci/project/github/react-native-voice/voice/master.svg?style=flat-square
-
-
Contributors
-
-- @asafron
-- @BrendanFDMoore
-- @brudny
-- @chitezh
-- @ifsnow
-- @jamsch
-- @misino
-- @Noitidart
-- @ohtangza & @hayanmind
-- @rudiedev6
-- @tdonia
-- @wenkesj
+MIT
diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md
new file mode 100644
index 00000000..b35d3e95
--- /dev/null
+++ b/TROUBLESHOOTING.md
@@ -0,0 +1,85 @@
+# Troubleshooting Guide
+
+## Module Resolution Errors
+
+### Error: `react-is` module cannot be resolved
+
+If you encounter an error like:
+```
+trying to resolve module `react-is` from files .../node_modules/hoist-non-react-statics/dist/hoist-non-react-statics.cjs.js
+the package json successfully found however this package itself specifies a main module field that could not be resolved
+```
+
+#### Solution 1: Install react-is (Recommended)
+
+Install `react-is` in your project:
+
+```bash
+# Using npm
+npm install react-is
+
+# Using yarn
+yarn add react-is
+
+# Using pnpm
+pnpm add react-is
+```
+
+#### Solution 2: Clear Metro Cache
+
+Clear Metro bundler cache and restart:
+
+```bash
+# Clear Metro cache
+npx react-native start --reset-cache
+
+# Or if using Expo
+npx expo start --clear
+```
+
+#### Solution 3: Check Metro Configuration
+
+Ensure your `metro.config.js` properly resolves node_modules:
+
+```javascript
+const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
+
+const config = {
+ resolver: {
+ // Ensure node_modules are resolved correctly
+ nodeModulesPaths: ['node_modules'],
+ },
+};
+
+module.exports = mergeConfig(getDefaultConfig(__dirname), config);
+```
+
+#### Solution 4: Reinstall Dependencies
+
+Sometimes a clean reinstall fixes module resolution issues:
+
+```bash
+# Remove node_modules and lock files
+rm -rf node_modules yarn.lock package-lock.json
+
+# Reinstall
+yarn install
+# or
+npm install
+```
+
+#### Solution 5: Check React Version Compatibility
+
+Ensure you're using a compatible React version. This library requires:
+- `react >= 18.0.0`
+- `react-native >= 0.71.0`
+- `react-is >= 16.12.0 || ^17.0.0 || ^18.0.0`
+
+Check your `package.json` to verify versions.
+
+### Additional Notes
+
+- `react-is` is typically provided by React itself, but some bundlers require it to be explicitly installed
+- This is a known issue with Metro bundler and transitive dependencies
+- The library declares `react-is` as an optional peer dependency to help with resolution
+
diff --git a/android/build.gradle b/android/build.gradle
index 529b2ced..941b5778 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -33,12 +33,6 @@ def supportsNamespace() {
android {
if (supportsNamespace()) {
namespace "com.wenkesj.voice"
-
- sourceSets {
- main {
- manifest.srcFile "src/main/AndroidManifestNew.xml"
- }
- }
}
compileSdkVersion getExtOrIntegerDefault("compileSdkVersion")
@@ -110,6 +104,7 @@ def kotlin_version = getExtOrDefault("kotlinVersion")
dependencies {
implementation "com.facebook.react:react-native:+"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
+ implementation "androidx.core:core-ktx:1.9.0"
}
if (isNewArchitectureEnabled()) {
diff --git a/android/gradle.properties b/android/gradle.properties
index b3510205..ff2af63e 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -1,5 +1,5 @@
-Voice_kotlinVersion=1.7.0
-Voice_minSdkVersion=21
-Voice_targetSdkVersion=31
-Voice_compileSdkVersion=31
-Voice_ndkversion=21.4.7075529
+Voice_kotlinVersion=1.9.24
+Voice_minSdkVersion=24
+Voice_targetSdkVersion=34
+Voice_compileSdkVersion=35
+Voice_ndkversion=26.1.10909125
diff --git a/android/src/main/VoiceSpec.kt b/android/src/main/VoiceSpec.kt
deleted file mode 100644
index 3f0c1ce6..00000000
--- a/android/src/main/VoiceSpec.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-package com.wenkesj.voice
-
-import com.facebook.react.bridge.Callback
-import com.facebook.react.bridge.Promise
-import com.facebook.react.bridge.ReactApplicationContext
-import com.facebook.react.bridge.ReadableMap
-
-abstract class VoiceSpec internal constructor(context: ReactApplicationContext) :
- NativeVoiceAndroidSpec(context) {
- private val voice = Voice(context)
-
- override fun destroySpeech(callback: Callback) {
- voice.destroySpeech(callback)
- }
-
- override fun startSpeech(locale: String, opts: ReadableMap, callback: Callback) {
- voice.startSpeech(locale,opts,callback)
- }
-
- override fun stopSpeech(callback: Callback) {
- voice.stopSpeech(callback)
- }
-
- override fun cancelSpeech(callback: Callback) {
- voice.cancelSpeech(callback)
- }
-
- override fun isSpeechAvailable(callback: Callback) {
- voice.isSpeechAvailable(callback)
- }
-
- override fun getSpeechRecognitionServices(promise: Promise) {
- voice.getSpeechRecognitionServices(promise)
- }
-
- override fun isRecognizing(callback: Callback) {
- voice.isRecognizing(callback)
- }
-
- override fun addListener(eventType: String) {
-
- }
-
- override fun removeListeners(count: Double) {
-
- }
-
- override fun getName(): String {
- return NAME
- }
-
- companion object {
- const val NAME = "Voice"
- }
-}
diff --git a/android/src/main/java/com/wenkesj/voice/Voice.kt b/android/src/main/java/com/wenkesj/voice/Voice.kt
index 58ba07b4..ee75148a 100644
--- a/android/src/main/java/com/wenkesj/voice/Voice.kt
+++ b/android/src/main/java/com/wenkesj/voice/Voice.kt
@@ -13,6 +13,7 @@ import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.util.Log
import androidx.annotation.Nullable
+import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Callback
import com.facebook.react.bridge.Promise
@@ -24,25 +25,67 @@ import com.facebook.react.modules.core.PermissionAwareActivity
import java.util.Locale
-class Voice (context:ReactApplicationContext):RecognitionListener {
+/**
+ * Voice - Android Speech Recognition implementation
+ * Handles speech-to-text using Android's SpeechRecognizer API
+ * Events are emitted via RCTDeviceEventEmitter to JavaScript
+ */
+class Voice(context: ReactApplicationContext) : RecognitionListener {
val reactContext: ReactApplicationContext = context
private var speech: SpeechRecognizer? = null
private var isRecognizing = false
private var locale: String? = null
private fun getLocale(locale: String?): String {
- if (locale != null && locale != "") {
- return locale
+ if (locale != null && locale.isNotEmpty()) {
+ // Normalize locale format (e.g., "id" -> "id-ID", "ur" -> "ur-PK")
+ val normalizedLocale = normalizeLocale(locale)
+ Log.d("ASR", "Using provided locale: $locale -> $normalizedLocale")
+ return normalizedLocale
}
- return Locale.getDefault().toString()
+ val defaultLocale = Locale.getDefault().toString()
+ Log.d("ASR", "Using default locale: $defaultLocale")
+ return defaultLocale
+ }
+
+ private fun normalizeLocale(locale: String): String {
+ // Handle common locale formats
+ val parts = locale.split("-", "_")
+ val language = parts[0].lowercase()
+
+ // Map common language codes to full locale codes
+ val localeMap = mapOf(
+ "id" to "id-ID", // Indonesian
+ "in" to "id-ID", // Indonesian (alternative)
+ "ur" to "ur-PK", // Urdu (Pakistan)
+ "hi" to "hi-IN", // Hindi
+ "en" to "en-US", // English (default to US)
+ "ms" to "ms-MY", // Malay (Malaysia)
+ "ar" to "ar-SA", // Arabic
+ "bn" to "bn-BD", // Bengali
+ )
+
+ // If it's already in format "lang-COUNTRY", return as is
+ if (parts.size >= 2) {
+ return locale.replace("_", "-")
+ }
+
+ // Otherwise, try to map it
+ return localeMap[language] ?: locale
}
private fun startListening(opts: ReadableMap) {
+ Log.d("ASR", "startListening called with locale: ${this.locale}")
if (speech != null) {
speech?.destroy()
speech = null
}
+ // Check if speech recognition is available
+ if (!SpeechRecognizer.isRecognitionAvailable(this.reactContext)) {
+ throw Exception("Speech recognition is not available on this device")
+ }
+
speech = if (opts.hasKey("RECOGNIZER_ENGINE")) {
when (opts.getString("RECOGNIZER_ENGINE")) {
"GOOGLE" -> {
@@ -58,39 +101,90 @@ class Voice (context:ReactApplicationContext):RecognitionListener {
SpeechRecognizer.createSpeechRecognizer(this.reactContext)
}
+ if (speech == null) {
+ throw Exception("Failed to create SpeechRecognizer")
+ }
+
speech?.setRecognitionListener(this)
+ Log.d("ASR", "startListening() - RecognitionListener set")
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
+ Log.d("ASR", "startListening() - Intent created")
+
+ // Set default language model if not specified
+ var languageModelSet = false
// Load the intent with options from JS
val iterator = opts.keySetIterator()
while (iterator.hasNextKey()) {
val key = iterator.nextKey()
when (key) {
- "EXTRA_LANGUAGE_MODEL" -> when (opts.getString(key)) {
- "LANGUAGE_MODEL_FREE_FORM" -> intent.putExtra(
- RecognizerIntent.EXTRA_LANGUAGE_MODEL,
- RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
- )
-
- "LANGUAGE_MODEL_WEB_SEARCH" -> intent.putExtra(
- RecognizerIntent.EXTRA_LANGUAGE_MODEL,
- RecognizerIntent.LANGUAGE_MODEL_WEB_SEARCH
- )
-
- else -> intent.putExtra(
- RecognizerIntent.EXTRA_LANGUAGE_MODEL,
- RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
- )
+ "EXTRA_LANGUAGE_MODEL" -> {
+ languageModelSet = true
+ when (opts.getString(key)) {
+ "LANGUAGE_MODEL_FREE_FORM" -> intent.putExtra(
+ RecognizerIntent.EXTRA_LANGUAGE_MODEL,
+ RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
+ )
+
+ "LANGUAGE_MODEL_WEB_SEARCH" -> intent.putExtra(
+ RecognizerIntent.EXTRA_LANGUAGE_MODEL,
+ RecognizerIntent.LANGUAGE_MODEL_WEB_SEARCH
+ )
+
+ else -> intent.putExtra(
+ RecognizerIntent.EXTRA_LANGUAGE_MODEL,
+ RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
+ )
+ }
}
"EXTRA_MAX_RESULTS" -> {
- val extras = opts.getDouble(key)
- intent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, extras.toInt())
+ // Support both string (legacy) and number (new) for backward compatibility
+ if (opts.hasKey(key) && !opts.isNull(key)) {
+ val value = when (opts.getType(key)) {
+ com.facebook.react.bridge.ReadableType.String -> {
+ // Legacy string format - convert to number
+ try {
+ opts.getString(key)?.toIntOrNull()
+ } catch (e: Exception) {
+ null
+ }
+ }
+ else -> {
+ // New number format
+ try {
+ opts.getDouble(key).toInt()
+ } catch (e: Exception) {
+ null
+ }
+ }
+ }
+ if (value != null) {
+ intent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, value)
+ }
+ }
}
"EXTRA_PARTIAL_RESULTS" -> {
- intent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, opts.getBoolean(key))
+ // Support both string (legacy) and boolean (new) for backward compatibility
+ if (opts.hasKey(key) && !opts.isNull(key)) {
+ val value = when (opts.getType(key)) {
+ com.facebook.react.bridge.ReadableType.String -> {
+ // Legacy string format - convert to boolean
+ opts.getString(key)?.lowercase() == "true"
+ }
+ else -> {
+ // New boolean format
+ try {
+ opts.getBoolean(key)
+ } catch (e: Exception) {
+ false
+ }
+ }
+ }
+ intent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, value)
+ }
}
"EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS" -> {
@@ -115,21 +209,48 @@ class Voice (context:ReactApplicationContext):RecognitionListener {
}
}
}
+
+ // Set default language model if not specified
+ if (!languageModelSet) {
+ intent.putExtra(
+ RecognizerIntent.EXTRA_LANGUAGE_MODEL,
+ RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
+ )
+ }
- intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, getLocale(this.locale))
+ // Set locale - ensure it's in the correct format
+ val localeString = getLocale(this.locale)
+ Log.d("ASR", "Setting locale to: $localeString")
+ intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, localeString)
+
+ // Add language preference hint for better recognition
+ intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE, localeString)
+
+ // Enable partial results by default for better UX
+ if (!opts.hasKey("EXTRA_PARTIAL_RESULTS")) {
+ intent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
+ Log.d("ASR", "startListening() - Partial results enabled by default")
+ }
+
+ Log.d("ASR", "startListening() - Calling speech?.startListening()")
speech?.startListening(intent)
+ Log.d("ASR", "startListening() - startListening() called")
}
private fun startSpeechWithPermissions(locale: String, opts: ReadableMap, callback: Callback) {
this.locale = locale
+ Log.d("ASR", "startSpeechWithPermissions() - Locale: $locale")
val mainHandler = Handler(reactContext.mainLooper)
mainHandler.post {
try {
+ Log.d("ASR", "startSpeechWithPermissions() - Starting recognition")
startListening(opts)
isRecognizing = true
+ Log.d("ASR", "startSpeechWithPermissions() - Recognition started successfully")
callback.invoke(false)
} catch (e: Exception) {
+ Log.e("ASR", "startSpeechWithPermissions() - Error: ${e.message}", e)
callback.invoke(e.message)
}
}
@@ -147,12 +268,28 @@ class Voice (context:ReactApplicationContext):RecognitionListener {
val granted = grantResults[i] == PackageManager.PERMISSION_GRANTED
permissionsGranted = permissionsGranted && granted
}
- startSpeechWithPermissions(locale!!, opts, callback!!)
+ if (permissionsGranted) {
+ startSpeechWithPermissions(locale!!, opts, callback!!)
+ } else {
+ val errorMessage = "Permission denied: RECORD_AUDIO permission is required"
+ callback?.invoke(errorMessage)
+ }
permissionsGranted
}
+ } else {
+ val errorMessage = "Current activity is null, cannot request permissions"
+ callback?.invoke(errorMessage)
}
return
}
+
+ // Check if permission is granted before starting
+ if (!isPermissionGranted()) {
+ val errorMessage = "RECORD_AUDIO permission is required but not granted"
+ callback?.invoke(errorMessage)
+ return
+ }
+
startSpeechWithPermissions(locale!!, opts, callback!!)
}
@@ -217,19 +354,42 @@ class Voice (context:ReactApplicationContext):RecognitionListener {
}
fun getSpeechRecognitionServices(promise: Promise) {
- val services = reactContext.packageManager
- .queryIntentServices(Intent(RecognitionService.SERVICE_INTERFACE), 0)
- val serviceNames = Arguments.createArray()
- for (service in services) {
- serviceNames.pushString(service.serviceInfo.packageName)
- }
+ val mainHandler = Handler(reactContext.mainLooper)
+ mainHandler.post {
+ try {
+ Log.d("ASR", "getSpeechRecognitionServices() - Starting to query services")
+ // RecognitionService.SERVICE_INTERFACE is the action string for recognition services
+ val intent = Intent(RecognitionService.SERVICE_INTERFACE)
+ // Use MATCH_DEFAULT_ONLY to get only services that can handle the intent by default
+ val services = reactContext.packageManager
+ .queryIntentServices(intent, PackageManager.MATCH_DEFAULT_ONLY)
+
+ Log.d("ASR", "getSpeechRecognitionServices() - Found ${services.size} services")
+ val serviceNames = Arguments.createArray()
+
+ if (services.isEmpty()) {
+ Log.w("ASR", "getSpeechRecognitionServices() - No recognition services found on device")
+ } else {
+ for (service in services) {
+ val packageName = service.serviceInfo.packageName
+ Log.d("ASR", "getSpeechRecognitionServices() - Service: $packageName")
+ serviceNames.pushString(packageName)
+ }
+ }
- promise.resolve(serviceNames)
+ Log.d("ASR", "getSpeechRecognitionServices() - Resolving promise with ${serviceNames.size()} services")
+ promise.resolve(serviceNames)
+ } catch (e: Exception) {
+ Log.e("ASR", "getSpeechRecognitionServices() - Error: ${e.message}", e)
+ e.printStackTrace()
+ promise.reject("GET_SERVICES_ERROR", e.message ?: "Failed to get speech recognition services", e)
+ }
+ }
}
private fun isPermissionGranted(): Boolean {
val permission = Manifest.permission.RECORD_AUDIO
- val res: Int = reactContext.checkCallingOrSelfPermission(permission)
+ val res: Int = ContextCompat.checkSelfPermission(reactContext, permission)
return res == PackageManager.PERMISSION_GRANTED
}
@@ -237,10 +397,32 @@ class Voice (context:ReactApplicationContext):RecognitionListener {
callback.invoke(isRecognizing)
}
+ /**
+ * Send an event to JavaScript via RCTDeviceEventEmitter
+ * Works with both Bridge mode and Bridgeless mode (new architecture)
+ */
private fun sendEvent(eventName: String, params: WritableMap) {
- reactContext
- .getJSModule(RCTDeviceEventEmitter::class.java)
- .emit(eventName, params)
+ // Use main thread handler - required for RCTDeviceEventEmitter
+ val mainHandler = Handler(reactContext.mainLooper)
+ mainHandler.post {
+ try {
+ val deviceEventEmitter = reactContext.getJSModule(RCTDeviceEventEmitter::class.java)
+ if (deviceEventEmitter == null) {
+ Log.e("ASR", "sendEvent($eventName) - DeviceEventEmitter is null!")
+ return@post
+ }
+
+ // Emit the event
+ deviceEventEmitter.emit(eventName, params)
+
+ // Only log non-volume events to avoid log spam
+ if (eventName != "onSpeechVolumeChanged") {
+ Log.d("ASR", "sendEvent($eventName) - Event emitted to JS")
+ }
+ } catch (e: Exception) {
+ Log.e("ASR", "sendEvent($eventName) - Error: ${e.message}", e)
+ }
+ }
}
private fun getErrorText(errorCode: Int): String {
@@ -282,7 +464,7 @@ class Voice (context:ReactApplicationContext):RecognitionListener {
override fun onBufferReceived(buffer: ByteArray?) {
val event = Arguments.createMap()
- event.putBoolean("error", false)
+ event.putBoolean("isFinal", false)
sendEvent("onSpeechRecognized", event)
Log.d("ASR", "onBufferReceived()")
}
@@ -297,10 +479,11 @@ class Voice (context:ReactApplicationContext):RecognitionListener {
override fun onError(error: Int) {
+ isRecognizing = false
val errorMessage = String.format("%d/%s", error, getErrorText(error))
val errorData = Arguments.createMap()
errorData.putString("message", errorMessage)
- errorData.putString("code", java.lang.String.valueOf(errorMessage))
+ errorData.putString("code", java.lang.String.valueOf(error))
val event = Arguments.createMap()
event.putMap("error", errorData)
sendEvent("onSpeechError", event)
@@ -308,30 +491,44 @@ class Voice (context:ReactApplicationContext):RecognitionListener {
}
override fun onResults(results: Bundle?) {
+ isRecognizing = false
+ Log.d("ASR", "onResults() called")
+
val arr = Arguments.createArray()
-
- val matches = results!!.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
- if (matches != null) {
- for (result in matches) {
- arr.pushString(result)
+
+ if (results != null) {
+ val matches = results.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
+
+ if (matches != null && matches.isNotEmpty()) {
+ for (result in matches) {
+ if (!result.isNullOrEmpty()) {
+ arr.pushString(result)
+ }
+ }
+ Log.d("ASR", "onResults() - ${arr.size()} results: ${matches.firstOrNull()}")
}
}
+
val event = Arguments.createMap()
event.putArray("value", arr)
sendEvent("onSpeechResults", event)
- Log.d("ASR", "onResults()")
}
override fun onPartialResults(partialResults: Bundle?) {
val arr = Arguments.createArray()
-
- val matches = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
- matches?.let {
- for (result in it) {
- arr.pushString(result)
+
+ if (partialResults != null) {
+ val matches = partialResults.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
+
+ if (matches != null && matches.isNotEmpty()) {
+ for (result in matches) {
+ if (!result.isNullOrEmpty()) {
+ arr.pushString(result)
+ }
+ }
}
}
-
+
val event = Arguments.createMap()
event.putArray("value", arr)
sendEvent("onSpeechPartialResults", event)
diff --git a/android/src/main/java/com/wenkesj/voice/VoiceModule.kt b/android/src/main/java/com/wenkesj/voice/VoiceModule.kt
index 65da5650..6d852ab6 100644
--- a/android/src/main/java/com/wenkesj/voice/VoiceModule.kt
+++ b/android/src/main/java/com/wenkesj/voice/VoiceModule.kt
@@ -4,53 +4,64 @@ import com.facebook.react.bridge.Callback
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReadableMap
-import com.facebook.react.bridge.ReactMethod;
+import com.facebook.react.bridge.ReactMethod
+/**
+ * React Native Voice Module
+ * Provides speech recognition functionality for both old and new architectures
+ */
class VoiceModule internal constructor(context: ReactApplicationContext) :
VoiceSpec(context) {
- private val voice = Voice(context)
+
+ // Single Voice instance - created once and reused
+ private val voiceInstance: Voice by lazy { Voice(context) }
+
+ // For new architecture - provides the Voice instance to the base class
+ override fun getVoice(): Voice = voiceInstance
@ReactMethod
override fun destroySpeech(callback: Callback) {
- voice.destroySpeech(callback)
+ voiceInstance.destroySpeech(callback)
}
@ReactMethod
override fun startSpeech(locale: String, opts: ReadableMap, callback: Callback) {
- voice.startSpeech(locale,opts,callback)
+ voiceInstance.startSpeech(locale, opts, callback)
}
@ReactMethod
override fun stopSpeech(callback: Callback) {
- voice.stopSpeech(callback)
+ voiceInstance.stopSpeech(callback)
}
@ReactMethod
override fun cancelSpeech(callback: Callback) {
- voice.cancelSpeech(callback)
+ voiceInstance.cancelSpeech(callback)
}
@ReactMethod
override fun isSpeechAvailable(callback: Callback) {
- voice.isSpeechAvailable(callback)
+ voiceInstance.isSpeechAvailable(callback)
}
@ReactMethod
override fun getSpeechRecognitionServices(promise: Promise) {
- voice.getSpeechRecognitionServices(promise)
+ voiceInstance.getSpeechRecognitionServices(promise)
}
@ReactMethod
override fun isRecognizing(callback: Callback) {
- voice.isRecognizing(callback)
+ voiceInstance.isRecognizing(callback)
}
+ @ReactMethod
override fun addListener(eventType: String) {
-
+ // Required for NativeEventEmitter - no-op since we use DeviceEventEmitter
}
+ @ReactMethod
override fun removeListeners(count: Double) {
-
+ // Required for NativeEventEmitter - no-op since we use DeviceEventEmitter
}
override fun getName(): String {
diff --git a/android/src/newarch/VoiceSpec.kt b/android/src/newarch/VoiceSpec.kt
index 3f0c1ce6..73804e2c 100644
--- a/android/src/newarch/VoiceSpec.kt
+++ b/android/src/newarch/VoiceSpec.kt
@@ -5,47 +5,53 @@ import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReadableMap
+/**
+ * TurboModule spec for Voice (New Architecture)
+ * This is an abstract base class - VoiceModule will provide the actual Voice instance
+ */
abstract class VoiceSpec internal constructor(context: ReactApplicationContext) :
NativeVoiceAndroidSpec(context) {
- private val voice = Voice(context)
+
+ // Abstract method to get Voice instance - VoiceModule will provide this
+ protected abstract fun getVoice(): Voice
override fun destroySpeech(callback: Callback) {
- voice.destroySpeech(callback)
+ getVoice().destroySpeech(callback)
}
override fun startSpeech(locale: String, opts: ReadableMap, callback: Callback) {
- voice.startSpeech(locale,opts,callback)
+ getVoice().startSpeech(locale, opts, callback)
}
override fun stopSpeech(callback: Callback) {
- voice.stopSpeech(callback)
+ getVoice().stopSpeech(callback)
}
override fun cancelSpeech(callback: Callback) {
- voice.cancelSpeech(callback)
+ getVoice().cancelSpeech(callback)
}
override fun isSpeechAvailable(callback: Callback) {
- voice.isSpeechAvailable(callback)
+ getVoice().isSpeechAvailable(callback)
}
override fun getSpeechRecognitionServices(promise: Promise) {
- voice.getSpeechRecognitionServices(promise)
+ getVoice().getSpeechRecognitionServices(promise)
}
override fun isRecognizing(callback: Callback) {
- voice.isRecognizing(callback)
+ getVoice().isRecognizing(callback)
}
override fun addListener(eventType: String) {
-
+ // Required for NativeEventEmitter
}
override fun removeListeners(count: Double) {
-
+ // Required for NativeEventEmitter
}
- override fun getName(): String {
+ override fun getName(): String {
return NAME
}
diff --git a/android/src/oldarch/VoiceSpec.kt b/android/src/oldarch/VoiceSpec.kt
index f8c869e4..0fa941ee 100644
--- a/android/src/oldarch/VoiceSpec.kt
+++ b/android/src/oldarch/VoiceSpec.kt
@@ -6,25 +6,37 @@ import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReadableMap
-
+/**
+ * Native Module spec for Voice (Old Architecture / Bridge)
+ * This is an abstract base class - VoiceModule will provide the actual implementation
+ */
abstract class VoiceSpec internal constructor(context: ReactApplicationContext) :
ReactContextBaseJavaModule(context) {
- abstract fun destroySpeech(callback:Callback)
+ // For compatibility with VoiceModule (shared between old/new arch)
+ protected open fun getVoice(): Voice {
+ throw UnsupportedOperationException("getVoice() must be implemented by subclass")
+ }
+
+ abstract fun destroySpeech(callback: Callback)
- abstract fun startSpeech(locale:String, opts:ReadableMap, callback:Callback)
+ abstract fun startSpeech(locale: String, opts: ReadableMap, callback: Callback)
- abstract fun stopSpeech(callback:Callback)
+ abstract fun stopSpeech(callback: Callback)
- abstract fun cancelSpeech(callback:Callback)
+ abstract fun cancelSpeech(callback: Callback)
- abstract fun isSpeechAvailable(callback:Callback)
+ abstract fun isSpeechAvailable(callback: Callback)
abstract fun getSpeechRecognitionServices(promise: Promise)
- abstract fun isRecognizing(callback:Callback)
+ abstract fun isRecognizing(callback: Callback)
+
+ abstract fun addListener(eventType: String)
- abstract fun addListener(eventType:String)
+ abstract fun removeListeners(count: Double)
- abstract fun removeListeners(count:Double)
+ companion object {
+ const val NAME = "Voice"
+ }
}
diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock
index 97277570..950cfdea 100644
--- a/example/ios/Podfile.lock
+++ b/example/ios/Podfile.lock
@@ -1772,7 +1772,7 @@ SPEC CHECKSUMS:
React-logger: addd140841248966c2547eb94836399cc1061f4d
React-Mapbuffer: 029b5332e78af8c67c4b5e65edfc717068b8eac1
React-microtasksnativemodule: f30949ee318ba90b9668de1e325b98838b9a4da2
- react-native-voice: 7548ce5ff00cd04bdb8e42bf6e2bd3e8b71d4f2b
+ react-native-voice: bf610989fa43b0c98e60f86925200f9aa8803050
React-nativeconfig: 470fce6d871c02dc5eff250a362d56391b7f52d6
React-NativeModulesApple: 1586448c61a7c2bd4040cc03ccde66a72037e77e
React-perflogger: c8860eaab4fe60d628b27bf0086a372c429fc74f
@@ -1805,4 +1805,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 71a689932e49f453bd6454dd189b45915dda66a0
-COCOAPODS: 1.15.2
+COCOAPODS: 1.16.2
diff --git a/example/ios/example.xcodeproj/project.pbxproj b/example/ios/example.xcodeproj/project.pbxproj
index 3dd2c496..b3e03545 100644
--- a/example/ios/example.xcodeproj/project.pbxproj
+++ b/example/ios/example.xcodeproj/project.pbxproj
@@ -590,7 +590,10 @@
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
- OTHER_LDFLAGS = "$(inherited) ";
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ " ",
+ );
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@@ -659,7 +662,10 @@
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
- OTHER_LDFLAGS = "$(inherited) ";
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ " ",
+ );
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;
diff --git a/example/src/VoiceTest.tsx b/example/src/VoiceTest.tsx
index 5b29e469..49e27b42 100644
--- a/example/src/VoiceTest.tsx
+++ b/example/src/VoiceTest.tsx
@@ -1,10 +1,12 @@
-import { Component } from 'react';
+import {useEffect, useState, useCallback} from 'react';
import {
StyleSheet,
Text,
View,
Image,
TouchableHighlight,
+ ScrollView,
+ Platform,
} from 'react-native';
import Voice, {
@@ -13,221 +15,348 @@ import Voice, {
type SpeechErrorEvent,
} from '@react-native-voice/voice';
-type Props = {};
-type State = {
- recognized: string;
- pitch: string;
- error: string;
- end: string;
- started: string;
- results: string[];
- partialResults: string[];
-};
-
-class VoiceTest extends Component {
- state = {
- recognized: '',
- pitch: '',
- error: '',
- end: '',
- started: '',
- results: [],
- partialResults: [],
- };
+// Set to true to enable debug logging
+const DEBUG = __DEV__;
- constructor(props: Props) {
- super(props);
- Voice.onSpeechStart = this.onSpeechStart;
- Voice.onSpeechRecognized = this.onSpeechRecognized;
- Voice.onSpeechEnd = this.onSpeechEnd;
- Voice.onSpeechError = this.onSpeechError;
- Voice.onSpeechResults = this.onSpeechResults;
- Voice.onSpeechPartialResults = this.onSpeechPartialResults;
- Voice.onSpeechVolumeChanged = this.onSpeechVolumeChanged;
- }
-
- componentWillUnmount() {
- Voice.destroy().then(Voice.removeAllListeners);
- }
-
- onSpeechStart = (e: any) => {
- console.log('onSpeechStart: ', e);
- this.setState({
- started: '√',
- });
- };
+function VoiceTest() {
+ const [recognized, setRecognized] = useState('');
+ const [pitch, setPitch] = useState(undefined);
+ const [error, setError] = useState('');
+ const [end, setEnd] = useState('');
+ const [started, setStarted] = useState('');
+ const [results, setResults] = useState([]);
+ const [partialResults, setPartialResults] = useState([]);
- onSpeechRecognized = (e: SpeechRecognizedEvent) => {
- console.log('onSpeechRecognized: ', e);
- this.setState({
- recognized: '√',
- });
- };
+ const log = useCallback((message: string) => {
+ if (DEBUG) {
+ console.log('[Voice]', message);
+ }
+ }, []);
- onSpeechEnd = (e: any) => {
- console.log('onSpeechEnd: ', e);
- this.setState({
- end: '√',
- });
- };
+ const onSpeechStart = useCallback(() => {
+ log('Speech started');
+ setStarted('√');
+ }, [log]);
- onSpeechError = (e: SpeechErrorEvent) => {
- console.log('onSpeechError: ', e);
- this.setState({
- error: JSON.stringify(e.error),
- });
- };
+ const onSpeechRecognized = useCallback((_e: SpeechRecognizedEvent) => {
+ setRecognized('√');
+ }, []);
- onSpeechResults = (e: SpeechResultsEvent) => {
- console.log('onSpeechResults: ', e);
- this.setState({
- results: e.value && e.value?.length > 0 ? e.value : [],
- });
- };
+ const onSpeechEnd = useCallback(() => {
+ log('Speech ended');
+ setEnd('√');
+ }, [log]);
- onSpeechPartialResults = (e: SpeechResultsEvent) => {
- console.log('onSpeechPartialResults: ', e);
- this.setState({
- partialResults: e.value && e.value?.length > 0 ? e.value : [],
- });
- };
+ const onSpeechError = useCallback(
+ (e: SpeechErrorEvent) => {
+ log(`Error: ${JSON.stringify(e.error)}`);
+ setError(JSON.stringify(e.error));
+ },
+ [log],
+ );
- onSpeechVolumeChanged = (e: any) => {
- console.log('onSpeechVolumeChanged: ', e);
- this.setState({
- pitch: e.value,
- });
- };
+ const onSpeechResults = useCallback(
+ (e: SpeechResultsEvent) => {
+ const newResults = e.value ?? [];
+ log(`Results: ${newResults.join(', ')}`);
+ setResults(newResults);
+ },
+ [log],
+ );
+
+ const onSpeechPartialResults = useCallback((e: SpeechResultsEvent) => {
+ const newPartialResults = e.value ?? [];
+ setPartialResults(newPartialResults);
+ }, []);
+
+ const onSpeechVolumeChanged = useCallback((e: {value?: number}) => {
+ setPitch(e.value);
+ }, []);
+
+ useEffect(() => {
+ Voice.onSpeechStart = onSpeechStart;
+ Voice.onSpeechRecognized = onSpeechRecognized;
+ Voice.onSpeechEnd = onSpeechEnd;
+ Voice.onSpeechError = onSpeechError;
+ Voice.onSpeechResults = onSpeechResults;
+ Voice.onSpeechPartialResults = onSpeechPartialResults;
+ Voice.onSpeechVolumeChanged = onSpeechVolumeChanged;
+
+ return () => {
+ Voice.destroy().then(Voice.removeAllListeners);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []); // Empty deps array - callbacks are stable via useCallback
- _startRecognizing = async () => {
- this.setState({
- recognized: '',
- pitch: '',
- error: '',
- started: '',
- results: [],
- partialResults: [],
- end: '',
- });
+ const _clearState = () => {
+ setRecognized('');
+ setPitch(undefined);
+ setError('');
+ setStarted('');
+ setResults([]);
+ setPartialResults([]);
+ setEnd('');
+ };
+ const _startRecognizing = async () => {
+ _clearState();
try {
+ log('Starting speech recognition...');
await Voice.start('en-US');
+ log('Speech recognition started');
} catch (e) {
- console.error(e);
+ const errorMsg = e instanceof Error ? e.message : String(e);
+ log(`Error: ${errorMsg}`);
+ setError(errorMsg);
}
};
- _stopRecognizing = async () => {
+ const _stopRecognizing = async () => {
try {
await Voice.stop();
+ log('Speech recognition stopped');
} catch (e) {
- console.error(e);
+ if (DEBUG) console.error(e);
}
};
- _cancelRecognizing = async () => {
+ const _cancelRecognizing = async () => {
try {
await Voice.cancel();
+ log('Speech recognition cancelled');
} catch (e) {
- console.error(e);
+ if (DEBUG) console.error(e);
}
};
- _destroyRecognizer = async () => {
+ const _destroyRecognizer = async () => {
try {
await Voice.destroy();
+ log('Speech recognizer destroyed');
} catch (e) {
- console.error(e);
+ if (DEBUG) console.error(e);
}
- this.setState({
- recognized: '',
- pitch: '',
- error: '',
- started: '',
- results: [],
- partialResults: [],
- end: '',
- });
+ _clearState();
};
- render() {
- return (
+ return (
+
- Welcome to React Native Voice!
+ React Native Voice
Press the button and start speaking.
- {`Started: ${this.state.started}`}
- {`Recognized: ${
- this.state.recognized
- }`}
- {`Pitch: ${this.state.pitch}`}
- {`Error: ${this.state.error}`}
- Results
- {this.state.results.map((result, index) => {
- return (
-
- {result}
-
- );
- })}
- Partial Results
- {this.state.partialResults.map((result, index) => {
- return (
-
- {result}
-
- );
- })}
- {`End: ${this.state.end}`}
-
+
+ {/* Status indicators */}
+
+ Started:
+
+ {started || '—'}
+
+ End:
+
+ {end || '—'}
+
+
+
+ {/* Error display */}
+ {error ? Error: {error} : null}
+
+ {/* Results */}
+ Results
+
+ {results.length > 0 ? (
+ results.map((result, index) => (
+
+ {result}
+
+ ))
+ ) : (
+ Speak to see results...
+ )}
+
+
+ {/* Partial Results */}
+ Live Transcription
+
+ {partialResults.length > 0 ? (
+ {partialResults[0]}
+ ) : (
+ ...
+ )}
+
+
+ {/* Volume indicator */}
+ {pitch ? (
+
+
+
+ ) : null}
+
+ {/* Controls */}
+
-
- Stop Recognizing
-
-
- Cancel
-
-
- Destroy
-
+
+
+
+ Stop
+
+
+ Cancel
+
+
+ Reset
+
+
+
+ {Platform.OS === 'ios' && (
+
+ Tip: On iOS, press Stop when done speaking
+
+ )}
- );
- }
+
+ );
}
const styles = StyleSheet.create({
- button: {
- width: 50,
- height: 50,
+ scrollContainer: {
+ flexGrow: 1,
},
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F5FCFF',
+ paddingVertical: 30,
+ paddingHorizontal: 20,
},
welcome: {
- fontSize: 20,
+ fontSize: 24,
+ fontWeight: 'bold',
textAlign: 'center',
- margin: 10,
+ marginBottom: 10,
+ color: '#333',
},
- action: {
+ instructions: {
textAlign: 'center',
- color: '#0000FF',
- marginVertical: 5,
+ color: '#666',
+ marginBottom: 20,
+ },
+ statusRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 15,
+ },
+ statusLabel: {
+ fontSize: 14,
+ color: '#888',
+ marginRight: 5,
+ },
+ statusValue: {
+ fontSize: 14,
+ color: '#ccc',
+ marginRight: 15,
+ },
+ statusActive: {
+ color: '#4CAF50',
fontWeight: 'bold',
},
- instructions: {
+ sectionTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ color: '#333',
+ marginTop: 15,
+ marginBottom: 8,
+ },
+ resultsContainer: {
+ backgroundColor: '#fff',
+ borderRadius: 10,
+ padding: 15,
+ width: '100%',
+ minHeight: 60,
+ shadowColor: '#000',
+ shadowOffset: {width: 0, height: 1},
+ shadowOpacity: 0.1,
+ shadowRadius: 3,
+ elevation: 2,
+ },
+ resultText: {
+ fontSize: 18,
+ color: '#2196F3',
+ textAlign: 'center',
+ },
+ partialContainer: {
+ backgroundColor: '#E3F2FD',
+ borderRadius: 10,
+ padding: 15,
+ width: '100%',
+ minHeight: 50,
+ },
+ partialText: {
+ fontSize: 16,
+ color: '#1976D2',
textAlign: 'center',
- color: '#333333',
- marginBottom: 5,
+ fontStyle: 'italic',
},
- stat: {
+ placeholder: {
+ fontSize: 14,
+ color: '#aaa',
textAlign: 'center',
- color: '#B0171F',
- marginBottom: 1,
+ },
+ volumeContainer: {
+ width: '80%',
+ height: 6,
+ backgroundColor: '#E0E0E0',
+ borderRadius: 3,
+ marginTop: 15,
+ overflow: 'hidden',
+ },
+ volumeBar: {
+ height: '100%',
+ backgroundColor: '#4CAF50',
+ borderRadius: 3,
+ },
+ buttonContainer: {
+ marginTop: 25,
+ marginBottom: 15,
+ borderRadius: 30,
+ },
+ button: {
+ width: 60,
+ height: 60,
+ },
+ actionsRow: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ gap: 20,
+ },
+ action: {
+ fontSize: 16,
+ color: '#2196F3',
+ paddingVertical: 10,
+ paddingHorizontal: 15,
+ },
+ errorText: {
+ color: '#F44336',
+ fontSize: 14,
+ textAlign: 'center',
+ marginBottom: 10,
+ },
+ hint: {
+ marginTop: 20,
+ fontSize: 12,
+ color: '#888',
+ fontStyle: 'italic',
},
});
diff --git a/example/src/VoiceTestFuncComp.tsx b/example/src/VoiceTestFuncComp.tsx
index 75b522fb..68626c3a 100644
--- a/example/src/VoiceTestFuncComp.tsx
+++ b/example/src/VoiceTestFuncComp.tsx
@@ -1,11 +1,5 @@
-import { useEffect, useState } from 'react';
-import {
- StyleSheet,
- Text,
- View,
- Image,
- TouchableHighlight,
-} from 'react-native';
+import {useEffect, useState} from 'react';
+import {StyleSheet, Text, View, Image, TouchableHighlight} from 'react-native';
import Voice, {
type SpeechRecognizedEvent,
@@ -21,6 +15,7 @@ function VoiceTest() {
const [started, setStarted] = useState('');
const [results, setResults] = useState([]);
const [partialResults, setPartialResults] = useState([]);
+ const [services, setServices] = useState([]);
useEffect(() => {
Voice.onSpeechStart = onSpeechStart;
@@ -106,6 +101,18 @@ function VoiceTest() {
_clearState();
};
+ const _getServices = async () => {
+ try {
+ console.log('Getting speech recognition services...');
+ const servicesList = await Voice.getSpeechRecognitionServices();
+ console.log('Services found:', servicesList);
+ setServices(servicesList);
+ } catch (e) {
+ console.error('Error getting services:', e);
+ setError(`Services Error: ${e}`);
+ }
+ };
+
const _clearState = () => {
setRecognized('');
setVolume('');
@@ -114,6 +121,9 @@ function VoiceTest() {
setStarted('');
setResults([]);
setPartialResults([]);
+ // Note: Services are device capabilities that don't change, but we clear them
+ // for UI consistency when resetting the recognition state
+ setServices([]);
};
return (
@@ -143,6 +153,16 @@ function VoiceTest() {
);
})}
{`End: ${end}`}
+ Recognition Services
+ {services.length > 0 ? (
+ services.map((service, index) => (
+
+ {service}
+
+ ))
+ ) : (
+ No services found
+ )}
@@ -155,6 +175,9 @@ function VoiceTest() {
Destroy
+
+ Get Services
+
);
}
diff --git a/ios/Voice/Voice.mm b/ios/Voice/Voice.mm
index e36949e0..7b9edc9d 100644
--- a/ios/Voice/Voice.mm
+++ b/ios/Voice/Voice.mm
@@ -29,8 +29,6 @@ @interface Voice ()
@implementation Voice {
}
-
-
///** Returns "YES" if no errors had occurred */
- (BOOL)setupAudioSession {
if ([self isHeadsetPluggedIn] || [self isHeadSetBluetooth]) {
@@ -164,11 +162,6 @@ - (void)setupAndTranscribeFile:(NSString *)filePath
self.speechRecognizer.delegate = self;
- [self sendEventWithName:@"onTranscriptionError"
- body:@{
- @"error" :
- @{@"code" : @"fake_error", @"message" : filePath}
- }];
// Set up recognition request
self.recognitionUrlRequest = [[SFSpeechURLRecognitionRequest alloc]
initWithURL:[NSURL fileURLWithPath:filePath]];
@@ -302,6 +295,10 @@ - (void)setupAndStartRecognizing:(NSString *)localeStr {
// Configure request so that results are returned before audio
// recording is finished
self.recognitionRequest.shouldReportPartialResults = YES;
+ // Set task hint for better end-of-speech detection (like dictation)
+ self.recognitionRequest.taskHint = SFSpeechRecognitionTaskHintDictation;
+ // Ensure continuous mode is off for auto-stop behavior
+ self.continuous = NO;
if (self.recognitionRequest == nil) {
[self sendResult:@{@"code" : @"recognition_init"}:nil:nil:nil];
@@ -580,9 +577,8 @@ - (void)speechRecognizer:(SFSpeechRecognizer *)speechRecognizer
}
}
-RCT_EXPORT_METHOD(startSpeech
- : (NSString *)localeStr callback
- : (RCTResponseSenderBlock)callback) {
+RCT_EXPORT_METHOD(startSpeech : (NSString *)
+ localeStr callback : (RCTResponseSenderBlock)callback) {
if (self.recognitionTask != nil) {
[self sendResult:RCTMakeError(@"Speech recognition already started!", nil,
nil):nil:nil:nil];
@@ -613,10 +609,8 @@ - (void)speechRecognizer:(SFSpeechRecognizer *)speechRecognizer
callback(@[ @false ]);
}
-RCT_EXPORT_METHOD(startTranscription
- : (NSString *)filePath withLocaleStr
- : (NSString *)localeStr callback
- : (RCTResponseSenderBlock)callback) {
+RCT_EXPORT_METHOD(startTranscription : (NSString *)filePath withLocaleStr : (
+ NSString *)localeStr callback : (RCTResponseSenderBlock)callback) {
if (self.recognitionTask != nil) {
[self sendResult:RCTMakeError(@"Speech recognition already started!", nil,
nil):nil:nil:nil];
@@ -646,8 +640,6 @@ - (void)speechRecognizer:(SFSpeechRecognizer *)speechRecognizer
}];
callback(@[ @false ]);
}
-
-
+ (BOOL)requiresMainQueueSetup {
return YES;
@@ -656,8 +648,7 @@ + (BOOL)requiresMainQueueSetup {
// Don't compile this code when we build for the old architecture.
#ifdef RCT_NEW_ARCH_ENABLED
- (std::shared_ptr)getTurboModule:
- (const facebook::react::ObjCTurboModule::InitParams &)params
-{
+ (const facebook::react::ObjCTurboModule::InitParams &)params {
return std::make_shared(params);
}
#endif
@@ -668,5 +659,4 @@ - (dispatch_queue_t)methodQueue {
RCT_EXPORT_MODULE()
-
@end
diff --git a/package.json b/package.json
index 0d048acd..81e6ae6d 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,13 @@
"ios",
"react-native",
"speech",
- "voice"
+ "voice",
+ "speech-to-text",
+ "speech-recognition",
+ "new-architecture",
+ "turbomodules",
+ "fabric",
+ "bridgeless"
],
"license": "MIT",
"source": "src/index.ts",
@@ -30,11 +36,16 @@
"types": "dist/index.d.ts",
"peerDependencies": {
"expo": ">=48.0.0",
- "react-native": ">=0.71.0"
+ "react": ">=18.0.0",
+ "react-native": ">=0.71.0",
+ "react-is": ">=18.0.0"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
+ },
+ "react-is": {
+ "optional": true
}
},
"repository": {
@@ -58,7 +69,7 @@
"android": "yarn --cwd example android",
"prepare": "yarn build && yarn build:plugin",
"build": "tsc",
- "dev-sync": "cp -r ./dist example/node_modules/@react-native-voice/voice",
+ "dev-sync": "mkdir -p example/node_modules/@react-native-voice/voice/dist && cp -r ./dist/* example/node_modules/@react-native-voice/voice/dist/ && cp -r ./android example/node_modules/@react-native-voice/voice/ && cp -r ./ios example/node_modules/@react-native-voice/voice/ && cp -r ./src example/node_modules/@react-native-voice/voice/ && cp package.json react-native-voice.podspec example/node_modules/@react-native-voice/voice/",
"type-check": "tsc -noEmit",
"build:plugin": "tsc --build plugin",
"lint:plugin": "eslint plugin/src/*"
diff --git a/src/NativeVoiceAndroid.ts b/src/NativeVoiceAndroid.ts
index f2ac10e6..014b912c 100644
--- a/src/NativeVoiceAndroid.ts
+++ b/src/NativeVoiceAndroid.ts
@@ -1,17 +1,34 @@
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
-type SpeechType = {
- EXTRA_LANGUAGE_MODEL: string;
- EXTRA_MAX_RESULTS: string;
- EXTRA_PARTIAL_RESULTS: string;
- REQUEST_PERMISSIONS_AUTO: string;
- RECOGNIZER_ENGINE: string;
+
+export type SpeechOptions = {
+ EXTRA_LANGUAGE_MODEL?: string;
+ /**
+ * Maximum number of results
+ * Supports both legacy string format and new number format for backward compatibility
+ * Legacy: string (e.g., "5") - will be converted to number
+ * New: number (e.g., 5) - preferred format
+ */
+ EXTRA_MAX_RESULTS?: string | number;
+ /**
+ * Enable partial results
+ * Supports both legacy string format and new boolean format for backward compatibility
+ * Legacy: string (e.g., "true") - will be converted to boolean
+ * New: boolean (e.g., true) - preferred format
+ */
+ EXTRA_PARTIAL_RESULTS?: string | boolean;
+ REQUEST_PERMISSIONS_AUTO?: boolean;
+ RECOGNIZER_ENGINE?: string;
+ EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS?: number;
+ EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS?: number;
+ EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS?: number;
};
+
export interface Spec extends TurboModule {
destroySpeech: (callback: (error: string) => void) => void;
startSpeech: (
locale: string,
- opts: SpeechType,
+ opts: SpeechOptions,
callback: (error: string) => void
) => void;
stopSpeech: (callback: (error: string) => void) => void;
@@ -20,9 +37,15 @@ export interface Spec extends TurboModule {
callback: (isAvailable: boolean, error: string) => void
) => void;
getSpeechRecognitionServices(): Promise;
- isRecognizing: (callback: (Recognizing: boolean) => void) => void;
+ isRecognizing: (callback: (isRecognizing: boolean) => void) => void;
+ /**
+ * Add an event listener for speech recognition events
+ * Supported events: 'onSpeechStart', 'onSpeechRecognized', 'onSpeechEnd', 'onSpeechError', 'onSpeechResults', 'onSpeechPartialResults', 'onSpeechVolumeChanged'
+ * Note: Android does not support transcription events
+ */
addListener: (eventType: string) => void;
removeListeners: (count: number) => void;
}
-export default TurboModuleRegistry.getEnforcing('Voice');
+// Use get() instead of getEnforcing() for graceful fallback if module isn't available
+export default TurboModuleRegistry.get('Voice');
diff --git a/src/NativeVoiceIOS.ts b/src/NativeVoiceIOS.ts
index 50b37a3e..78c13c24 100644
--- a/src/NativeVoiceIOS.ts
+++ b/src/NativeVoiceIOS.ts
@@ -5,6 +5,7 @@ export interface Spec extends TurboModule {
destroySpeech: (callback: (error: string) => void) => void;
startSpeech: (locale: string, callback: (error: string) => void) => void;
startTranscription: (
+ filePath: string,
locale: string,
callback: (error: string) => void,
) => void;
@@ -15,10 +16,15 @@ export interface Spec extends TurboModule {
isSpeechAvailable: (
callback: (isAvailable: boolean, error: string) => void,
) => void;
- isRecognizing: (callback: (Recognizing: boolean) => void) => void;
+ isRecognizing: (callback: (isRecognizing: boolean) => void) => void;
+ /**
+ * Add an event listener for speech recognition events
+ * Supported events: 'onSpeechStart', 'onSpeechRecognized', 'onSpeechEnd', 'onSpeechError', 'onSpeechResults', 'onSpeechPartialResults', 'onSpeechVolumeChanged', 'onTranscriptionStart', 'onTranscriptionEnd', 'onTranscriptionError', 'onTranscriptionResults'
+ */
addListener: (eventType: string) => void;
removeListeners: (count: number) => void;
destroyTranscription: (callback: (error: string) => void) => void;
}
-export default TurboModuleRegistry.getEnforcing('Voice');
+// Use get() instead of getEnforcing() to allow graceful fallback
+export default TurboModuleRegistry.get('Voice');
diff --git a/src/VoiceModuleTypes.ts b/src/VoiceModuleTypes.ts
index 53e0160c..16008c56 100644
--- a/src/VoiceModuleTypes.ts
+++ b/src/VoiceModuleTypes.ts
@@ -31,12 +31,87 @@ export type SpeechResultsEvent = {
value?: string[];
};
+/**
+ * Transcription segment with timing metadata (iOS only)
+ * Used for file-based transcription with word-level timing information
+ */
+export type TranscriptionSegment = {
+ transcription?: string;
+ timestamp?: number;
+ duration?: number;
+};
+
+/**
+ * Transcription results event
+ * Note: Transcription is iOS-only. Android does not support transcription.
+ *
+ * IMPORTANT: At runtime, `segments` is always `TranscriptionSegment[]` (objects).
+ * The union type `string[] | TranscriptionSegment[]` is only for TypeScript backward
+ * compatibility. The native iOS implementation never sends string arrays.
+ */
export type TranscriptionResultsEvent = {
- segments?: string[];
+ /**
+ * Array of transcription segments (iOS only, optional)
+ *
+ * Runtime type: Always TranscriptionSegment[] (objects with transcription, timestamp, duration)
+ * TypeScript type: string[] | TranscriptionSegment[] (for backward compatibility)
+ *
+ * Use `isTranscriptionSegmentArray()` type guard to safely check the format at runtime.
+ *
+ * @example
+ * ```typescript
+ * Voice.onTranscriptionResults = (e) => {
+ * if (e.segments && isTranscriptionSegmentArray(e.segments)) {
+ * // TypeScript now knows e.segments is TranscriptionSegment[]
+ * e.segments.forEach(segment => {
+ * console.log(segment.transcription, segment.timestamp);
+ * });
+ * }
+ * };
+ * ```
+ */
+ segments?: string[] | TranscriptionSegment[];
+ /** Full transcription text (unchanged, backward compatible, optional) */
transcription?: string;
+ /** Whether this is the final result (optional) */
isFinal?: boolean;
};
+/**
+ * Type guard to check if segments is TranscriptionSegment[] format
+ *
+ * At runtime, segments is always TranscriptionSegment[], but this guard helps
+ * TypeScript narrow the type and provides runtime safety.
+ *
+ * @param segments - The segments array to check
+ * @returns true if segments is TranscriptionSegment[] format
+ *
+ * @example
+ * ```typescript
+ * if (e.segments && isTranscriptionSegmentArray(e.segments)) {
+ * // TypeScript knows e.segments is TranscriptionSegment[]
+ * e.segments.forEach(segment => {
+ * console.log(segment.transcription, segment.timestamp);
+ * });
+ * }
+ * ```
+ */
+export function isTranscriptionSegmentArray(
+ segments: string[] | TranscriptionSegment[] | undefined
+): segments is TranscriptionSegment[] {
+ if (!segments) {
+ return false;
+ }
+ // Empty array: At runtime, segments is always TranscriptionSegment[], so empty array
+ // should be considered the new format (TranscriptionSegment[])
+ if (segments.length === 0) {
+ return true;
+ }
+ // Check if first element is an object (TranscriptionSegment) or string
+ const first = segments[0];
+ return typeof first === 'object' && first !== null && 'transcription' in first;
+}
+
export type SpeechErrorEvent = {
error?: {
code?: string;
diff --git a/src/index.ts b/src/index.ts
index 71ffd04f..a7d6cb30 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,10 +1,10 @@
import {
NativeModules,
+ DeviceEventEmitter,
NativeEventEmitter,
Platform,
type EventSubscription,
} from 'react-native';
-import invariant from 'invariant';
import {
type SpeechEvents,
type TranscriptionEvents,
@@ -26,29 +26,58 @@ const LINKING_ERROR =
'- You rebuilt the app after installing the package\n' +
'- You are not using Expo Go\n';
-//@ts-expect-error
+//@ts-expect-error - Check if TurboModules are enabled (new architecture)
const isTurboModuleEnabled = global.__turboModuleProxy != null;
-const VoiceNativeModule = isTurboModuleEnabled
- ? Platform.OS === 'android'
- ? require('./NativeVoiceAndroid').default
- : require('./NativeVoiceIOS').default
- : NativeModules.Voice;
+// Try to get the native module - with fallback for Bridgeless mode
+const getVoiceModule = () => {
+ // Try TurboModule first if enabled
+ if (isTurboModuleEnabled) {
+ try {
+ const turboModule = Platform.OS === 'android'
+ ? require('./NativeVoiceAndroid').default
+ : require('./NativeVoiceIOS').default;
+ if (turboModule) {
+ return turboModule;
+ }
+ } catch (e) {
+ // TurboModule not available, fall through to NativeModules
+ }
+ }
+
+ // Fallback to NativeModules (works in both Bridge and Bridgeless mode)
+ return NativeModules.Voice;
+};
-const Voice = VoiceNativeModule
- ? VoiceNativeModule
- : new Proxy(
- {},
- {
- get() {
- throw new Error(LINKING_ERROR);
- },
- },
- );
+const Voice = getVoiceModule() || new Proxy(
+ {},
+ {
+ get() {
+ throw new Error(LINKING_ERROR);
+ },
+ },
+);
-// NativeEventEmitter is only availabe on React Native platforms, so this conditional is used to avoid import conflicts in the browser/server
-const voiceEmitter =
- Platform.OS !== 'web' ? new NativeEventEmitter(Voice) : null;
+// Platform-specific event emitter setup:
+// - iOS: Always uses RCTEventEmitter (module-specific), needs NativeEventEmitter
+// - Android: Uses RCTDeviceEventEmitter (global), needs DeviceEventEmitter
+const voiceEmitter = (() => {
+ if (Platform.OS === 'web') {
+ return null;
+ }
+
+ // iOS always uses NativeEventEmitter with the Voice module
+ if (Platform.OS === 'ios') {
+ try {
+ return Voice ? new NativeEventEmitter(Voice) : DeviceEventEmitter;
+ } catch (e) {
+ return DeviceEventEmitter;
+ }
+ }
+
+ // Android uses DeviceEventEmitter (global event bus)
+ return DeviceEventEmitter;
+})();
type SpeechEvent = keyof SpeechEvents;
type TranscriptionEvent = keyof TranscriptionEvents;
@@ -56,10 +85,12 @@ class RCTVoice {
private _loaded: boolean;
private _listeners: EventSubscription[];
private _events: Required & Required;
+ private _needsListenerUpdate: boolean;
constructor() {
this._loaded = false;
- this._listeners = JSON.parse(JSON.stringify([]));
+ this._listeners = [];
+ this._needsListenerUpdate = false;
this._events = {
onSpeechStart: () => {},
onSpeechRecognized: () => {},
@@ -83,12 +114,12 @@ class RCTVoice {
}
});
- this._listeners = JSON.parse(JSON.stringify([]));
+ this._listeners = [];
}
}
destroy() {
- if (!this._loaded && !this._listeners) {
+ if (!this._loaded || this._listeners.length === 0) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
@@ -103,7 +134,7 @@ class RCTVoice {
});
}
destroyTranscription() {
- if (!this._loaded && !this._listeners) {
+ if (!this._loaded || this._listeners.length === 0) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
@@ -113,7 +144,7 @@ class RCTVoice {
} else {
if (this._listeners?.length > 0) {
this._listeners.forEach((listener) => listener.remove());
- this._listeners = JSON.parse(JSON.stringify([]));
+ this._listeners = [];
}
resolve();
}
@@ -122,16 +153,9 @@ class RCTVoice {
}
start(locale: string, options = {}) {
- if (
- !this._loaded &&
- this._listeners.length === 0 &&
- voiceEmitter !== null
- ) {
- this._listeners = (Object.keys(this._events) as SpeechEvent[]).map(
- (key: SpeechEvent) => voiceEmitter.addListener(key, this._events[key]),
- );
- }
-
+ // Ensure listeners are set up BEFORE starting recognition
+ this._setupListeners();
+
return new Promise((resolve, reject) => {
const callback = (error: string) => {
if (error) {
@@ -140,6 +164,7 @@ class RCTVoice {
resolve();
}
};
+
if (Platform.OS === 'android') {
Voice.startSpeech(
locale,
@@ -160,12 +185,8 @@ class RCTVoice {
});
}
startTranscription(url: string, locale: string, options = {}) {
- if (!this._loaded && !this._listeners && voiceEmitter !== null) {
- this._listeners = (Object.keys(this._events) as TranscriptionEvent[]).map(
- (key: TranscriptionEvent) =>
- voiceEmitter.addListener(key, this._events[key]),
- );
- }
+ // Ensure listeners are set up BEFORE starting transcription
+ this._setupListeners();
return new Promise((resolve, reject) => {
const callback = (error: string) => {
@@ -196,7 +217,7 @@ class RCTVoice {
});
}
stop() {
- if (!this._loaded && !this._listeners) {
+ if (!this._loaded || this._listeners.length === 0) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
@@ -210,7 +231,7 @@ class RCTVoice {
});
}
stopTranscription() {
- if (!this._loaded && !this._listeners) {
+ if (!this._loaded || this._listeners.length === 0) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
@@ -224,7 +245,7 @@ class RCTVoice {
});
}
cancel() {
- if (!this._loaded && !this._listeners) {
+ if (!this._loaded || this._listeners.length === 0) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
@@ -238,11 +259,11 @@ class RCTVoice {
});
}
cancelTranscription() {
- if (!this._loaded && !this._listeners) {
+ if (!this._loaded || this._listeners.length === 0) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
- Voice.cancelSpeech((error?: string) => {
+ Voice.cancelTranscription((error?: string) => {
if (error) {
reject(new Error(error));
} else {
@@ -266,13 +287,13 @@ class RCTVoice {
/**
* (Android) Get a list of the speech recognition engines available on the device
* */
- getSpeechRecognitionServices() {
+ getSpeechRecognitionServices(): Promise {
if (Platform.OS !== 'android') {
- invariant(
- Voice,
- 'Speech recognition services can be queried for only on Android',
+ return Promise.reject(
+ new Error(
+ 'Speech recognition services can be queried for only on Android',
+ ),
);
- return;
}
return Voice.getSpeechRecognitionServices();
@@ -285,45 +306,138 @@ class RCTVoice {
}
set onSpeechStart(fn: (e: SpeechStartEvent) => void) {
- this._events.onSpeechStart = fn;
+ if (this._events.onSpeechStart !== fn) {
+ this._events.onSpeechStart = fn;
+ this._needsListenerUpdate = true;
+ }
}
set onTranscriptionStart(fn: (e: TranscriptionStartEvent) => void) {
- this._events.onTranscriptionStart = fn;
+ if (this._events.onTranscriptionStart !== fn) {
+ this._events.onTranscriptionStart = fn;
+ this._needsListenerUpdate = true;
+ }
}
set onSpeechRecognized(fn: (e: SpeechRecognizedEvent) => void) {
- this._events.onSpeechRecognized = fn;
+ if (this._events.onSpeechRecognized !== fn) {
+ this._events.onSpeechRecognized = fn;
+ this._needsListenerUpdate = true;
+ }
}
set onSpeechEnd(fn: (e: SpeechEndEvent) => void) {
- this._events.onSpeechEnd = fn;
+ if (this._events.onSpeechEnd !== fn) {
+ this._events.onSpeechEnd = fn;
+ this._needsListenerUpdate = true;
+ }
}
set onTranscriptionEnd(fn: (e: SpeechEndEvent) => void) {
- this._events.onTranscriptionEnd = fn;
+ if (this._events.onTranscriptionEnd !== fn) {
+ this._events.onTranscriptionEnd = fn;
+ this._needsListenerUpdate = true;
+ }
}
set onSpeechError(fn: (e: SpeechErrorEvent) => void) {
- this._events.onSpeechError = fn;
+ if (this._events.onSpeechError !== fn) {
+ this._events.onSpeechError = fn;
+ this._needsListenerUpdate = true;
+ }
}
set onTranscriptionError(fn: (e: TranscriptionErrorEvent) => void) {
- this._events.onTranscriptionError = fn;
+ if (this._events.onTranscriptionError !== fn) {
+ this._events.onTranscriptionError = fn;
+ this._needsListenerUpdate = true;
+ }
}
set onSpeechResults(fn: (e: SpeechResultsEvent) => void) {
- this._events.onSpeechResults = fn;
+ if (this._events.onSpeechResults !== fn) {
+ this._events.onSpeechResults = fn;
+ this._needsListenerUpdate = true;
+ }
}
set onTranscriptionResults(fn: (e: TranscriptionResultsEvent) => void) {
- this._events.onTranscriptionResults = fn;
+ if (this._events.onTranscriptionResults !== fn) {
+ this._events.onTranscriptionResults = fn;
+ this._needsListenerUpdate = true;
+ }
}
set onSpeechPartialResults(fn: (e: SpeechResultsEvent) => void) {
- this._events.onSpeechPartialResults = fn;
+ if (this._events.onSpeechPartialResults !== fn) {
+ this._events.onSpeechPartialResults = fn;
+ this._needsListenerUpdate = true;
+ }
}
set onSpeechVolumeChanged(fn: (e: SpeechVolumeChangeEvent) => void) {
- this._events.onSpeechVolumeChanged = fn;
+ if (this._events.onSpeechVolumeChanged !== fn) {
+ this._events.onSpeechVolumeChanged = fn;
+ this._needsListenerUpdate = true;
+ }
+ }
+
+ /**
+ * Sets up event listeners for all registered event handlers.
+ * This method is called before starting recognition to ensure listeners are active.
+ * Listeners are only updated when handlers have changed (tracked via _needsListenerUpdate).
+ */
+ private _setupListeners() {
+ if (voiceEmitter === null) {
+ return;
+ }
+
+ // Only update listeners if handlers have changed or listeners haven't been set up yet
+ if (!this._needsListenerUpdate && this._loaded && this._listeners.length > 0) {
+ return;
+ }
+
+ // Remove existing listeners before setting up new ones
+ if (this._listeners.length > 0) {
+ this._listeners.forEach(listener => {
+ try {
+ listener.remove();
+ } catch (e) {
+ // Ignore errors when removing listeners
+ }
+ });
+ this._listeners = [];
+ }
+
+ // Set up listeners for all events (both Speech and Transcription events)
+ const newListeners: EventSubscription[] = [];
+ const allEventKeys = [
+ ...(Object.keys(this._events) as (SpeechEvent | TranscriptionEvent)[]),
+ ];
+
+ allEventKeys.forEach((key: SpeechEvent | TranscriptionEvent) => {
+ const handler = this._events[key];
+
+ if (!handler || typeof handler !== 'function') {
+ return;
+ }
+
+ const currentHandler = handler;
+
+ const listener = voiceEmitter!.addListener(key, (event: any) => {
+ if (currentHandler) {
+ try {
+ currentHandler(event);
+ } catch (error) {
+ // Handler error - silently ignore
+ }
+ }
+ });
+
+ newListeners.push(listener);
+ });
+
+ this._listeners = newListeners;
+ this._loaded = true;
+ this._needsListenerUpdate = false;
}
}
@@ -341,4 +455,5 @@ export type {
TranscriptionStartEvent,
TranscriptionResultsEvent,
};
+export { isTranscriptionSegmentArray } from './VoiceModuleTypes';
export default new RCTVoice();