Enhanced React Native Voice Recognition Library with Hold Recording Support
A robust React Native voice recognition library with critical bug fixes for React Native 0.76+ and the New Architecture. This package resolves the major event listener issues that break speech-to-text functionality in the latest React Native versions.
- Fixed: Critical event listener bug in React Native 0.76+ with New Architecture
-
Solution: Implemented
DeviceEventEmitter
pattern for reliable event handling - Result: Speech-to-text results now work properly in RN 0.76+ and 0.80+
- Issue: Android Speech Recognition API sometimes returns empty final results even when partial results contain valid transcription
-
Symptoms:
onSpeechResults
callback receives empty array[]
despite successful partial results - Root Cause: Device-specific speech recognition quirks, short speech segments, background noise, timing issues
-
Solution: Implemented comprehensive fallback mechanism using partial results
- Timeout Fallback: 1-second timeout ensures transcript processing even if final results fail
- Partial Result Storage: Stores meaningful partial results for fallback use
- Smart Fallback Logic: Uses partial results when final results are empty
- Error Recovery: Proper cleanup and interruption handling
-
New:
startHoldRecording()
andstopHoldRecording()
methods - Feature: Continuous recording without auto-stop timeouts
- Use Case: Perfect for voice messages and long-form dictation
- Architecture: Full New Architecture (TurboModules) support
- Platforms: iOS 11+ and Android API 21+
- Stability: Comprehensive error handling and state management
npm install react-native-voice-hold
cd ios && pod install
Add microphone permissions to android/app/src/main/AndroidManifest.xml
:
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
import Voice from 'react-native-voice-hold';
import { DeviceEventEmitter } from 'react-native';
const VoiceComponent = () => {
const [results, setResults] = useState<string[]>([]);
const [isListening, setIsListening] = useState(false);
useEffect(() => {
// ✅ CRITICAL: Use DeviceEventEmitter for RN 0.76+ compatibility
const onSpeechResults = DeviceEventEmitter.addListener(
'onSpeechResults',
(e: any) => {
console.log('📝 Speech results:', e.value);
setResults(e.value);
}
);
const onSpeechStart = DeviceEventEmitter.addListener(
'onSpeechStart',
() => {
console.log('🎤 Speech started');
setIsListening(true);
}
);
const onSpeechEnd = DeviceEventEmitter.addListener(
'onSpeechEnd',
() => {
console.log('🔇 Speech ended');
setIsListening(false);
}
);
return () => {
onSpeechResults.remove();
onSpeechStart.remove();
onSpeechEnd.remove();
};
}, []);
const startListening = async () => {
try {
await Voice.start('en-US');
} catch (error) {
console.error('❌ Error starting voice recognition:', error);
}
};
const stopListening = async () => {
try {
await Voice.stop();
} catch (error) {
console.error('❌ Error stopping voice recognition:', error);
}
};
return (
<View>
<Button
title={isListening ? 'Stop Listening' : 'Start Listening'}
onPress={isListening ? stopListening : startListening}
/>
{results.map((result, index) => (
<Text key={index}>{result}</Text>
))}
</View>
);
};
import Voice from 'react-native-voice-hold';
// Start continuous recording (perfect for voice messages)
const startHoldRecording = async () => {
try {
await Voice.startHoldRecording('en-US', {
// Hold mode disables silence timeouts
continuous: true,
maximumWaitTime: 300000, // 5 minutes max
});
console.log('🎙️ Hold recording started');
} catch (error) {
console.error('❌ Error starting hold recording:', error);
}
};
// Stop hold recording manually
const stopHoldRecording = async () => {
try {
await Voice.stopHoldRecording();
console.log('⏹️ Hold recording stopped');
} catch (error) {
console.error('❌ Error stopping hold recording:', error);
}
};
import { useVoiceRecognition } from 'react-native-voice-hold';
const VoiceAssistant = () => {
const [state, actions] = useVoiceRecognition({
locale: 'en-US',
onStart: () => {
console.log('🎤 Speech started');
// Clear any existing timeout and reset state
},
onEnd: () => {
console.log('🔇 Speech ended - processing...');
// 🔥 CRITICAL FIX: 1-second timeout ensures transcript processing
// even if final results don't come
},
onResults: results => {
console.log('📝 Final results received:', results);
if (results.length > 0) {
// Process final results normally
processTranscript(results[0]);
} else {
// 🔥 CRITICAL FIX: Use partial results as fallback
console.log('⚠️ Final results empty, using partial results fallback');
// Hook automatically handles fallback logic
}
},
onPartialResults: partial => {
console.log('🔄 Partial results:', partial);
// 🔥 CRITICAL FIX: Hook stores partial results for fallback
},
});
// The hook automatically handles all fallback logic!
return (
<View>
<TouchableOpacity
onPressIn={() => actions.startHoldRecording()}
onPressOut={() => actions.stopListening()}
>
<Text>Hold to Speak</Text>
</TouchableOpacity>
</View>
);
};
import React, { useEffect, useState, useRef } from 'react';
import { DeviceEventEmitter, Platform } from 'react-native';
import Voice from 'react-native-voice-hold';
const VoiceComponent = () => {
const [results, setResults] = useState<string[]>([]);
const [isListening, setIsListening] = useState(false);
// 🔥 CRITICAL FIX: Add refs for fallback mechanism
const lastPartialResult = useRef<string>('');
const resultsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isInterrupted = useRef<boolean>(false);
useEffect(() => {
const onSpeechStart = DeviceEventEmitter.addListener('onSpeechStart', () => {
console.log('🎤 Speech started');
setIsListening(true);
// Clear any existing timeout
if (resultsTimeoutRef.current) {
clearTimeout(resultsTimeoutRef.current);
resultsTimeoutRef.current = null;
}
lastPartialResult.current = '';
});
const onSpeechEnd = DeviceEventEmitter.addListener('onSpeechEnd', () => {
console.log('🔇 Speech ended - processing...');
// 🔥 CRITICAL FIX: Add timeout to ensure we process transcript
resultsTimeoutRef.current = setTimeout(() => {
console.log('⏰ Timeout: No final results received, using partial results');
const fallbackTranscript = lastPartialResult.current;
if (fallbackTranscript.trim() && !isInterrupted.current) {
console.log('🔄 Timeout fallback transcript:', fallbackTranscript);
handleTranscript(fallbackTranscript);
}
}, 1000); // Wait 1 second for final results
});
const onSpeechResults = DeviceEventEmitter.addListener('onSpeechResults', (e) => {
console.log('📝 Speech results:', e.value);
// Clear the timeout since we got results
if (resultsTimeoutRef.current) {
clearTimeout(resultsTimeoutRef.current);
resultsTimeoutRef.current = null;
}
if (e.value && e.value.length > 0) {
const finalTranscript = e.value[0];
console.log('⚡ Fast transcript:', finalTranscript);
handleTranscript(finalTranscript);
} else {
// 🔥 CRITICAL FIX: Use partial results as fallback
console.log('⚠️ Final results empty, checking partial results...');
const fallbackTranscript = lastPartialResult.current;
if (fallbackTranscript.trim()) {
console.log('🔄 Using fallback transcript:', fallbackTranscript);
handleTranscript(fallbackTranscript);
}
}
});
const onSpeechPartialResults = DeviceEventEmitter.addListener('onSpeechPartialResults', (e) => {
console.log('🔄 Partial results:', e.value);
if (e.value && e.value.length > 0) {
const partialTranscript = e.value[0];
// 🔥 CRITICAL FIX: Store the last partial result for fallback
if (partialTranscript.trim()) {
lastPartialResult.current = partialTranscript;
}
}
});
return () => {
onSpeechStart.remove();
onSpeechEnd.remove();
onSpeechResults.remove();
onSpeechPartialResults.remove();
// Clear timeout on cleanup
if (resultsTimeoutRef.current) {
clearTimeout(resultsTimeoutRef.current);
resultsTimeoutRef.current = null;
}
Voice.destroy();
};
}, []);
const handleTranscript = (transcript: string) => {
console.log('🎯 Processing transcript:', transcript);
setResults([transcript]);
};
// ... rest of component
};
-
Voice.start(locale)
- Start voice recognition -
Voice.stop()
- Stop voice recognition -
Voice.cancel()
- Cancel voice recognition -
Voice.destroy()
- Clean up resources -
Voice.isAvailable()
- Check if voice recognition is available -
Voice.isRecognizing()
- Check if currently recognizing
-
Voice.startHoldRecording(locale, options)
- Start continuous recording -
Voice.stopHoldRecording()
- Stop hold recording
// ✅ CORRECT: Use DeviceEventEmitter
DeviceEventEmitter.addListener('onSpeechStart', handler);
DeviceEventEmitter.addListener('onSpeechRecognized', handler);
DeviceEventEmitter.addListener('onSpeechEnd', handler);
DeviceEventEmitter.addListener('onSpeechError', handler);
DeviceEventEmitter.addListener('onSpeechResults', handler);
DeviceEventEmitter.addListener('onSpeechPartialResults', handler);
DeviceEventEmitter.addListener('onSpeechVolumeChanged', handler);
// ❌ BROKEN: Direct assignment (doesn't work in RN 0.76+)
Voice.onSpeechResults = handler; // DON'T USE
-
Install the new package:
npm uninstall @react-native-voice/voice npm install react-native-voice-hold
-
Update your imports:
// Old
import Voice from '@react-native-voice/voice';
// New import Voice from 'react-native-voice-hold';
3. **Update event listeners for RN 0.76+ compatibility:**
```typescript
// Old (broken in RN 0.76+)
Voice.onSpeechResults = (e) => console.log(e.value);
// New (works in all RN versions)
DeviceEventEmitter.addListener('onSpeechResults', (e) => console.log(e.value));
-
Add fallback logic for empty final results:
// The useVoiceRecognition hook automatically handles this! // Or implement the fallback pattern shown above for direct usage
Problem: onSpeechResults
receives empty array despite successful partial results.
Solution: ✅ Fixed in this package - The hook and examples above include comprehensive fallback logic.
Problem: Speech events not firing in React Native 0.76+.
Solution: ✅ Fixed - Use DeviceEventEmitter
pattern instead of direct assignment.
Problem: Hold recording stops unexpectedly.
Solution: ✅ Fixed - Proper hold mode state management prevents premature stopping.
MIT License - see LICENSE file for details.
Contributions are welcome! Please read our contributing guidelines and submit pull requests.
Built with ❤️ for the React Native community