├── .watchmanconfig ├── assets ├── icon.png └── splash.png ├── .gitignore ├── babel.config.js ├── .editorconfig ├── config.json.example ├── package.json ├── app.json ├── README.md ├── components ├── FadeInView.js ├── SearchBox.js └── Hits.js └── App.js /.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fostermadeco/voice-search-example-app/HEAD/assets/icon.png -------------------------------------------------------------------------------- /assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fostermadeco/voice-search-example-app/HEAD/assets/splash.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/**/* 2 | .expo/* 3 | npm-debug.* 4 | *.jks 5 | *.p12 6 | *.key 7 | *.mobileprovision 8 | 9 | config.json -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*.js] 5 | indent_style = spaces 6 | indent_size = 4 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true -------------------------------------------------------------------------------- /config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "CLOUD_FUNCTION_URL": "https://example.com", 3 | "ALGOLIA_APP_ID": "latency", 4 | "ALGOLIA_API_KEY": "3d9875e51fbd20c7754e65422f7ce5e1", 5 | "ALGOLIA_INDEX": "instant_search" 6 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "node_modules/expo/AppEntry.js", 3 | "scripts": { 4 | "start": "expo start", 5 | "ios": "expo start --ios", 6 | "eject": "expo eject" 7 | }, 8 | "dependencies": { 9 | "expo": "^38.0.0", 10 | "expo-av": "~8.2.1", 11 | "expo-file-system": "~9.0.1", 12 | "expo-permissions": "~9.0.1", 13 | "react": "16.11.0", 14 | "react-instantsearch-native": "^6.7.0", 15 | "react-native": "https://github.com/expo/react-native/archive/sdk-38.0.2.tar.gz" 16 | }, 17 | "devDependencies": { 18 | "babel-preset-expo": "^8.2.3" 19 | }, 20 | "private": true 21 | } 22 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "Voice Search Example App", 4 | "slug": "voice-search-example-app", 5 | "privacy": "public", 6 | "platforms": [ 7 | "ios", 8 | "android" 9 | ], 10 | "version": "1.0.0", 11 | "orientation": "portrait", 12 | "icon": "./assets/icon.png", 13 | "splash": { 14 | "image": "./assets/splash.png", 15 | "resizeMode": "contain", 16 | "backgroundColor": "#ffffff" 17 | }, 18 | "updates": { 19 | "fallbackToCacheTimeout": 0 20 | }, 21 | "assetBundlePatterns": [ 22 | "**/*" 23 | ], 24 | "ios": { 25 | "supportsTablet": true 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Voice Search Example App 2 | 3 | **NOTE: this only works in iOS not Android** 4 | 5 | React Native app built with Expo that uses a Google Cloud Function and Google Speech API to populate Aloglia Instant Search. Only works on iOS. 6 | 7 | Note: run this on a recent node version > v14.0.5. 8 | 9 | ## Getting Started 10 | 11 | Rename the `config.json.example` file to `config.json` and replace the cloud function property value. The Aloglia config variables are all set to a test Algolia index that returns results for a fake e-commerce site. 12 | 13 | ``` 14 | yarn 15 | yarn start 16 | ``` 17 | 18 | Because this app uses the Audio API, test it using the Expo app on a device and not the iOS simulator. 19 | 20 | See [this repo](https://github.com/fostermadeco/audio-to-text-gcloud) for more info about the Google Cloud Function for the Speech API. -------------------------------------------------------------------------------- /components/FadeInView.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Animated, Text, View } from 'react-native'; 3 | 4 | class FadeInView extends React.Component { 5 | state = { 6 | fadeAnim: new Animated.Value(0), 7 | } 8 | 9 | componentDidMount() { 10 | Animated.loop( 11 | Animated.timing( 12 | this.state.fadeAnim, 13 | { 14 | toValue: 1, 15 | duration: 1400, 16 | }, 17 | ) 18 | ).start(); 19 | } 20 | 21 | render() { 22 | let { fadeAnim } = this.state; 23 | const { children } = this.props; 24 | return ( 25 | 26 | {children} 27 | 28 | ) 29 | } 30 | } 31 | 32 | export default FadeInView; 33 | -------------------------------------------------------------------------------- /components/SearchBox.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | StyleSheet, 4 | View, 5 | FlatList, 6 | Image, 7 | Text, 8 | TextInput, 9 | } from 'react-native'; 10 | import { connectInfiniteHits, connectSearchBox } from 'react-instantsearch-native'; 11 | 12 | class SearchBox extends React.Component { 13 | componentDidUpdate(prevProps) { 14 | if (prevProps.query !== this.props.query) { 15 | this.refineSearch(this.props.query); 16 | } 17 | } 18 | 19 | refineSearch = (text) => { 20 | this.props.refine(text); 21 | }; 22 | 23 | render() { 24 | const { query, onChange } = this.props; 25 | return ( 26 | onChange(text)} 29 | value={query} 30 | placeholder={'Search ...'} 31 | clearButtonMode={'always'} 32 | spellCheck={false} 33 | autoCorrect={false} 34 | autoCapitalize={'none'} 35 | /> 36 | ); 37 | } 38 | } 39 | 40 | const styles = StyleSheet.create({ 41 | input: { 42 | height: 60, 43 | borderWidth: 1, 44 | padding: 10, 45 | marginVertical: 20, 46 | borderRadius: 5, 47 | }, 48 | }); 49 | 50 | export default connectSearchBox(SearchBox); 51 | -------------------------------------------------------------------------------- /components/Hits.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StyleSheet, View, FlatList, Image, Text } from 'react-native'; 3 | import { connectInfiniteHits } from 'react-instantsearch-native'; 4 | 5 | const Hits = connectInfiniteHits(({ hits, hasMore, refine }) => { 6 | const onEndReached = function() { 7 | if (hasMore) { 8 | refine(); 9 | } 10 | }; 11 | 12 | return ( 13 | item.objectID} 17 | renderItem={({ item }) => { 18 | return ( 19 | 20 | 24 | 25 | 26 | {item.name} 27 | 28 | 29 | {item.type} 30 | 31 | 32 | 33 | ); 34 | }} 35 | /> 36 | ); 37 | }); 38 | 39 | export default Hits; 40 | -------------------------------------------------------------------------------- /App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { StyleSheet, Text, View, TouchableOpacity, SafeAreaView, ActivityIndicator } from 'react-native'; 3 | import { FontAwesome } from '@expo/vector-icons'; 4 | import FadeInView from './components/FadeInView'; 5 | import { Audio } from 'expo-av'; 6 | import * as Permissions from 'expo-permissions'; 7 | import * as FileSystem from 'expo-file-system'; 8 | import { InstantSearch } from 'react-instantsearch-native'; 9 | import algoliasearch from 'algoliasearch/lite'; 10 | 11 | import config from './config'; 12 | import SearchBox from './components/SearchBox'; 13 | import Hits from './components/Hits'; 14 | 15 | const searchClient = algoliasearch( 16 | config.ALGOLIA_APP_ID, 17 | config.ALGOLIA_API_KEY, 18 | ); 19 | 20 | const recordingOptions = { 21 | // android not currently in use. Not getting results from speech to text with .m4a 22 | // but parameters are required 23 | android: { 24 | extension: '.m4a', 25 | outputFormat: Audio.RECORDING_OPTION_ANDROID_OUTPUT_FORMAT_MPEG_4, 26 | audioEncoder: Audio.RECORDING_OPTION_ANDROID_AUDIO_ENCODER_AAC, 27 | sampleRate: 44100, 28 | numberOfChannels: 2, 29 | bitRate: 128000, 30 | }, 31 | ios: { 32 | extension: '.wav', 33 | audioQuality: Audio.RECORDING_OPTION_IOS_AUDIO_QUALITY_HIGH, 34 | sampleRate: 44100, 35 | numberOfChannels: 1, 36 | bitRate: 128000, 37 | linearPCMBitDepth: 16, 38 | linearPCMIsBigEndian: false, 39 | linearPCMIsFloat: false, 40 | }, 41 | }; 42 | 43 | const App = () => { 44 | const [recording, setRecording] = useState(null); 45 | const [isFetching, setIsFetching] = useState(false); 46 | const [isRecording, setIsRecording] = useState(false); 47 | const [query, setQuery] = useState(''); 48 | 49 | useEffect(() => { 50 | Permissions.askAsync(Permissions.AUDIO_RECORDING); 51 | }, []); 52 | 53 | const deleteRecordingFile = async () => { 54 | try { 55 | const info = await FileSystem.getInfoAsync(recording.getURI()); 56 | await FileSystem.deleteAsync(info.uri) 57 | } catch(error) { 58 | console.log("There was an error deleting recording file", error); 59 | } 60 | } 61 | 62 | const getTranscription = async () => { 63 | setIsFetching(true); 64 | try { 65 | const info = await FileSystem.getInfoAsync(recording.getURI()); 66 | console.log(`FILE INFO: ${JSON.stringify(info)}`); 67 | const uri = info.uri; 68 | const formData = new FormData(); 69 | formData.append('file', { 70 | uri, 71 | type: 'audio/x-wav', 72 | name: 'speech2text' 73 | }); 74 | const response = await fetch(config.CLOUD_FUNCTION_URL, { 75 | method: 'POST', 76 | body: formData 77 | }); 78 | const data = await response.json(); 79 | console.log(data); 80 | setQuery(data.transcript); 81 | } catch(error) { 82 | console.log('There was an error reading file', error); 83 | stopRecording(); 84 | resetRecording(); 85 | } 86 | setIsFetching(false); 87 | } 88 | 89 | const startRecording = async () => { 90 | const { status } = await Permissions.getAsync(Permissions.AUDIO_RECORDING); 91 | if (status !== 'granted') return; 92 | 93 | setIsRecording(true); 94 | await Audio.setAudioModeAsync({ 95 | allowsRecordingIOS: true, 96 | interruptionModeIOS: Audio.INTERRUPTION_MODE_IOS_DO_NOT_MIX, 97 | playsInSilentModeIOS: true, 98 | shouldDuckAndroid: true, 99 | interruptionModeAndroid: Audio.INTERRUPTION_MODE_ANDROID_DO_NOT_MIX, 100 | playThroughEarpieceAndroid: true, 101 | }); 102 | const recording = new Audio.Recording(); 103 | 104 | try { 105 | await recording.prepareToRecordAsync(recordingOptions); 106 | await recording.startAsync(); 107 | } catch (error) { 108 | console.log(error); 109 | stopRecording(); 110 | } 111 | 112 | setRecording(recording); 113 | } 114 | 115 | const stopRecording = async () => { 116 | setIsRecording(false); 117 | try { 118 | await recording.stopAndUnloadAsync(); 119 | } catch (error) { 120 | // Do nothing -- we are already unloaded. 121 | } 122 | } 123 | 124 | const resetRecording = () => { 125 | deleteRecordingFile(); 126 | setRecording(null); 127 | }; 128 | 129 | const handleOnPressIn = () => { 130 | startRecording(); 131 | }; 132 | 133 | const handleOnPressOut = () => { 134 | stopRecording(); 135 | getTranscription(); 136 | }; 137 | 138 | const handleQueryChange = (query) => { 139 | setQuery(query); 140 | }; 141 | 142 | return ( 143 | 144 | 145 | {isRecording && 146 | 147 | 148 | 149 | } 150 | {!isRecording && 151 | 152 | } 153 | Voice Search 154 | 159 | {isFetching && } 160 | {!isFetching && Hold for Voice Search} 161 | 162 | 163 | 164 | 168 | 169 | 170 | 171 | 172 | 173 | ); 174 | }; 175 | 176 | const styles = StyleSheet.create({ 177 | container: { 178 | marginTop: 40, 179 | backgroundColor: '#fff', 180 | alignItems: 'center', 181 | }, 182 | button: { 183 | backgroundColor: '#48C9B0', 184 | paddingVertical: 20, 185 | width: '90%', 186 | alignItems: 'center', 187 | borderRadius: 5, 188 | marginTop: 20, 189 | } 190 | }); 191 | 192 | export default App; 193 | --------------------------------------------------------------------------------