• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.car.messenger.tts;
18 
19 import android.content.Context;
20 import android.os.Handler;
21 import android.speech.tts.TextToSpeech;
22 import android.speech.tts.UtteranceProgressListener;
23 import android.support.annotation.VisibleForTesting;
24 import android.util.Log;
25 import android.util.Pair;
26 
27 import java.util.HashMap;
28 import java.util.List;
29 import java.util.Map;
30 import java.util.function.BiConsumer;
31 
32 /**
33  * Component that wraps platform TTS engine and supports play-out of batches of text.
34  * <p>
35  * It takes care of setting up TTS Engine when text is played out and shutting it down after an idle
36  * period with no play-out. This is desirable since owning app is long-lived and TTS Engine brings
37  * up another service-process.
38  * <p>
39  * As batch of text is played-out, it issues callbacks on {@link Listener} provided with the batch.
40  */
41 public class TTSHelper {
42     /**
43      * Listener interface used by clients to be notified as batch of text is played out.
44      */
45     public interface Listener {
46         /**
47          * Called when play-out starts for batch. May never get called if batch has errors or
48          * interruptions.
49          */
onTTSStarted()50          void onTTSStarted();
51 
52         /**
53          * Called when play-out ends for batch.
54          *
55          * @param error Whether play-out ended due to an error or not. Not if it was aborted, its
56          *              not considered an error.
57          */
onTTSStopped(boolean error)58         void onTTSStopped(boolean error);
59     }
60 
61     private static final String TAG = "Messenger.TTSHelper";
62     private static boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
63 
64     private static final char UTTERANCE_ID_SEPARATOR = ';';
65     private static final long DEFAULT_SHUTDOWN_DELAY_MILLIS = 60 * 1000;
66 
67     private final Handler mHandler = new Handler();
68     private final Context mContext;
69     private final long mShutdownDelayMillis;
70     private TTSEngine mTTSEngine;
71     private int mInitStatus;
72     private SpeechRequest mPendingRequest;
73     private final Map<String, BatchListener> mListeners = new HashMap<>();
74     private String currentBatchId;
75 
76     /**
77      * Construct with default settings.
78      */
TTSHelper(Context context)79     public TTSHelper(Context context) {
80         this(context, new AndroidTTSEngine(), DEFAULT_SHUTDOWN_DELAY_MILLIS);
81     }
82 
83     @VisibleForTesting
TTSHelper(Context context, TTSEngine ttsEngine, long shutdownDelayMillis)84     TTSHelper(Context context, TTSEngine ttsEngine, long shutdownDelayMillis) {
85         mContext = context;
86         mTTSEngine = ttsEngine;
87         mShutdownDelayMillis = shutdownDelayMillis;
88         // OnInitListener will only set to SUCCESS/ERROR. So we initialize to STOPPED.
89         mInitStatus = TextToSpeech.STOPPED;
90     }
91 
initMaybeAndKeepAlive()92     private void initMaybeAndKeepAlive() {
93         if (!mTTSEngine.isInitialized()) {
94             if (DBG) {
95                 Log.d(TAG, "Initializing TTS Engine");
96             }
97             mTTSEngine.initialize(mContext, this::handleInitCompleted);
98             mTTSEngine.setOnUtteranceProgressListener(mProgressListener);
99         }
100         // Since we're handling a request, delay engine shutdown.
101         mHandler.removeCallbacks(mMaybeShutdownRunnable);
102         mHandler.postDelayed(mMaybeShutdownRunnable, mShutdownDelayMillis);
103     }
104 
handleInitCompleted(int initStatus)105     private void handleInitCompleted(int initStatus) {
106         if (DBG) {
107             Log.d(TAG, "init completed: " + initStatus);
108         }
109         mInitStatus = initStatus;
110         if (mPendingRequest != null) {
111             playInternal(mPendingRequest.mTextToSpeak, mPendingRequest.mListener);
112             mPendingRequest = null;
113         }
114     }
115 
116     private final Runnable mMaybeShutdownRunnable = new Runnable() {
117         @Override
118         public void run() {
119             if (mListeners.isEmpty() || mPendingRequest == null) {
120                 shutdownEngine();
121             } else {
122                 mHandler.postDelayed(this, mShutdownDelayMillis);
123             }
124         }
125     };
126 
127     /**
128      * Plays out given batch of text. If engine is not active, it is setup and the request is stored
129      * until then. Only one batch is supported at a time; If a previous batch is waiting engine
130      * setup, that batch is dropped. If a previous batch is playing, the play-out is stopped and
131      * next one is passed to the TTS Engine. Callbacks are issued on the provided {@code listener}.
132      *
133      * NOTE: Underlying engine may have limit on length of text in each element of the batch; it
134      * will reject anything longer. See {@link TextToSpeech#getMaxSpeechInputLength()}.
135      *
136      * @param textToSpeak Batch of text to play-out.
137      * @param listener Observer that will receive callbacks about play-out progress.
138      */
requestPlay(List<CharSequence> textToSpeak, Listener listener)139     public void requestPlay(List<CharSequence> textToSpeak, Listener listener) {
140         if (textToSpeak == null || textToSpeak.size() < 1) {
141             throw new IllegalArgumentException("Empty/null textToSpeak");
142         }
143         initMaybeAndKeepAlive();
144 
145         // Check if its still initializing.
146         if (mInitStatus == TextToSpeech.STOPPED) {
147             // Squash any already queued request.
148             if (mPendingRequest != null) {
149                 mPendingRequest.mListener.onTTSStopped(false /* error */);
150             }
151             mPendingRequest = new SpeechRequest(textToSpeak, listener);
152         } else {
153             playInternal(textToSpeak, listener);
154         }
155     }
156 
requestStop()157     public void requestStop() {
158         mTTSEngine.stop();
159         currentBatchId = null;
160     }
161 
isSpeaking()162     public boolean isSpeaking() {
163         return mTTSEngine.isSpeaking();
164     }
165 
playInternal(List<CharSequence> textToSpeak, Listener listener)166     private void playInternal(List<CharSequence> textToSpeak, Listener listener) {
167         if (mInitStatus == TextToSpeech.ERROR) {
168             Log.e(TAG, "TTS setup failed!");
169             mHandler.post(() -> listener.onTTSStopped(true /* error */));
170             return;
171         }
172 
173         // Abort anything currently playing and flushes queue.
174         mTTSEngine.stop();
175 
176         // Queue up new batch. We assign id's = "batchId:index" where index decrements from
177         // batchSize - 1 down to 0. If queueing fails, we abort the whole batch.
178         currentBatchId = Integer.toString(listener.hashCode());
179         int index = textToSpeak.size() - 1;
180         for (CharSequence text : textToSpeak) {
181             String utteranceId =
182                     String.format("%s%c%d", currentBatchId, UTTERANCE_ID_SEPARATOR, index);
183             if (DBG) {
184                 Log.d(TAG, String.format("Queueing tts: '%s' [%s]", text, utteranceId));
185             }
186             if (mTTSEngine.speak(text, TextToSpeech.QUEUE_ADD, null, utteranceId)
187                     != TextToSpeech.SUCCESS) {
188                 mTTSEngine.stop();
189                 currentBatchId = null;
190                 Log.e(TAG, "Queuing text failed!");
191                 mHandler.post(() -> listener.onTTSStopped(true /* error */));
192                 return;
193             }
194             index--;
195         }
196         // Register BatchListener for entire batch. Will invoke callbacks on Listener as batch
197         // progresses.
198         mListeners.put(currentBatchId, new BatchListener(listener));
199     }
200 
201     /**
202      * Releases resources and shuts down TTS Engine.
203      */
cleanup()204     public void cleanup() {
205         mHandler.removeCallbacksAndMessages(null /* token */);
206         shutdownEngine();
207     }
208 
shutdownEngine()209     private void shutdownEngine() {
210         if (mTTSEngine.isInitialized()) {
211             if (DBG) {
212                 Log.d(TAG, "Shutting down TTS Engine");
213             }
214             mTTSEngine.stop();
215             mTTSEngine.shutdown();
216             mInitStatus = TextToSpeech.STOPPED;
217         }
218     }
219 
parse(String utteranceId)220     private static Pair<String, Integer> parse(String utteranceId) {
221         int separatorIndex = utteranceId.indexOf(UTTERANCE_ID_SEPARATOR);
222         String batchId = utteranceId.substring(0, separatorIndex);
223         int index = Integer.parseInt(utteranceId.substring(separatorIndex + 1));
224         return Pair.create(batchId, index);
225     }
226 
227     // Handles all callbacks from TTSEngine. Possible order of callbacks:
228     // - onStart, onDone: successful play-out.
229     // - onStart, onStop: play-out starts, but interrupted.
230     // - onStart, onError: play-out starts and fails.
231     // - onStop: play-out never starts, but aborted.
232     // - onError: play-out never starts, but fails.
233     // Since the callbacks arrive on other threads, they are dispatched onto mHandler where the
234     // appropriate BatchListener is invoked.
235     private final UtteranceProgressListener mProgressListener = new UtteranceProgressListener() {
236         private void safeInvokeAsync(String utteranceId,
237                 BiConsumer<BatchListener, Pair<String, Integer>> callback) {
238             mHandler.post(() -> {
239                 Pair<String, Integer> parsedId = parse(utteranceId);
240                 BatchListener listener = mListeners.get(parsedId.first);
241                 if (listener != null) {
242                     callback.accept(listener, parsedId);
243                 } else {
244                     if (DBG) {
245                         Log.d(TAG, "Missing batch listener: " + utteranceId);
246                     }
247                 }
248             });
249         }
250 
251         @Override
252         public void onStart(String utteranceId) {
253             if (DBG) {
254                 Log.d(TAG, "TTS onStart: " + utteranceId);
255             }
256             safeInvokeAsync(utteranceId, BatchListener::onStart);
257         }
258 
259         @Override
260         public void onDone(String utteranceId) {
261             if (DBG) {
262                 Log.d(TAG, "TTS onDone: " + utteranceId);
263             }
264             safeInvokeAsync(utteranceId, BatchListener::onDone);
265         }
266 
267         @Override
268         public void onStop(String utteranceId, boolean interrupted) {
269             if (DBG) {
270                 Log.d(TAG, "TTS onStop: " + utteranceId);
271             }
272             safeInvokeAsync(utteranceId, BatchListener::onStop);
273         }
274 
275         @Override
276         public void onError(String utteranceId) {
277             if (DBG) {
278                 Log.d(TAG, "TTS onError: " + utteranceId);
279             }
280             safeInvokeAsync(utteranceId, BatchListener::onError);
281         }
282     };
283 
284     /**
285      * Handles callbacks for a single batch of TTS text and issues callbacks on wrapped
286      * {@link Listener} that client is listening on.
287      */
288     private class BatchListener {
289         private final Listener mListener;
290         private boolean mBatchStarted = false;
291 
BatchListener(Listener listener)292         BatchListener(Listener listener) {
293             mListener = listener;
294         }
295 
296         // Issues Listener.onTTSStarted when first item of batch starts.
onStart(Pair<String, Integer> parsedId)297         void onStart(Pair<String, Integer> parsedId) {
298             if (!mBatchStarted) {
299                 mBatchStarted = true;
300                 mListener.onTTSStarted();
301             }
302         }
303 
304         // Issues Listener.onTTSStopped when last item of batch finishes.
onDone(Pair<String, Integer> parsedId)305         void onDone(Pair<String, Integer> parsedId) {
306             if (parsedId.second == 0) {
307                 handleBatchFinished(parsedId, false /* error */);
308             }
309         }
310 
311         // If any item of batch fails, abort the batch and issue Listener.onTTSStopped.
onError(Pair<String, Integer> parsedId)312         void onError(Pair<String, Integer> parsedId) {
313             if (parsedId.first.equals(currentBatchId)) {
314                 mTTSEngine.stop();
315             }
316             handleBatchFinished(parsedId, true /* error */);
317         }
318 
319         // If any item of batch is preempted (rest should also be), issue Listener.onTTSStopped.
onStop(Pair<String, Integer> parsedId)320         void onStop(Pair<String, Integer> parsedId) {
321             handleBatchFinished(parsedId, false /* error */);
322         }
323 
324         // Handles terminal callbacks for the batch. We invoke stopped and remove ourselves.
325         // No further callbacks will be handled for the batch.
handleBatchFinished(Pair<String, Integer> parsedId, boolean error)326         private void handleBatchFinished(Pair<String, Integer> parsedId, boolean error) {
327             mListener.onTTSStopped(error);
328             mListeners.remove(parsedId.first);
329         }
330     }
331 
332     private static class SpeechRequest {
333         final List<CharSequence> mTextToSpeak;
334         final Listener mListener;
335 
SpeechRequest(List<CharSequence> textToSpeak, Listener listener)336         SpeechRequest(List<CharSequence> textToSpeak, Listener listener) {
337             mTextToSpeak = textToSpeak;
338             mListener = listener;
339         }
340     }
341 }
342