Skip to content

Instantly share code, notes, and snippets.

@LukasKnuth
Created June 14, 2014 14:59
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save LukasKnuth/0c0d17b343483d25aca2 to your computer and use it in GitHub Desktop.
Save LukasKnuth/0c0d17b343483d25aca2 to your computer and use it in GitHub Desktop.
A TTS (Text-To-Speech) wrapper with extended functionality, designed to be robust and easy to use.
import android.annotation.TargetApi;
import android.content.Context;
import android.media.AudioManager;
import android.os.Build;
import android.speech.tts.TextToSpeech;
import android.speech.tts.UtteranceProgressListener;
import android.util.Log;
import java.util.HashMap;
/**
* A TTS (Text-To-Speech) wrapper with extended functionality, designed to be robust and easy to use.
*
* @author Lukas Knuth
* @version 1.0
*/
public final class TTS implements TextToSpeech.OnUtteranceCompletedListener {
private TextToSpeech tts;
private final AudioManager am;
private int speech_count = 0;
private final HashMap<String, String> tts_params = new HashMap<String, String>();
private final AudioManager.OnAudioFocusChangeListener afl = new AudioManager.OnAudioFocusChangeListener() {
@Override
public void onAudioFocusChange(int focusChange) {
// TODO React to audio-focus changes here!
}
};
public interface InitCallback{
/**
* Initialisation was successful, work with the TTS.
*/
public void initSuccess(TTS tts);
/**
* There was an error while initialising the engine.
* @param reason error-number, as returned by {@link android.speech.tts.TextToSpeech.OnInitListener#onInit(int)}.
*/
public void initFail(int reason);
}
/**
* Creates a new Text-To-Speech engine.
* @param callback will be called, once the engine is initialised and ready for usage.
* @throws java.lang.IllegalStateException you can't initialize this object with a
* {@code context} from an Activity that has not yet completed it's {@link android.app.Activity#onCreate(android.os.Bundle)}
* method. Maybe do it in {@link android.app.Activity#onStart()} instead?
*/
public TTS(Context context, final InitCallback callback){
// TTS Parameters:
this.tts_params.put(TextToSpeech.Engine.KEY_PARAM_STREAM, String.valueOf(AudioManager.STREAM_MUSIC));
this.tts_params.put(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, "PLACEHOLDER"); // We only need this so the utterance-complete listener gets called...
// Initialise TTS:
am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
tts = new TextToSpeech(context, new TextToSpeech.OnInitListener() {
@Override
@SuppressWarnings("deprecation")
@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1)
public void onInit(int status) {
if (status == TextToSpeech.SUCCESS){
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1){
tts.setOnUtteranceProgressListener(new UtteranceProgressListener() {
@Override public void onStart(String s) {}
@Override public void onError(String s) {}
@Override
public void onDone(String utterance_id) {
TTS.this.onUtteranceCompleted(utterance_id);
}
});
} else {
tts.setOnUtteranceCompletedListener(TTS.this);
}
callback.initSuccess(TTS.this);
} else if (status == TextToSpeech.ERROR) {
callback.initFail(status);
}
}
});
}
/**
* Shutdown the TTS-Engine.
* @param stop_immediately whether the TTS-engine should let any previously queued speeches
* finish, or stop them (and the engine) immediatly.
*/
public void shutdown(boolean stop_immediately){
if (stop_immediately){
tts.stop();
}
tts.shutdown();
}
/**
* <p>Queue a text for reading out. This will only queue this text and waits, until any earlier
* text's are done playing. Also, Music volume will be lowered (if supported by the current
* media-player) while the text is spoken.</p>
* <p>This method returns immediately after queuing the text.</p>
* @param text the text to read out.
* @return whether the text was successfully queued for reading out, or not.
*/
public boolean queueSpeech(String text){
// Media-Player should lower volume:
int focus_res = am.requestAudioFocus(
afl, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
);
// Talk:
if (focus_res == AudioManager.AUDIOFOCUS_REQUEST_GRANTED){
// Add the text to the queue:
int queue_res = tts.speak(text, TextToSpeech.QUEUE_ADD, this.tts_params);
if (queue_res == TextToSpeech.SUCCESS){
// Successfully queued:
this.speech_count++;
return true;
}
}
return false;
}
@Override
public void onUtteranceCompleted(String utterance_id) {
this.speech_count--;
if (speech_count == 0){
// No more speeches are queued, give focus back:
am.abandonAudioFocus(afl);
}
}
}
@LukasKnuth
Copy link
Author

What race condition would that be? Since speak() is blocking (it will queue the utterance to be spoken and return immediately) no code is ran in parallel here.

If you want this to be more "secure", you could generate a sequential/random integer for the "utteranceId" and place it in a Set. Then, when onUtteranceCompleted(utteranceId) is called, remove the Id from the Set and check if it's empty. That would get rid of counting and even if something was called multiple times, your code would still behave correctly.

I find that scenario very speculative though, since nothing like that is documented and the TextToSpeech instance isn't shared.

@kasnder
Copy link

kasnder commented Jul 5, 2021

Wow! Thank you for the really helpful feedback.

My concern was around this.speech_count++; (line 114). What if the current thread gets interrupted running this line, and queueSpeech gets called from another thread (then running this.speech_count++;) again and, in the end, increasing the counter wrongly?

I agree that a solution with random IDs and a queue might be a bit of an overkill.

@LukasKnuth
Copy link
Author

Not sure if I'm overlooking something, but that's just not how threads/interruption works.

If a thread is interrupted, something in the thread needs to actively poll for the interrupted flag and react accordingly (for example by throwing the InterruptedException, thus terminating the thread). You can safely assume that none of the methods inside the queueSpeech() method do this. See https://stackoverflow.com/a/3590008/717341

It is definitely possible for the thread in which speak() is called to be terminated right after (for example by the OS for memory reasons). But in that case, it will take it's local instance of TTS with it.

If you share an instance of TTS between multiple threads, I'd argue that would be an incorrect usage of the class, since it's not documented to be thread-safe. I don't think there is a simple way to make it thread-safe, since I'm not sure if Android's Context object doesn't have any threading guarantees. You might be fine, but you can't know.

@kasnder
Copy link

kasnder commented Jul 6, 2021

Again, thanks for the clarification! I think I had forgotten that Java lives in its own weird world..

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment