/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.car.messenger.tts;
import android.content.Context;
import android.os.Handler;
import android.speech.tts.TextToSpeech;
import android.speech.tts.UtteranceProgressListener;
import android.support.annotation.VisibleForTesting;
import android.util.Log;
import android.util.Pair;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
/**
* Component that wraps platform TTS engine and supports play-out of batches of text.
*
* It takes care of setting up TTS Engine when text is played out and shutting it down after an idle
* period with no play-out. This is desirable since owning app is long-lived and TTS Engine brings
* up another service-process.
*
* As batch of text is played-out, it issues callbacks on {@link Listener} provided with the batch.
*/
public class TTSHelper {
/**
* Listener interface used by clients to be notified as batch of text is played out.
*/
public interface Listener {
/**
* Called when play-out starts for batch. May never get called if batch has errors or
* interruptions.
*/
void onTTSStarted();
/**
* Called when play-out ends for batch.
*
* @param error Whether play-out ended due to an error or not. Not if it was aborted, its
* not considered an error.
*/
void onTTSStopped(boolean error);
}
private static final String TAG = "Messenger.TTSHelper";
private static boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
private static final char UTTERANCE_ID_SEPARATOR = ';';
private static final long DEFAULT_SHUTDOWN_DELAY_MILLIS = 60 * 1000;
private final Handler mHandler = new Handler();
private final Context mContext;
private final long mShutdownDelayMillis;
private TTSEngine mTTSEngine;
private int mInitStatus;
private SpeechRequest mPendingRequest;
private final Map mListeners = new HashMap<>();
private String currentBatchId;
/**
* Construct with default settings.
*/
public TTSHelper(Context context) {
this(context, new AndroidTTSEngine(), DEFAULT_SHUTDOWN_DELAY_MILLIS);
}
@VisibleForTesting
TTSHelper(Context context, TTSEngine ttsEngine, long shutdownDelayMillis) {
mContext = context;
mTTSEngine = ttsEngine;
mShutdownDelayMillis = shutdownDelayMillis;
// OnInitListener will only set to SUCCESS/ERROR. So we initialize to STOPPED.
mInitStatus = TextToSpeech.STOPPED;
}
private void initMaybeAndKeepAlive() {
if (!mTTSEngine.isInitialized()) {
if (DBG) {
Log.d(TAG, "Initializing TTS Engine");
}
mTTSEngine.initialize(mContext, this::handleInitCompleted);
mTTSEngine.setOnUtteranceProgressListener(mProgressListener);
}
// Since we're handling a request, delay engine shutdown.
mHandler.removeCallbacks(mMaybeShutdownRunnable);
mHandler.postDelayed(mMaybeShutdownRunnable, mShutdownDelayMillis);
}
private void handleInitCompleted(int initStatus) {
if (DBG) {
Log.d(TAG, "init completed: " + initStatus);
}
mInitStatus = initStatus;
if (mPendingRequest != null) {
playInternal(mPendingRequest.mTextToSpeak, mPendingRequest.mListener);
mPendingRequest = null;
}
}
private final Runnable mMaybeShutdownRunnable = new Runnable() {
@Override
public void run() {
if (mListeners.isEmpty() || mPendingRequest == null) {
shutdownEngine();
} else {
mHandler.postDelayed(this, mShutdownDelayMillis);
}
}
};
/**
* Plays out given batch of text. If engine is not active, it is setup and the request is stored
* until then. Only one batch is supported at a time; If a previous batch is waiting engine
* setup, that batch is dropped. If a previous batch is playing, the play-out is stopped and
* next one is passed to the TTS Engine. Callbacks are issued on the provided {@code listener}.
*
* NOTE: Underlying engine may have limit on length of text in each element of the batch; it
* will reject anything longer. See {@link TextToSpeech#getMaxSpeechInputLength()}.
*
* @param textToSpeak Batch of text to play-out.
* @param listener Observer that will receive callbacks about play-out progress.
*/
public void requestPlay(List textToSpeak, Listener listener) {
if (textToSpeak == null || textToSpeak.size() < 1) {
throw new IllegalArgumentException("Empty/null textToSpeak");
}
initMaybeAndKeepAlive();
// Check if its still initializing.
if (mInitStatus == TextToSpeech.STOPPED) {
// Squash any already queued request.
if (mPendingRequest != null) {
mPendingRequest.mListener.onTTSStopped(false /* error */);
}
mPendingRequest = new SpeechRequest(textToSpeak, listener);
} else {
playInternal(textToSpeak, listener);
}
}
public void requestStop() {
mTTSEngine.stop();
currentBatchId = null;
}
public boolean isSpeaking() {
return mTTSEngine.isSpeaking();
}
private void playInternal(List textToSpeak, Listener listener) {
if (mInitStatus == TextToSpeech.ERROR) {
Log.e(TAG, "TTS setup failed!");
mHandler.post(() -> listener.onTTSStopped(true /* error */));
return;
}
// Abort anything currently playing and flushes queue.
mTTSEngine.stop();
// Queue up new batch. We assign id's = "batchId:index" where index decrements from
// batchSize - 1 down to 0. If queueing fails, we abort the whole batch.
currentBatchId = Integer.toString(listener.hashCode());
int index = textToSpeak.size() - 1;
for (CharSequence text : textToSpeak) {
String utteranceId =
String.format("%s%c%d", currentBatchId, UTTERANCE_ID_SEPARATOR, index);
if (DBG) {
Log.d(TAG, String.format("Queueing tts: '%s' [%s]", text, utteranceId));
}
if (mTTSEngine.speak(text, TextToSpeech.QUEUE_ADD, null, utteranceId)
!= TextToSpeech.SUCCESS) {
mTTSEngine.stop();
currentBatchId = null;
Log.e(TAG, "Queuing text failed!");
mHandler.post(() -> listener.onTTSStopped(true /* error */));
return;
}
index--;
}
// Register BatchListener for entire batch. Will invoke callbacks on Listener as batch
// progresses.
mListeners.put(currentBatchId, new BatchListener(listener));
}
/**
* Releases resources and shuts down TTS Engine.
*/
public void cleanup() {
mHandler.removeCallbacksAndMessages(null /* token */);
shutdownEngine();
}
private void shutdownEngine() {
if (mTTSEngine.isInitialized()) {
if (DBG) {
Log.d(TAG, "Shutting down TTS Engine");
}
mTTSEngine.stop();
mTTSEngine.shutdown();
mInitStatus = TextToSpeech.STOPPED;
}
}
private static Pair parse(String utteranceId) {
int separatorIndex = utteranceId.indexOf(UTTERANCE_ID_SEPARATOR);
String batchId = utteranceId.substring(0, separatorIndex);
int index = Integer.parseInt(utteranceId.substring(separatorIndex + 1));
return Pair.create(batchId, index);
}
// Handles all callbacks from TTSEngine. Possible order of callbacks:
// - onStart, onDone: successful play-out.
// - onStart, onStop: play-out starts, but interrupted.
// - onStart, onError: play-out starts and fails.
// - onStop: play-out never starts, but aborted.
// - onError: play-out never starts, but fails.
// Since the callbacks arrive on other threads, they are dispatched onto mHandler where the
// appropriate BatchListener is invoked.
private final UtteranceProgressListener mProgressListener = new UtteranceProgressListener() {
private void safeInvokeAsync(String utteranceId,
BiConsumer> callback) {
mHandler.post(() -> {
Pair parsedId = parse(utteranceId);
BatchListener listener = mListeners.get(parsedId.first);
if (listener != null) {
callback.accept(listener, parsedId);
} else {
if (DBG) {
Log.d(TAG, "Missing batch listener: " + utteranceId);
}
}
});
}
@Override
public void onStart(String utteranceId) {
if (DBG) {
Log.d(TAG, "TTS onStart: " + utteranceId);
}
safeInvokeAsync(utteranceId, BatchListener::onStart);
}
@Override
public void onDone(String utteranceId) {
if (DBG) {
Log.d(TAG, "TTS onDone: " + utteranceId);
}
safeInvokeAsync(utteranceId, BatchListener::onDone);
}
@Override
public void onStop(String utteranceId, boolean interrupted) {
if (DBG) {
Log.d(TAG, "TTS onStop: " + utteranceId);
}
safeInvokeAsync(utteranceId, BatchListener::onStop);
}
@Override
public void onError(String utteranceId) {
if (DBG) {
Log.d(TAG, "TTS onError: " + utteranceId);
}
safeInvokeAsync(utteranceId, BatchListener::onError);
}
};
/**
* Handles callbacks for a single batch of TTS text and issues callbacks on wrapped
* {@link Listener} that client is listening on.
*/
private class BatchListener {
private final Listener mListener;
private boolean mBatchStarted = false;
BatchListener(Listener listener) {
mListener = listener;
}
// Issues Listener.onTTSStarted when first item of batch starts.
void onStart(Pair parsedId) {
if (!mBatchStarted) {
mBatchStarted = true;
mListener.onTTSStarted();
}
}
// Issues Listener.onTTSStopped when last item of batch finishes.
void onDone(Pair parsedId) {
if (parsedId.second == 0) {
handleBatchFinished(parsedId, false /* error */);
}
}
// If any item of batch fails, abort the batch and issue Listener.onTTSStopped.
void onError(Pair parsedId) {
if (parsedId.first.equals(currentBatchId)) {
mTTSEngine.stop();
}
handleBatchFinished(parsedId, true /* error */);
}
// If any item of batch is preempted (rest should also be), issue Listener.onTTSStopped.
void onStop(Pair parsedId) {
handleBatchFinished(parsedId, false /* error */);
}
// Handles terminal callbacks for the batch. We invoke stopped and remove ourselves.
// No further callbacks will be handled for the batch.
private void handleBatchFinished(Pair parsedId, boolean error) {
mListener.onTTSStopped(error);
mListeners.remove(parsedId.first);
}
}
private static class SpeechRequest {
final List mTextToSpeak;
final Listener mListener;
SpeechRequest(List textToSpeak, Listener listener) {
mTextToSpeak = textToSpeak;
mListener = listener;
}
}
}