| /* |
| * 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.media.AudioManager; |
| import android.os.Handler; |
| import android.speech.tts.TextToSpeech; |
| import android.speech.tts.UtteranceProgressListener; |
| import android.util.Log; |
| import android.util.Pair; |
| |
| import androidx.annotation.VisibleForTesting; |
| |
| 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. |
| * <p> |
| * 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. |
| * <p> |
| * 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); |
| |
| /** |
| * Called when request to get audio focus failed. This happens before the requested TTS |
| * is played. |
| */ |
| void onAudioFocusFailed(); |
| } |
| |
| 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 AudioManager mAudioManager; |
| private final AudioManager.OnAudioFocusChangeListener mNoOpAFChangeListener = (f) -> {}; |
| private final long mShutdownDelayMillis; |
| private TTSEngine mTTSEngine; |
| private int mInitStatus; |
| private SpeechRequest mPendingRequest; |
| private final Map<String, BatchListener> 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; |
| mAudioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE); |
| 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}. |
| * Will request audio focus first, failure will trigger onAudioFocusFailed in 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<CharSequence> textToSpeak, Listener listener) { |
| if (textToSpeak == null || textToSpeak.isEmpty()) { |
| throw new IllegalArgumentException("Empty/null textToSpeak"); |
| } |
| int result = mAudioManager.requestAudioFocus(mNoOpAFChangeListener, |
| getStream(), |
| AudioManager.AUDIOFOCUS_GAIN_TRANSIENT); |
| if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { |
| listener.onAudioFocusFailed(); |
| return; |
| } |
| initMaybeAndKeepAlive(); |
| |
| // Check if its still initializing. |
| if (mInitStatus == TextToSpeech.STOPPED) { |
| // Squash any already queued request. |
| if (mPendingRequest != null) { |
| onTtsStopped(mPendingRequest.mListener, false /* error */); |
| } |
| mPendingRequest = new SpeechRequest(textToSpeak, listener); |
| } else { |
| playInternal(textToSpeak, listener); |
| } |
| } |
| |
| public void requestStop() { |
| mTTSEngine.stop(); |
| currentBatchId = null; |
| } |
| |
| public boolean isSpeaking() { |
| return mTTSEngine.isSpeaking(); |
| } |
| |
| // wrap call back to listener.onTTSStopped with adandonAudioFocus. |
| private void onTtsStopped(Listener listener, boolean error) { |
| mAudioManager.abandonAudioFocus(mNoOpAFChangeListener); |
| mHandler.post(() -> listener.onTTSStopped(error)); |
| } |
| |
| private void playInternal(List<CharSequence> textToSpeak, Listener listener) { |
| if (mInitStatus == TextToSpeech.ERROR) { |
| Log.e(TAG, "TTS setup failed!"); |
| onTtsStopped(listener, 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!"); |
| onTtsStopped(listener, 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(); |
| } |
| |
| public int getStream() { |
| return mTTSEngine.getStream(); |
| } |
| |
| 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<String, Integer> 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<BatchListener, Pair<String, Integer>> callback) { |
| mHandler.post(() -> { |
| Pair<String, Integer> 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<String, Integer> parsedId) { |
| if (!mBatchStarted) { |
| mBatchStarted = true; |
| mListener.onTTSStarted(); |
| } |
| } |
| |
| // Issues Listener.onTTSStopped when last item of batch finishes. |
| void onDone(Pair<String, Integer> 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<String, Integer> 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<String, Integer> 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<String, Integer> parsedId, boolean error) { |
| onTtsStopped(mListener, error); |
| mListeners.remove(parsedId.first); |
| } |
| } |
| |
| private static class SpeechRequest { |
| final List<CharSequence> mTextToSpeak; |
| final Listener mListener; |
| |
| SpeechRequest(List<CharSequence> textToSpeak, Listener listener) { |
| mTextToSpeak = textToSpeak; |
| mListener = listener; |
| } |
| } |
| } |