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