1import { Picker } from '@react-native-picker/picker';
2import * as Speech from 'expo-speech';
3import * as React from 'react';
4import {
5  Button,
6  Platform,
7  ScrollView,
8  StyleSheet,
9  Text,
10  TouchableOpacity,
11  View,
12} from 'react-native';
13
14import HeadingText from '../components/HeadingText';
15import { Colors } from '../constants';
16
17const EXAMPLES = [
18  { language: 'en', text: 'Hello world' },
19  { language: 'es', text: 'Hola mundo' },
20  { language: 'en', text: 'Charlie Cheever chased a chortling choosy child' },
21  { language: 'en', text: 'Adam Perry ate a pear in pairs in Paris' },
22];
23
24const AmountControlButton: React.FunctionComponent<
25  React.ComponentProps<typeof TouchableOpacity> & {
26    title: string;
27  }
28> = (props) => (
29  <TouchableOpacity
30    disabled={props.disabled}
31    onPress={props.onPress}
32    hitSlop={{ top: 10, bottom: 10, left: 20, right: 20 }}>
33    <Text
34      style={{
35        color: props.disabled ? '#ccc' : Colors.tintColor,
36        fontWeight: 'bold',
37        paddingHorizontal: 5,
38        fontSize: 18,
39      }}>
40      {props.title}
41    </Text>
42  </TouchableOpacity>
43);
44
45interface State {
46  selectedExample: { language: string; text: string };
47  inProgress: boolean;
48  paused: boolean;
49  pitch: number;
50  rate: number;
51  voiceList?: { name: string; identifier: string }[];
52  voice?: string;
53}
54
55// See: https://github.com/expo/expo/pull/10229#discussion_r490961694
56// eslint-disable-next-line @typescript-eslint/ban-types
57export default class TextToSpeechScreen extends React.Component<{}, State> {
58  static navigationOptions = {
59    title: 'Speech',
60  };
61
62  readonly state: State = {
63    selectedExample: EXAMPLES[0],
64    inProgress: false,
65    paused: false,
66    pitch: 1,
67    rate: 0.75,
68  };
69
70  async componentDidMount() {
71    if (Platform.OS === 'ios') {
72      await this._loadAllVoices();
73    }
74  }
75
76  render() {
77    return (
78      <ScrollView style={styles.container}>
79        <HeadingText>Select a phrase</HeadingText>
80
81        <View style={styles.examplesContainer}>{EXAMPLES.map(this._renderExample)}</View>
82
83        <View style={styles.separator} />
84
85        <View style={styles.controlRow}>
86          <Button disabled={this.state.inProgress} onPress={this._speak} title="Speak" />
87
88          <Button disabled={!this.state.inProgress} onPress={this._stop} title="Stop" />
89        </View>
90
91        {Platform.OS === 'ios' && (
92          <View style={styles.controlRow}>
93            <Button
94              disabled={!this.state.inProgress || this.state.paused}
95              onPress={this._pause}
96              title="Pause"
97            />
98            <Button disabled={!this.state.paused} onPress={this._resume} title="Resume" />
99          </View>
100        )}
101
102        {Platform.OS === 'ios' && this.state.voiceList && (
103          <View>
104            <Picker
105              selectedValue={this.state.voice}
106              onValueChange={(voice) => this.setState({ voice })}>
107              {this.state.voiceList.map((voice) => (
108                <Picker.Item key={voice.identifier} label={voice.name} value={voice.identifier} />
109              ))}
110            </Picker>
111          </View>
112        )}
113
114        <Text style={styles.controlText}>Pitch: {this.state.pitch.toFixed(2)}</Text>
115        <View style={styles.controlRow}>
116          <AmountControlButton
117            onPress={this._increasePitch}
118            title="Increase"
119            disabled={this.state.inProgress}
120          />
121
122          <Text>/</Text>
123
124          <AmountControlButton
125            onPress={this._decreasePitch}
126            title="Decrease"
127            disabled={this.state.inProgress}
128          />
129        </View>
130
131        <Text style={styles.controlText}>Rate: {this.state.rate.toFixed(2)}</Text>
132        <View style={styles.controlRow}>
133          <AmountControlButton
134            onPress={this._increaseRate}
135            title="Increase"
136            disabled={this.state.inProgress}
137          />
138
139          <Text>/</Text>
140          <AmountControlButton
141            onPress={this._decreaseRate}
142            title="Decrease"
143            disabled={this.state.inProgress}
144          />
145        </View>
146      </ScrollView>
147    );
148  }
149
150  _speak = () => {
151    const start = () => {
152      this.setState({ inProgress: true });
153    };
154    const complete = () => {
155      this.state.inProgress && this.setState({ inProgress: false, paused: false });
156    };
157
158    Speech.speak(this.state.selectedExample.text, {
159      voice: this.state.voice,
160      language: this.state.selectedExample.language,
161      pitch: this.state.pitch,
162      rate: this.state.rate,
163      onStart: start,
164      onDone: complete,
165      onStopped: complete,
166      onError: complete,
167    });
168  };
169
170  _loadAllVoices = async () => {
171    const availableVoices = await Speech.getAvailableVoicesAsync();
172    this.setState({
173      voiceList: availableVoices,
174      voice: availableVoices[0].identifier,
175    });
176  };
177
178  _stop = () => {
179    Speech.stop();
180  };
181
182  _pause = async () => {
183    await Speech.pause();
184    this.setState({ paused: true });
185  };
186
187  _resume = () => {
188    Speech.resume();
189    this.setState({ paused: false });
190  };
191
192  _increasePitch = () => {
193    this.setState((state) => ({
194      ...state,
195      pitch: state.pitch + 0.1,
196    }));
197  };
198
199  _increaseRate = () => {
200    this.setState((state) => ({
201      ...state,
202      rate: state.rate + 0.1,
203    }));
204  };
205
206  _decreasePitch = () => {
207    this.setState((state) => ({
208      ...state,
209      pitch: state.pitch - 0.1,
210    }));
211  };
212
213  _decreaseRate = () => {
214    this.setState((state) => ({
215      ...state,
216      rate: state.rate - 0.1,
217    }));
218  };
219
220  _renderExample = (example: { language: string; text: string }, i: number) => {
221    const { selectedExample } = this.state;
222    const isSelected = selectedExample === example;
223
224    return (
225      <TouchableOpacity
226        key={i}
227        hitSlop={{ top: 10, bottom: 10, left: 20, right: 20 }}
228        onPress={() => this._selectExample(example)}>
229        <Text style={[styles.exampleText, isSelected && styles.selectedExampleText]}>
230          {example.text} ({example.language})
231        </Text>
232      </TouchableOpacity>
233    );
234  };
235
236  _selectExample = (example: { language: string; text: string }) => {
237    this.setState({ selectedExample: example });
238  };
239}
240
241const styles = StyleSheet.create({
242  container: {
243    flex: 1,
244    padding: 10,
245    paddingBottom: 24,
246  },
247  separator: {
248    height: 1,
249    backgroundColor: '#eee',
250    marginTop: 0,
251    marginBottom: 15,
252  },
253  exampleText: {
254    fontSize: 15,
255    color: '#ccc',
256    marginVertical: 10,
257  },
258  examplesContainer: {
259    paddingTop: 15,
260    paddingBottom: 10,
261    paddingHorizontal: 20,
262  },
263  selectedExampleText: {
264    color: 'black',
265  },
266  resultText: {
267    padding: 20,
268  },
269  errorResultText: {
270    padding: 20,
271    color: 'red',
272  },
273  button: {
274    ...Platform.select({
275      android: {
276        marginBottom: 10,
277      },
278    }),
279  },
280  controlText: {
281    fontSize: 16,
282    fontWeight: '500',
283    marginTop: 5,
284    textAlign: 'center',
285  },
286  controlRow: {
287    flexDirection: 'row',
288    alignItems: 'center',
289    justifyContent: 'center',
290    marginBottom: 10,
291  },
292});
293