Skip to content
AF
Audio Coaching

TextToSpeech workout cues on Android: initialization, focus, queue and completion

Last verified October 3, 2026 · 6 min read

Sources checked (3) · October 3, 2026
Android's TextToSpeech can only speak after initialization completes, which Google signals through TextToSpeech.OnInitListener, and speak() is asynchronous: it only queues the request, so completion arrives through an UtteranceProgressListener keyed by the utterance id you pass. Google's reference does not say TextToSpeech manages audio focus, so a cue app requests AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK before speaking and abandons it in onDone, onStop or onError; Google documents that by onDone all audio has been played back. Set the cue's AudioAttributes with setAudioAttributes, use QUEUE_FLUSH when a newer cue makes the queued one stale, declare the android.intent.action.TTS_SERVICE intent in a queries element when targeting Android 11, and call shutdown() when the workout ends.

Android's built-in TextToSpeech is the quickest way to make a workout app talk, and it is asynchronous in more places than its method names suggest. The constructor returns before the engine is ready. speak() returns before anything is spoken. Completion arrives on a listener, possibly on another thread. And the reference says nothing about audio focus, so ducking the user's music is your job.

This page walks the lifecycle for coaching cues. The focus request itself is on the MAY_DUCK page, and the background restriction for apps targeting Android 15 is on the AUDIOFOCUS_REQUEST_FAILED page.

Initialization, and the callback that can come early#

Google's TextToSpeech reference: "A TextToSpeech instance can only be used to synthesize text once it has completed its initialization. Implement the TextToSpeech.OnInitListener to be notified of the completion of the initialization."

The constructor's parameter documentation adds the detail that causes a confusing crash: "In a case of a failure the listener may be called immediately, before TextToSpeech instance is fully constructed." If your onInit reads the property you are about to assign the instance to, a failure path can hit it while it is still null. Treat init status as data and keep onInit from touching the instance until construction has returned.

Create the engine when the workout screen opens, not when the first cue is due, so initialization is finished before anything needs saying.

Android 11: the queries element#

Apps targeting Android 11 "that use text-to-speech should declare TextToSpeech.Engine.INTENT_ACTION_TTS_SERVICE in the queries elements of their manifest," and Google gives the block:

<queries>
  <intent>
    <action android:name="android.intent.action.TTS_SERVICE" />
  </intent>
</queries>

Speaking a cue#

tts.setAudioAttributes(cueAttributes)          // same attributes as the focus request

fun speakCue(text: String, id: String) {
    if (audioManager.requestAudioFocus(cueFocus) != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) return
    tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, id)
}

Google documents setAudioAttributes as setting "the audio attributes to be used when speaking text or playing back a file," returning ERROR or SUCCESS. Use the same attributes your focus request carries, as Google's focus guide recommends for any player.

speak(CharSequence, int, Bundle, String) "Speaks the text using the specified queuing strategy and speech parameters," and Google is explicit that it "is asynchronous, i.e. the method just adds the request to the queue of TTS requests and then returns. The synthesis might not have finished (or even started!) at the time when this method returns." The text must be "No longer than getMaxSpeechInputLength() characters," and the return value is ERROR or SUCCESS "of queuing the speak operation," not of speaking it.

QUEUE_FLUSH or QUEUE_ADD#

Queue modeGoogle's descriptionUse it for
QUEUE_FLUSH"all entries in the playback queue (media to be played and text to be synthesized) are dropped and replaced by the new entry," flushed "with respect to a given calling app"A time-critical cue that makes anything still queued stale: "go", "rest", "last set"
QUEUE_ADD"the new entry is added at the end of the playback queue"The second half of a cue you deliberately split, such as an exercise name followed by its target

Our judgement: default to QUEUE_FLUSH for workout cues. A queue that holds a cue past its moment delivers it late, and a late cue is wrong. Google notes the flush only affects your own app's entries: "Entries in the queue from other callees are not discarded."

Knowing when it finished, so the music can come back#

Completion arrives through UtteranceProgressListener, keyed by the utterance id you passed to speak. Google's speak documentation: "In order to reliably detect errors during synthesis, we recommend setting an utterance progress listener." The listener's class note matters for threading: "The callbacks specified in this method can be called from multiple threads."

