---
title: "TextToSpeech workout cues on Android: initialization, focus, queue and completion"
canonical: "https://aifitnessapi.com/audio-coaching/texttospeech-workout-cues-android"
cluster: "Audio Coaching"
primary_query: "android texttospeech workout cues"
last_reviewed: "2026-10-03"
description: "OnInitListener timing, the TTS_SERVICE queries element, QUEUE_FLUSH for stale cues, and releasing audio focus in onDone so the user's music comes back."
publisher: "AIFitnessAPI — independent, not sponsored"
cite_as: "\"TextToSpeech workout cues on Android: initialization, focus, queue and completion\", AIFitnessAPI, https://aifitnessapi.com/audio-coaching/texttospeech-workout-cues-android"
---

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

> 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.

- Canonical: https://aifitnessapi.com/audio-coaching/texttospeech-workout-cues-android
- Last reviewed: 2026-10-03
- Publisher: AIFitnessAPI (https://aifitnessapi.com) — independent, not sponsored
- Cite as: "TextToSpeech workout cues on Android: initialization, focus, queue and completion", AIFitnessAPI, https://aifitnessapi.com/audio-coaching/texttospeech-workout-cues-android

---

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](/audio-coaching/android-audio-focus-may-duck), and the background restriction for apps targeting Android 15 is on [the AUDIOFOCUS_REQUEST_FAILED page](/audio-coaching/audiofocus-request-failed-android-15).

## Initialization, and the callback that can come early

Google's [`TextToSpeech`](https://developer.android.com/reference/android/speech/tts/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:

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

## Speaking a cue

```kotlin
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 mode | Google's description | Use 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`](https://developer.android.com/reference/android/speech/tts/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."

| Callback | Google's description | What 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_FLUSH` | Abandon 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 |

```kotlin
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.

## Sources

- [developer.android.com/reference/android/speech/tts/TextToSpeech](https://developer.android.com/reference/android/speech/tts/TextToSpeech) — init, Android 11 queries, speak, queue modes, language codes, rate, shutdown (checked 2026-10-03)
- [developer.android.com/reference/android/speech/tts/UtteranceProgressListener](https://developer.android.com/reference/android/speech/tts/UtteranceProgressListener) — callbacks, threads, onDone semantics (checked 2026-10-03)
- [developer.android.com/media/optimize/audio-focus](https://developer.android.com/media/optimize/audio-focus) — requesting and abandoning focus (checked 2026-10-03)

## FAQ

### 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.

[Permalink](https://aifitnessapi.com/audio-coaching/texttospeech-workout-cues-android#faq-1)

### 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'.

[Permalink](https://aifitnessapi.com/audio-coaching/texttospeech-workout-cues-android#faq-2)

### 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.

[Permalink](https://aifitnessapi.com/audio-coaching/texttospeech-workout-cues-android#faq-3)

### 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.

[Permalink](https://aifitnessapi.com/audio-coaching/texttospeech-workout-cues-android#faq-4)
