├── .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 |
--------------------------------------------------------------------------------