CallbackGoogle's descriptionWhat a cue app does
onStart(utteranceId)Called when an utterance "starts" as perceived by the caller, "soon before audio is played back"Nothing, or mark the cue delivered
onDone(utteranceId)"All audio will have been played back by this point for audible output"Abandon audio focus, so the music unducks
onStop(utteranceId, interrupted)Called when an utterance "has been stopped while in progress or flushed from the synthesis queue," including by stop() or QUEUE_FLUSHAbandon focus if nothing else is queued
onError(utteranceId, errorCode)Called when an error has occurred during processing; Google notes there "will never be a call to both onDone(String) and onError(String) for the same utterance"Abandon focus; fall back to a haptic
tts.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
    override fun onStart(utteranceId: String) {}
    override fun onDone(utteranceId: String) = release(utteranceId)
    override fun onStop(utteranceId: String, interrupted: Boolean) = release(utteranceId)
    @Deprecated("Deprecated in Java")
    override fun onError(utteranceId: String) = release(utteranceId)
    override fun onError(utteranceId: String, errorCode: Int) = release(utteranceId)
})

onDone is the right release point because of Google's wording: by then "All audio will have been played back." Do not use isSpeaking() for this. Google warns that "a speech item is considered complete once it's audio data has been sent to the audio mixer," and "There might be a finite lag between this point, and when the audio hardware completes playback."

Languages and voices that are not there#

setLanguage(Locale) returns a support code, and Google notes the engine "will try to use the closest match to the specified language as represented by the Locale, but there is no guarantee that the exact same Locale will be used." Two return values mean you will get no speech: LANG_MISSING_DATA, "the language data is missing," and LANG_NOT_SUPPORTED. Separately, an utterance can fail with ERROR_NOT_INSTALLED_YET, "a failure caused by an unfinished download of the voice data." Our judgement: check the language when the workout screen opens, and if it is missing, tell the user before the workout starts that cues will be tones and haptics, rather than discovering it at the first cue.

Rate, pauses and shutdown#

  • setSpeechRate(float): "1.0 is the normal speech rate, lower values slow down the speech (0.5 is half the normal speech rate), greater values accelerate it (2.0 is twice the normal speech rate)." Our judgement: expose it as a user setting rather than choosing a rate for everyone.
  • playSilentUtterance(durationInMs, queueMode, utteranceId) "Plays silence for the specified amount of time," which is how to put a deliberate gap between two halves of a cue without a timer.
  • stop() "Interrupts the current utterance (whether played or rendered to file) and discards other utterances in the queue." Call it when the user pauses.
  • shutdown() "Releases the resources used by the TextToSpeech engine," and Google suggests calling it in onDestroy(). For a workout app, call it when the workout ends, from whatever owns the engine, which is often the foreground service rather than an activity.

Monday morning#

Create the engine when the workout screen opens and confirm onInit cannot touch a half-built instance. Add the TTS_SERVICE queries element. Set the cue attributes on the engine and match them in the focus request. Speak with QUEUE_FLUSH and release focus in onDone, onStop and onError. Check the language before the workout starts. Then run a workout with music playing and listen for the music coming back after every cue.

Frequently asked questions

Does Android TextToSpeech duck the user's music automatically?
Not as far as Google's reference says. The TextToSpeech and UtteranceProgressListener references describe initialization, queuing, attributes and progress callbacks, and say nothing about audio focus. So treat ducking as your job: request AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK before calling speak, with the same AudioAttributes you set on the engine through setAudioAttributes, and abandon focus when the utterance finishes. On Android 8.0 and higher the system then ducks the music app for you.
Should workout cues use QUEUE_FLUSH or QUEUE_ADD in TextToSpeech?
QUEUE_FLUSH for anything time-critical. Google documents QUEUE_FLUSH as dropping all entries in the playback queue and replacing them with the new entry, and QUEUE_ADD as adding the new entry at the end of the queue. A cue held in a queue behind a longer one is delivered late, and a late cue is wrong. Use QUEUE_ADD only for the second half of a cue you split on purpose. Google notes the flush only affects your own app's entries, not other callers'.
Why does TextToSpeech setLanguage return LANG_MISSING_DATA?
Because the voice data for that language is not on the device. Google's reference documents LANG_MISSING_DATA as the language data being missing and LANG_NOT_SUPPORTED as the language not being supported, and notes that setLanguage tries the closest match to the locale with no guarantee of an exact one. An utterance can separately fail with ERROR_NOT_INSTALLED_YET, an unfinished download of the voice data. Check the language when the workout screen opens and tell the user before the workout if cues will be tones and haptics.
Why does my TextToSpeech onInit callback run before the instance is assigned?
Because Google documents that it can. The TextToSpeech constructor's listener documentation says that in a case of a failure the listener may be called immediately, before the TextToSpeech instance is fully constructed. If onInit reads the property you are assigning the instance to, a failure path can find it still null. Record the init status in onInit and act on it only after construction has returned.

All 42 questions in Audio Coaching →

Keep reading

Elsewhere on the site

Pages that share this one’s concepts and sources, from other sections.

Next steps

Was this page useful?

Independent comparison, last reviewed October 3, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.

← All audio coaching · by AIFitnessAPI