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