From 8ea409122ba206471f2f08c6c1c38fb89b70ec58 Mon Sep 17 00:00:00 2001 From: Simon Zheng Date: Sat, 12 Apr 2025 16:59:59 -0700 Subject: [PATCH] Allow voice input for future prompts / output speech explaining what was done Summary: Allow voice input for future prompts / output speech explaining what was done # Cursor Agent Prompt: Add Voice Input to Claude Code Go ## Objective Add a voice input feature using Expo Speech Recognition to the Claude Code Go app, placing a speech button next to the existing text input for prompt submission. ## Background Claude Code Go is an Expo React Native app that allows users to browse directories and run prompts in a git repository, leveraging Claude Code for code generation. Currently, the app only supports text input for prompts, but we want to add voice dictation as an alternative input method. ## Requirements 1. Add a microphone button next to the existing text input field 2. Implement speech-to-text functionality using Expo Speech Recognition 3. Show visual feedback during recording 4. Allow users to cancel recording 5. Maintain compatibility with the existing text input workflow ## Technical Implementation ### 1. Install Dependencies First, install the Expo Speech Recognition package: ```bash npx expo install expo-speech-recognition @react-native-voice/voice ``` ### 2. Add Permissions Update the app.json file to request microphone permissions: ```json { "expo": { "plugins": [ [ "expo-speech-recognition", { "microphonePermission": "Allow $(PRODUCT_NAME) to access your microphone" } ] ], "android": { "permissions": ["RECORD_AUDIO"] } } } ``` ### 3. Component Implementation Find the component file where your text input exists and implement the speech button with the following features: - A microphone button that toggles speech recognition - Visual feedback during recording - Error handling - Integration with the existing text input ### 4. Code Implementation Create a custom hook for speech recognition: ```jsx // hooks/useSpeechRecognition.js import { useState, useEffect } from 'react'; import * as Speech from 'expo-speech-recognition'; import { Platform } from 'react-native'; export const useSpeechRecognition = () => { const [isListening, setIsListening] = useState(false); const [speechText, setSpeechText] = useState(''); const [hasPermission, setHasPermission] = useState(false); const [error, setError] = useState(null); useEffect(() => { const checkPermissions = async () => { try { const { status } = await Speech.requestPermissionsAsync(); setHasPermission(status === 'granted'); } catch (err) { setError('Permission check failed'); console.error(err); } }; checkPermissions(); return () => { if (isListening) { stopListening(); } }; }, []); const startListening = async () => { try { if (!hasPermission) { const { status } = await Speech.requestPermissionsAsync(); if (status !== 'granted') { setError('Microphone permission denied'); return; } setHasPermission(true); } setSpeechText(''); setError(null); await Speech.startAsync({ onSpeechResult: (result) => { if (result.value && result.value.length > 0) { setSpeechText(result.value[0]); } }, onSpeechError: (err) => { setError(err.message); setIsListening(false); }, options: { language: 'en-US', }, }); setIsListening(true); } catch (err) { setError('Failed to start speech recognition'); console.error(err); } }; const stopListening = async () => { try { await Speech.stopAsync(); setIsListening(false); } catch (err) { console.error(err); } }; const toggleListening = async () => { if (isListening) { await stopListening(); } else { await startListening(); } }; return { isListening, speechText, hasPermission, error, startListening, stopListening, toggleListening, }; }; ``` Modify your prompt input component to include the voice button: ```jsx // components/PromptInput.jsx import React, { useState, useEffect } from 'react'; import { View, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Text } from 'react-native'; import { FontAwesome } from '@expo/vector-icons'; import { useSpeechRecognition } from '../hooks/useSpeechRecognition'; export const PromptInput = ({ onSubmit }) => { const [prompt, setPrompt] = useState(''); const { isListening, speechText, hasPermission, error, toggleListening, stopListening, } = useSpeechRecognition(); useEffect(() => { if (speechText) { setPrompt(prev => prev + speechText); } }, [speechText]); const handleSubmit = () => { if (prompt.trim()) { onSubmit(prompt.trim()); setPrompt(''); } }; return ( {isListening ? ( ) : ( )} {isListening && ( Listening... Cancel )} {error && {error}} ); }; const styles = StyleSheet.create({ container: { padding: 12, borderTopWidth: 1, borderTopColor: '#e0e0e0', backgroundColor: '#fff', }, inputContainer: { flexDirection: 'row', alignItems: 'flex-end', }, input: { flex: 1, borderWidth: 1, borderColor: '#ddd', borderRadius: 8, padding: 10, maxHeight: 120, fontSize: 16, }, buttons: { flexDirection: 'row', marginLeft: 8, alignItems: 'center', }, iconButton: { padding: 10, borderRadius: 20, marginRight: 8, backgroundColor: '#f0f0f0', }, recording: { backgroundColor: '#ffe0e0', }, sendButton: { backgroundColor: '#007BFF', borderRadius: 20, padding: 10, alignItems: 'center', justifyContent: 'center', }, listeningIndicator: { flexDirection: 'row', alignItems: 'center', padding: 8, marginTop: 8, backgroundColor: '#f8f8f8', borderRadius: 8, }, listeningText: { marginLeft: 8, color: '#333', flex: 1, }, cancelText: { color: '#ff4f4f', fontWeight: 'bold', }, errorText: { color: '#ff4f4f', marginTop: 8, }, }); ``` ### 5. Register the Component Make sure to update your main screen or layout to use the enhanced PromptInput component: ```jsx // screens/PromptScreen.jsx import React from 'react'; import { View, StyleSheet } from 'react-native'; import { PromptInput } from '../components/PromptInput'; export const PromptScreen = () => { const handleSubmit = async (prompt) => { // Your existing logic to handle the prompt submission console.log('Processing prompt:', prompt); // Call your API or Claude Code here }; return ( {/* Other components */} ); }; const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#fff', }, }); ``` ## Testing 1. Test on both iOS and Android devices 2. Verify permissions work correctly 3. Test both text input and voice input 4. Ensure voice input properly populates the text field 5. Confirm error handling works as expected ## Additional Considerations - Add loading indicators during speech processing - Consider implementing a time limit for voice input - Add sound effects or haptic feedback when starting/stopping recording - Consider adding a voice level indicator during recording - Implement a way to handle different languages ## Edge Cases - Handle microphone permission denials gracefully - Consider offline functionality - Handle long speech inputs that might exceed text limits Test Plan: --- app.json | 13 +- components/ChatInput.tsx | 250 +++++++++++++++++++++++----------- hooks/usePermissionChecker.ts | 80 +++++++++++ hooks/useSpeechRecognition.ts | 199 +++++++++++++++++++++++++++ package-lock.json | 177 ++++++++++++++++++++++++ package.json | 2 + scripts/validate-voice.js | 118 ++++++++++++++++ 7 files changed, 757 insertions(+), 82 deletions(-) create mode 100644 hooks/usePermissionChecker.ts create mode 100644 hooks/useSpeechRecognition.ts create mode 100644 scripts/validate-voice.js diff --git a/app.json b/app.json index 43df9db..3550f16 100644 --- a/app.json +++ b/app.json @@ -15,7 +15,9 @@ "adaptiveIcon": { "foregroundImage": "./assets/images/adaptive-icon.png", "backgroundColor": "#ffffff" - } + }, + "permissions": ["RECORD_AUDIO", "android.permission.RECORD_AUDIO"], + "package": "com.claude.codego" }, "web": { "bundler": "metro", @@ -32,7 +34,14 @@ "resizeMode": "contain", "backgroundColor": "#ffffff" } - ] + ], + [ + "expo-speech-recognition", + { + "microphonePermission": "Allow Claude Code Go to access your microphone" + } + ], + "@react-native-voice/voice" ], "experiments": { "typedRoutes": true diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 50ea51e..5959857 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -8,12 +8,14 @@ import { View, Text, Alert, + ActivityIndicator, } from "react-native"; import { Constants } from "@/constants/Constants"; import { useAppContext } from "@/contexts/AppContext"; import { useThemeColor } from "@/hooks/useThemeColor"; import { IconSymbol } from "./ui/IconSymbol"; +import { useSpeechRecognition } from "@/hooks/useSpeechRecognition"; export function ChatInput() { const [text, setText] = useState(""); @@ -32,6 +34,23 @@ export function ChatInput() { const tintColor = useThemeColor({}, "tint"); const errorColor = "#ff6b6b"; // Red color for error indicator + // Voice recognition state + const { + isListening, + speechText, + hasPermission, + error: speechError, + toggleListening, + stopListening, + } = useSpeechRecognition(); + + // Listen for speech text changes + useEffect(() => { + if (speechText) { + setText((prev) => prev + speechText); + } + }, [speechText]); + // Check for errors only when component mounts useEffect(() => { // Update immediately when component mounts @@ -40,6 +59,15 @@ export function ChatInput() { // No interval to avoid too many requests }, []); + // Display any speech recognition errors + useEffect(() => { + if (speechError) { + console.error("Speech recognition error:", speechError); + // Don't show an error alert for every error - just log it + // If needed, we could add an unobtrusive error indicator here + } + }, [speechError]); + const parseJsonResponses = (responseText: string) => { try { const parsed = JSON.parse(responseText); @@ -272,120 +300,182 @@ export function ChatInput() { return ( - - + {pendingErrorCount > 0 && ( + { + updatePendingErrorCount(); + }} + > + + + {pendingErrorCount} error{pendingErrorCount !== 1 ? "s" : ""}{" "} + detected + + + Tap to send a "fix errors" prompt + + + + )} + + + 0 - ? `Reply to Claude (or press send to fix ${pendingErrorCount} error${ - pendingErrorCount > 1 ? "s" : "" - })` - : "Reply to Claude..." - } - placeholderTextColor={pendingErrorCount > 0 ? errorColor : "#999"} - multiline={false} - returnKeyType="send" - onSubmitEditing={handleSend} + multiline /> - - {pendingErrorCount > 0 && ( - { - Alert.alert( - "Clear Errors", - `Clear ${pendingErrorCount} pending error${ - pendingErrorCount > 1 ? "s" : "" - }?`, - [ - { text: "Cancel", style: "cancel" }, - { text: "Clear", onPress: clearPendingErrors }, - ] - ); - }} - style={[styles.errorBadge, { backgroundColor: errorColor }]} - > - {pendingErrorCount} - - )} - 0 ? tintColor : "#cccccc", // Gray out when disabled - }, - ]} - onPress={handleSend} - disabled={!text.trim() && pendingErrorCount === 0} // Enable if there's text OR errors - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - > - - + + {/* Voice input button */} + + + + + {/* Send button */} + + + + + + {/* Voice recording indicator */} + {isListening && ( + + + Listening... + + Cancel + + + )} ); } const styles = StyleSheet.create({ container: { - flexDirection: "row", + padding: 12, + borderTopWidth: 1, + borderTopColor: "#2a2a2a", + }, + errorBanner: { + backgroundColor: "#ff6b6b20", padding: 10, + borderRadius: 8, + marginBottom: 8, + flexDirection: "row", alignItems: "center", - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: "#ccc", // Default color, will be overridden by inline style }, - inputContainer: { + errorIcon: { + marginRight: 8, + }, + errorTextContainer: { flex: 1, - position: "relative", - marginRight: 10, }, - input: { + errorText: { + color: "#ff6b6b", + fontWeight: "bold", + }, + errorSubText: { + color: "#ff6b6b80", + fontSize: 12, + }, + inputRow: { + flexDirection: "row", + alignItems: "flex-end", + }, + inputContainer: { flex: 1, + borderWidth: 1, + borderColor: "#2a2a2a", borderRadius: 20, - paddingVertical: 10, - paddingHorizontal: 16, - maxHeight: 100, - width: "100%", - paddingRight: 40, // Make room for the error badge + paddingLeft: 16, + paddingRight: 8, + paddingTop: 8, + paddingBottom: 8, + marginRight: 6, }, - errorBadge: { - position: "absolute", - right: 8, - top: "50%", - marginTop: -10, - width: 20, - height: 20, - borderRadius: 10, - justifyContent: "center", - alignItems: "center", + input: { + fontSize: 16, + maxHeight: 120, }, - errorBadgeText: { - color: "white", - fontSize: 12, - fontWeight: "bold", + buttonContainer: { + flexDirection: "row", + gap: 8, }, - sendButton: { + iconButton: { width: 40, height: 40, borderRadius: 20, + borderWidth: 1, + borderColor: "#2a2a2a", justifyContent: "center", alignItems: "center", }, + iconButtonActive: { + borderColor: "#ff6b6b", + }, + disabledButton: { + opacity: 0.5, + }, + listeningIndicator: { + flexDirection: "row", + alignItems: "center", + padding: 8, + marginTop: 8, + backgroundColor: "#f8f8f830", + borderRadius: 8, + }, + listeningText: { + marginLeft: 8, + color: "#fff", + flex: 1, + }, + cancelText: { + color: "#ff6b6b", + fontWeight: "bold", + }, }); diff --git a/hooks/usePermissionChecker.ts b/hooks/usePermissionChecker.ts new file mode 100644 index 0000000..ff329a8 --- /dev/null +++ b/hooks/usePermissionChecker.ts @@ -0,0 +1,80 @@ +import { useState, useEffect } from "react"; +import { Platform, PermissionsAndroid } from "react-native"; + +type Permission = typeof PermissionsAndroid.PERMISSIONS.RECORD_AUDIO; + +export const usePermissionChecker = () => { + const [permissionStatus, setPermissionStatus] = useState<{ + [key: string]: boolean | "unavailable"; + }>({}); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + checkPermissions(); + }, []); + + const checkPermissions = async () => { + setIsLoading(true); + + const permissionsToCheck = [ + PermissionsAndroid.PERMISSIONS.RECORD_AUDIO, + // Add other permissions as needed + ]; + + const status: { [key: string]: boolean | "unavailable" } = {}; + + if (Platform.OS === "android") { + try { + for (const permission of permissionsToCheck) { + try { + const result = await PermissionsAndroid.check(permission); + status[permission] = result; + } catch (error) { + console.error(`Error checking permission ${permission}:`, error); + status[permission] = false; + } + } + } catch (error) { + console.error("Error checking permissions:", error); + } + } else { + // iOS doesn't have a direct way to check permissions without requesting them + // So we'll mark these as unavailable + for (const permission of permissionsToCheck) { + status[permission] = "unavailable"; + } + } + + setPermissionStatus(status); + setIsLoading(false); + }; + + const requestPermission = async (permission: Permission) => { + if (Platform.OS !== "android") { + return false; + } + + try { + const granted = await PermissionsAndroid.request(permission); + const result = granted === PermissionsAndroid.RESULTS.GRANTED; + + // Update our status + setPermissionStatus((prev) => ({ + ...prev, + [permission]: result, + })); + + return result; + } catch (error) { + console.error(`Error requesting permission ${permission}:`, error); + return false; + } + }; + + return { + permissionStatus, + isLoading, + checkPermissions, + requestPermission, + }; +}; diff --git a/hooks/useSpeechRecognition.ts b/hooks/useSpeechRecognition.ts new file mode 100644 index 0000000..3ef0eb1 --- /dev/null +++ b/hooks/useSpeechRecognition.ts @@ -0,0 +1,199 @@ +import { useState, useEffect } from "react"; +import Voice, { + SpeechResultsEvent, + SpeechErrorEvent, +} from "@react-native-voice/voice"; +import { Platform, PermissionsAndroid } from "react-native"; + +export const useSpeechRecognition = () => { + const [isListening, setIsListening] = useState(false); + const [speechText, setSpeechText] = useState(""); + const [hasPermission, setHasPermission] = useState(false); + const [error, setError] = useState(null); + const [isInitialized, setIsInitialized] = useState(false); + + useEffect(() => { + const checkPermissions = async () => { + try { + if (Platform.OS === "android") { + const granted = await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.RECORD_AUDIO, + { + title: "Microphone Permission", + message: + "Claude Code Go needs access to your microphone to transcribe speech", + buttonNeutral: "Ask Me Later", + buttonNegative: "Cancel", + buttonPositive: "OK", + } + ); + setHasPermission(granted === PermissionsAndroid.RESULTS.GRANTED); + } else { + // iOS will handle permissions via the native prompt + setHasPermission(true); + } + } catch (err) { + setError("Permission check failed"); + console.error("Permission check error:", err); + } + }; + + // Initialize Voice library + const initializeVoice = async () => { + try { + // Always destroy any existing instance first + await Voice.destroy(); + await Voice.removeAllListeners(); + + // Platform-specific setup + if (Platform.OS === "android") { + console.log("Platform-specific handling for Android"); + // Android requires specific initialization (nothing extra for now) + } else if (Platform.OS === "ios") { + console.log("Platform-specific handling for iOS"); + // iOS-specific setup if needed + } + + Voice.onSpeechStart = () => { + console.log("Speech started"); + }; + + Voice.onSpeechEnd = () => { + console.log("Speech ended"); + setIsListening(false); + }; + + Voice.onSpeechResults = (e: SpeechResultsEvent) => { + console.log("Speech results received", Platform.OS, e.value); + if (e.value && e.value.length > 0) { + setSpeechText(e.value[0]); + } + }; + + Voice.onSpeechError = (e: SpeechErrorEvent) => { + console.error("Speech error:", e); + const errorMessage = + Platform.OS === "android" + ? `Error code: ${e.error?.code}` + : e.error?.message || "Unknown error"; + setError(`Speech recognition error: ${errorMessage}`); + setIsListening(false); + }; + + setIsInitialized(true); + } catch (err) { + console.error("Voice initialization error:", err); + setError("Failed to initialize speech recognition"); + } + }; + + checkPermissions(); + initializeVoice(); + + // Clean up listeners when component unmounts + return () => { + if (isListening) { + stopListening(); + } + Voice.destroy() + .then(() => { + console.log("Voice instance destroyed"); + }) + .catch((e) => { + console.error("Error destroying Voice instance:", e); + }); + }; + }, []); + + const startListening = async () => { + try { + if (!isInitialized) { + setError("Speech recognition not initialized yet"); + return; + } + + if (!hasPermission) { + if (Platform.OS === "android") { + const granted = await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.RECORD_AUDIO + ); + if (granted !== PermissionsAndroid.RESULTS.GRANTED) { + setError("Microphone permission denied"); + return; + } + setHasPermission(true); + } else { + setError("Microphone permission not granted"); + return; + } + } + + setSpeechText(""); + setError(null); + + // Make sure we're not already listening + if (isListening) { + await Voice.stop(); + } + + // Different options based on platform + if (Platform.OS === "android") { + console.log("Starting Android voice recognition"); + try { + await Voice.start("en-US", { + EXTRA_LANGUAGE_MODEL: "LANGUAGE_MODEL_FREE_FORM", + EXTRA_MAX_RESULTS: 5, + EXTRA_PARTIAL_RESULTS: true, + EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS: 500, + EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS: 1500, + EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS: 1000, + }); + setIsListening(true); + } catch (err) { + console.error("Android voice recognition error:", err); + setError(`Android voice error: ${err}`); + } + } else { + console.log("Starting iOS voice recognition"); + await Voice.start("en-US"); + setIsListening(true); + } + } catch (err) { + console.error("Failed to start speech recognition:", err); + setError("Failed to start speech recognition"); + setIsListening(false); + } + }; + + const stopListening = async () => { + try { + if (!isInitialized) { + return; + } + + console.log(`Stopping ${Platform.OS} voice recognition`); + await Voice.stop(); + setIsListening(false); + } catch (err) { + console.error("Error stopping speech recognition:", err); + } + }; + + const toggleListening = async () => { + if (isListening) { + await stopListening(); + } else { + await startListening(); + } + }; + + return { + isListening, + speechText, + hasPermission, + error, + startListening, + stopListening, + toggleListening, + }; +}; diff --git a/package-lock.json b/package-lock.json index cf8c1cc..9c5cf85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@expo/vector-icons": "^14.0.2", "@react-native-async-storage/async-storage": "^2.1.2", "@react-native-community/slider": "^4.5.6", + "@react-native-voice/voice": "^3.2.4", "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "^7.0.14", "@types/body-parser": "^1.19.5", @@ -25,6 +26,7 @@ "expo-haptics": "~14.0.1", "expo-linking": "~7.0.5", "expo-router": "~4.0.20", + "expo-speech-recognition": "^1.1.1", "expo-splash-screen": "~0.29.22", "expo-status-bar": "~2.0.1", "expo-symbols": "~0.2.2", @@ -4152,6 +4154,163 @@ "integrity": "sha512-UhLPFeqx0YfPLrEz8ffT3uqAyXWu6iqFjohNsbp4cOU7hnJwg2RXtDnYHoHMr7MOkZDVdlLMdrSrAuzY6KGqrg==", "license": "MIT" }, + "node_modules/@react-native-voice/voice": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@react-native-voice/voice/-/voice-3.2.4.tgz", + "integrity": "sha512-4i3IpB/W5VxCI7BQZO5Nr2VB0ecx0SLvkln2Gy29cAQKqgBl+1ZsCwUBChwHlPbmja6vA3tp/+2ADQGwB1OhHg==", + "dependencies": { + "@expo/config-plugins": "^2.0.0", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react-native": ">= 0.60.2" + } + }, + "node_modules/@react-native-voice/voice/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/@react-native-voice/voice/node_modules/@expo/config-plugins": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-2.0.4.tgz", + "integrity": "sha512-JGt/X2tFr7H8KBQrKfbGo9hmCubQraMxq5sj3bqDdKmDOLcE1a/EDCP9g0U4GHsa425J8VDIkQUHYz3h3ndEXQ==", + "dependencies": { + "@expo/config-types": "^41.0.0", + "@expo/json-file": "8.2.30", + "@expo/plist": "0.0.13", + "debug": "^4.3.1", + "find-up": "~5.0.0", + "fs-extra": "9.0.0", + "getenv": "^1.0.0", + "glob": "7.1.6", + "resolve-from": "^5.0.0", + "slash": "^3.0.0", + "xcode": "^3.0.1", + "xml2js": "^0.4.23" + } + }, + "node_modules/@react-native-voice/voice/node_modules/@expo/config-types": { + "version": "41.0.0", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-41.0.0.tgz", + "integrity": "sha512-Ax0pHuY5OQaSrzplOkT9DdpdmNzaVDnq9VySb4Ujq7UJ4U4jriLy8u93W98zunOXpcu0iiKubPsqD6lCiq0pig==" + }, + "node_modules/@react-native-voice/voice/node_modules/@expo/json-file": { + "version": "8.2.30", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-8.2.30.tgz", + "integrity": "sha512-vrgGyPEXBoFI5NY70IegusCSoSVIFV3T3ry4tjJg1MFQKTUlR7E0r+8g8XR6qC705rc2PawaZQjqXMAVtV6s2A==", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "fs-extra": "9.0.0", + "json5": "^1.0.1", + "write-file-atomic": "^2.3.0" + } + }, + "node_modules/@react-native-voice/voice/node_modules/@expo/plist": { + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.0.13.tgz", + "integrity": "sha512-zGPSq9OrCn7lWvwLLHLpHUUq2E40KptUFXn53xyZXPViI0k9lbApcR9KlonQZ95C+ELsf0BQ3gRficwK92Ivcw==", + "dependencies": { + "base64-js": "^1.2.3", + "xmlbuilder": "^14.0.0", + "xmldom": "~0.5.0" + } + }, + "node_modules/@react-native-voice/voice/node_modules/fs-extra": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.0.tgz", + "integrity": "sha512-pmEYSk3vYsG/bF651KPUXZ+hvjpgWYw/Gc7W9NFUe3ZVLczKKWIij3IKpOrQcdw4TILtibFslZ0UmR8Vvzig4g==", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^1.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native-voice/voice/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@react-native-voice/voice/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/@react-native-voice/voice/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@react-native-voice/voice/node_modules/jsonfile/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@react-native-voice/voice/node_modules/universalify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz", + "integrity": "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@react-native-voice/voice/node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/@react-native-voice/voice/node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "engines": { + "node": ">=4.0" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.76.9", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.9.tgz", @@ -8396,6 +8555,16 @@ "node": ">=10" } }, + "node_modules/expo-speech-recognition": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/expo-speech-recognition/-/expo-speech-recognition-1.1.1.tgz", + "integrity": "sha512-snQ1rsmQOXUIkkvwW3eCxSZ1FHYQ0lNLeguDTGX0Xg0xOug7zhQDk0/HppAvagfennQBYvQfAHvzUe936aK2LQ==", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-splash-screen": { "version": "0.29.22", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-0.29.22.tgz", @@ -16566,6 +16735,14 @@ "dev": true, "license": "MIT" }, + "node_modules/xmldom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.5.0.tgz", + "integrity": "sha512-Foaj5FXVzgn7xFzsKeNIde9g6aFBxTPi37iwsno8QvApmtg7KYrr+OPyRHcJF7dud2a5nGRBXK3n0dL62Gf7PA==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index b036c69..3404559 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@expo/vector-icons": "^14.0.2", "@react-native-async-storage/async-storage": "^2.1.2", "@react-native-community/slider": "^4.5.6", + "@react-native-voice/voice": "^3.2.4", "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "^7.0.14", "@types/body-parser": "^1.19.5", @@ -35,6 +36,7 @@ "expo-haptics": "~14.0.1", "expo-linking": "~7.0.5", "expo-router": "~4.0.20", + "expo-speech-recognition": "^1.1.1", "expo-splash-screen": "~0.29.22", "expo-status-bar": "~2.0.1", "expo-symbols": "~0.2.2", diff --git a/scripts/validate-voice.js b/scripts/validate-voice.js new file mode 100644 index 0000000..dabce98 --- /dev/null +++ b/scripts/validate-voice.js @@ -0,0 +1,118 @@ +// Validation script for voice recognition +const fs = require("fs"); +const path = require("path"); +const { execSync } = require("child_process"); + +console.log("šŸŽ¤ Voice Recognition Validation Script"); +console.log("====================================="); + +// Check if @react-native-voice/voice is installed +try { + const packageJson = JSON.parse( + fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8") + ); + + console.log("\nšŸ“‹ Checking dependencies..."); + + const voicePackage = packageJson.dependencies["@react-native-voice/voice"]; + if (voicePackage) { + console.log( + "āœ… @react-native-voice/voice found in package.json:", + voicePackage + ); + } else { + console.log("āŒ @react-native-voice/voice not found in package.json"); + console.log(" Run: npx expo install @react-native-voice/voice"); + } + + const speechRecognition = packageJson.dependencies["expo-speech-recognition"]; + if (speechRecognition) { + console.log( + "āœ… expo-speech-recognition found in package.json:", + speechRecognition + ); + } else { + console.log("āŒ expo-speech-recognition not found in package.json"); + console.log(" Run: npx expo install expo-speech-recognition"); + } + + // Check app.json for voice-related configuration + console.log("\nšŸ“‹ Checking app.json configuration..."); + const appJson = JSON.parse( + fs.readFileSync(path.join(__dirname, "..", "app.json"), "utf8") + ); + + // Check Android permissions + const androidPermissions = appJson.expo.android?.permissions || []; + if (androidPermissions.includes("RECORD_AUDIO")) { + console.log("āœ… RECORD_AUDIO permission found in app.json"); + } else { + console.log("āŒ RECORD_AUDIO permission not found in app.json"); + } + + // Check plugins + const plugins = appJson.expo.plugins || []; + let hasVoicePlugin = false; + let hasSpeechRecognitionPlugin = false; + + for (const plugin of plugins) { + if (typeof plugin === "string" && plugin === "@react-native-voice/voice") { + hasVoicePlugin = true; + } else if ( + Array.isArray(plugin) && + plugin[0] === "expo-speech-recognition" + ) { + hasSpeechRecognitionPlugin = true; + } + } + + if (hasVoicePlugin) { + console.log("āœ… @react-native-voice/voice plugin found in app.json"); + } else { + console.log("āŒ @react-native-voice/voice plugin not found in app.json"); + } + + if (hasSpeechRecognitionPlugin) { + console.log("āœ… expo-speech-recognition plugin found in app.json"); + } else { + console.log("āŒ expo-speech-recognition plugin not found in app.json"); + } + + // Check if the Voice hook is properly implemented + console.log("\nšŸ“‹ Checking hook implementation..."); + if ( + fs.existsSync( + path.join(__dirname, "..", "hooks", "useSpeechRecognition.ts") + ) + ) { + console.log("āœ… useSpeechRecognition hook found"); + + const hookContent = fs.readFileSync( + path.join(__dirname, "..", "hooks", "useSpeechRecognition.ts"), + "utf8" + ); + + if (hookContent.includes("PermissionsAndroid.request")) { + console.log("āœ… Android permission handling found in hook"); + } else { + console.log("āŒ Android permission handling missing in hook"); + } + + if (hookContent.includes("Platform.OS === 'android'")) { + console.log("āœ… Platform-specific code found in hook"); + } else { + console.log("āŒ Platform-specific handling missing in hook"); + } + } else { + console.log("āŒ useSpeechRecognition hook not found"); + } + + console.log("\nšŸ“‹ Next steps:"); + console.log("1. Rebuild your app with: npx expo prebuild --clean"); + console.log("2. Run on Android with: npx expo run:android"); + console.log( + "3. If issues persist, check Android device logs with: adb logcat" + ); +} catch (error) { + console.error("Error during validation:", error); +}