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