How to Implement Text to Speech in Android Studio (Kotlin & Java)

To implement text to speech in Android Studio you need exactly three things: a <queries> block in your manifest, a TextToSpeech object created with an OnInitListener, and a call to speak(). No runtime permission is required, and no third-party library either — android.speech.tts.TextToSpeech has shipped with Android since API 4.
Here is the whole thing in Kotlin, minus the boilerplate:
private var tts: TextToSpeech? = null
tts = TextToSpeech(this) { status ->
if (status == TextToSpeech.SUCCESS) {
tts?.language = Locale.US
tts?.speak("Hello from Android Hire", TextToSpeech.QUEUE_FLUSH, null, "hello")
}
}
That snippet works, but it will silently fail on any modern device if you skip the manifest step below — which is the single most common reason a text-to-speech tutorial "does nothing" when you run it. Since Android 11 (API 30), package visibility filtering hides the TTS engine from your app unless you declare that you want to see it. Most older tutorials, including the previous version of this one, were written before that change.
Below you get the manifest fix, a complete working Kotlin app, the same app in Java, a Jetpack Compose version, and the parts nobody covers: choosing a specific voice, tracking when speech starts and finishes, saving speech to an audio file, and the 4,000-character limit that breaks long-text apps.
The second half is the production material — requesting audio focus so you do not talk over the user's music, splitting a full article into sentences for a "listen to this" feature, keeping the engine alive across screen rotation, and a checklist to run before you ship.
In a hurry? Jump to: the manifest fix · full Kotlin code · full Java code · Jetpack Compose · pitch, rate & voices · reading long text aloud · audio focus · troubleshooting · production checklist
What You Need Before You Start
- Android Studio (any recent version — the code below has no version-specific APIs).
- minSdk 21 or higher. The modern
speak()overload that takes an utterance ID is API 21+. Anything lower forces the deprecatedHashMapversion. - A device or emulator with a TTS engine installed. Almost every phone ships with Speech Recognition & Synthesis from Google. Bare AOSP emulator images sometimes do not — use a Google APIs or Google Play system image.
- No permissions. Text to speech needs no
<uses-permission>entry and no runtime permission request. If you are handling permissions elsewhere in your app, see our guide to Android runtime permissions.
Step 1: Add the <queries> Block to AndroidManifest.xml
Do this first. Android 11 introduced package visibility filtering, so an app can no longer see every other app installed on the device. The TTS engine lives in a separate app, which means your app cannot bind to it unless you declare the intent you are looking for.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries>
<application ... >
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
<!-- The queries block is a direct child of <manifest>, NOT of <application>. -->
Without it, onInit() is handed TextToSpeech.ERROR, nothing is spoken, and Logcat shows a bind failure that looks like a device problem rather than a code problem. Google Play requires apps to target a recent API level, so this affects effectively every new app.
Note the android:exported="true" on the launcher activity too — that has been mandatory since Android 12 and is another reason old tutorial manifests fail to build.
Step 2: Build the Layout
A text field, a speak button, and a stop button. Save this as res/layout/activity_main.xml.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp">
<EditText
android:id="@+id/inputText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Enter text to speak"
android:inputType="textMultiLine"
android:minLines="3" />
<Button
android:id="@+id/speakButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:enabled="false"
android:text="Speak" />
<Button
android:id="@+id/stopButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Stop" />
</LinearLayout>
The Speak button starts disabled. Engine initialization is asynchronous and takes a moment, so enabling the button only after a successful onInit() prevents the classic "first tap does nothing" bug.
Step 3: The Full Kotlin Activity
package com.example.texttospeech
import android.os.Bundle
import android.speech.tts.TextToSpeech
import android.widget.Button
import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import java.util.Locale
class MainActivity : AppCompatActivity() {
private var tts: TextToSpeech? = null
private lateinit var inputText: EditText
private lateinit var speakButton: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
inputText = findViewById(R.id.inputText)
speakButton = findViewById(R.id.speakButton)
val stopButton = findViewById<Button>(R.id.stopButton)
tts = TextToSpeech(this) { status ->
if (status == TextToSpeech.SUCCESS) {
when (tts?.setLanguage(Locale.US)) {
TextToSpeech.LANG_MISSING_DATA,
TextToSpeech.LANG_NOT_SUPPORTED ->
toast("This language is not supported on your device")
else -> speakButton.isEnabled = true
}
} else {
toast("Text to speech engine failed to start")
}
}
speakButton.setOnClickListener { speak() }
stopButton.setOnClickListener { tts?.stop() }
}
private fun speak() {
val text = inputText.text.toString().trim()
if (text.isEmpty()) {
toast("Type something first")
return
}
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "utterance-1")
}
private fun toast(message: String) =
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
override fun onDestroy() {
tts?.stop()
tts?.shutdown()
tts = null
super.onDestroy()
}
}
Three details in that code are worth calling out, because they are where most copy-pasted tutorials go wrong.
Check for SUCCESS, not "not ERROR"
You will see if (status != TextToSpeech.ERROR) in a lot of older code, including the earlier version of this article. It is unreliable: onInit() is documented to return either SUCCESS or ERROR, so test for the value you actually want. Then check the return value of setLanguage() separately — a successful engine start does not mean your chosen language is installed.
Always pass an utterance ID
The last parameter of speak() is the utterance ID. Passing null works, but you lose every progress callback, so you can never tell when speech starts or finishes. Give it a string and you unlock progress tracking.
Shut down in onDestroy(), not onPause()
Calling shutdown() in onPause() is a common mistake — the engine dies the moment the user opens the notification shade or a dialog appears, and speech never resumes when they come back. Use stop() in onPause() if you want speech to halt when the app is backgrounded, but reserve shutdown() for onDestroy(). Releasing the engine matters: it is a bound service, and leaking it holds system resources.
The Same App in Java
If you are working in Java, here is the equivalent activity. (New to Kotlin activities in general? Start with our guide on moving from one Activity to another in Kotlin.)
package com.example.texttospeech;
import android.os.Bundle;
import android.speech.tts.TextToSpeech;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import java.util.Locale;
public class MainActivity extends AppCompatActivity
implements TextToSpeech.OnInitListener {
private TextToSpeech tts;
private EditText inputText;
private Button speakButton;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
inputText = findViewById(R.id.inputText);
speakButton = findViewById(R.id.speakButton);
Button stopButton = findViewById(R.id.stopButton);
tts = new TextToSpeech(this, this);
speakButton.setOnClickListener(v -> {
String text = inputText.getText().toString().trim();
if (text.isEmpty()) {
Toast.makeText(this, "Type something first",
Toast.LENGTH_SHORT).show();
return;
}
tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, "utterance-1");
});
stopButton.setOnClickListener(v -> tts.stop());
}
@Override
public void onInit(int status) {
if (status != TextToSpeech.SUCCESS) {
Toast.makeText(this, "Text to speech engine failed to start",
Toast.LENGTH_SHORT).show();
return;
}
int result = tts.setLanguage(Locale.US);
if (result == TextToSpeech.LANG_MISSING_DATA
|| result == TextToSpeech.LANG_NOT_SUPPORTED) {
Toast.makeText(this, "This language is not supported on your device",
Toast.LENGTH_SHORT).show();
} else {
speakButton.setEnabled(true);
}
}
@Override
protected void onDestroy() {
if (tts != null) {
tts.stop();
tts.shutdown();
tts = null;
}
super.onDestroy();
}
}
Text to Speech in Jetpack Compose
Compose has no built-in TTS wrapper, so you manage the engine's lifecycle yourself with remember and DisposableEffect. The onDispose block replaces onDestroy().
@Composable
fun rememberTextToSpeech(): TextToSpeech? {
val context = LocalContext.current
var tts by remember { mutableStateOf<TextToSpeech?>(null) }
DisposableEffect(Unit) {
val engine = TextToSpeech(context) { status ->
if (status == TextToSpeech.SUCCESS) {
tts?.language = Locale.US
}
}
tts = engine
onDispose {
engine.stop()
engine.shutdown()
tts = null
}
}
return tts
}
@Composable
fun SpeakScreen() {
val tts = rememberTextToSpeech()
var text by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(24.dp)) {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("Enter text to speak") },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(16.dp))
Button(
onClick = {
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "compose-1")
},
enabled = tts != null && text.isNotBlank()
) {
Text("Speak")
}
}
}
Keying DisposableEffect on Unit means the engine is created once and torn down when the composable leaves the composition. Because tts is state, the button stays disabled until the engine is genuinely ready.
Language, Pitch, Speed and Voices
Changing the language
setLanguage() takes a Locale and returns a status you should always check.
when (tts?.setLanguage(Locale.FRENCH)) {
TextToSpeech.LANG_MISSING_DATA -> {
// The engine speaks French, but the voice data is not downloaded.
// Fixable by the user - send them to the system TTS settings.
startActivity(Intent("com.android.settings.TTS_SETTINGS"))
}
TextToSpeech.LANG_NOT_SUPPORTED -> {
// The engine cannot speak this language at all. Fall back.
tts?.language = Locale.US
}
else -> { /* Ready to speak. */ }
}
LANG_MISSING_DATA and LANG_NOT_SUPPORTED mean different things, and handling only one of them is why apps go silent for users outside your own locale. Missing data is fixable by the user; unsupported is not.
You will see older code send users to TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA here. That constant is deprecated, along with the rest of the intent-based TextToSpeech.Engine constants such as ACTION_CHECK_TTS_DATA and KEY_PARAM_STREAM. Opening the system text-to-speech settings screen is the durable alternative — wrap it in a try/catch for ActivityNotFoundException, because a small number of OEM builds hide that screen.
Pitch and speech rate
tts?.setPitch(1.0f) // 1.0 is normal; 0.5 deeper, 2.0 higher
tts?.setSpeechRate(1.0f) // 1.0 is normal; 0.5 half speed, 2.0 double
Both accept a float and default to 1.0f. Set them before calling speak() — changes do not affect an utterance already in progress. In practice, values below about 0.4 or above 2.5 turn into noise on most engines, so keep sliders inside that range. If you want to expose pitch and rate as sliders in your UI, our Android SeekBar tutorial covers exactly that control.
Picking a specific voice
Locale gets you a language. Voice (API 21+) gets you a particular speaker, and it is what separates a polished app from a generic one.
val voice = tts?.voices?.firstOrNull { v ->
v.locale == Locale.US && !v.isNetworkConnectionRequired
}
voice?.let { tts?.voice = it }
Filtering on isNetworkConnectionRequired matters: high-quality network voices sound better but fail offline. If your app must work on a plane, prefer local voices and treat the network ones as an upgrade. You can also inspect voice.quality and voice.latency to choose.
Knowing When Speech Starts and Finishes
speak() returns immediately — it queues the utterance, it does not block. To update a UI while speech plays, attach an UtteranceProgressListener. This is what the utterance ID is for.
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {
runOnUiThread { speakButton.isEnabled = false }
}
override fun onDone(utteranceId: String?) {
runOnUiThread { speakButton.isEnabled = true }
}
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) {
runOnUiThread { speakButton.isEnabled = true }
}
})
These callbacks arrive on a background thread, so wrap any UI work in runOnUiThread (or post to a Compose state). The no-argument onError(String) is deprecated in favour of onError(String, int), which gives you an error code — override both if you support older devices.
Saving Speech to an Audio File
To generate a WAV file instead of playing audio — useful for caching, sharing, or offline playback — use synthesizeToFile():
val file = File(cacheDir, "speech.wav")
tts?.synthesizeToFile(text, null, file, "file-1")
The call is asynchronous. The file is not complete until onDone() fires for that utterance ID, so do not try to read or share it before then. Writing to cacheDir or filesDir avoids storage permissions entirely.
The 4,000-Character Limit
A single speak() call is capped. Ask the framework rather than hard-coding the number:
val max = TextToSpeech.getMaxSpeechInputLength() // 4000 on current Android
Text longer than that is rejected outright — nothing is spoken and you get no obvious error. If you are building a reader app, split the text into sentence-sized chunks and queue them with QUEUE_ADD:
longText.chunked(TextToSpeech.getMaxSpeechInputLength())
.forEachIndexed { index, chunk ->
val mode = if (index == 0) TextToSpeech.QUEUE_FLUSH
else TextToSpeech.QUEUE_ADD
tts?.speak(chunk, mode, null, "chunk-$index")
}
Splitting on sentence boundaries rather than raw character counts sounds far more natural, since a hard cut mid-word produces an audible glitch between chunks.
Letting Users Choose a TTS Engine
Most devices ship with Google's engine, but many users install alternatives such as Samsung TTS or a third-party voice pack. You can list every installed engine and let the user pick:
val engines: List<TextToSpeech.EngineInfo> = tts?.engines ?: emptyList()
engines.forEach { engine ->
Log.d("TTS", "${engine.label} -> ${engine.name}") // name is the package
}
To actually use one, pass its package name to the three-argument constructor. This has to happen at construction time — you cannot swap engines on a live instance:
tts = TextToSpeech(this, { status -> /* ... */ }, "com.google.android.tts")
Store the chosen package in preferences and shut down the old instance before creating the new one. If the package is not installed the constructor falls back to the system default, so a stale saved preference degrades gracefully rather than crashing.
Playing Nicely With Music and Other Apps
This is the step that separates a demo from a shippable app, and no other tutorial on this topic covers it. By default your speech fires straight over whatever the user is already listening to. Request audio focus first so the music app knows to get out of the way, and describe your audio correctly so the system treats it as speech.
// Tell the system this is spoken content, not music.
val attributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
tts?.setAudioAttributes(attributes)
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val focusRequest = AudioFocusRequest.Builder(
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
.setAudioAttributes(attributes)
.setOnAudioFocusChangeListener { change ->
if (change == AudioManager.AUDIOFOCUS_LOSS) tts?.stop()
}
.build()
if (audioManager.requestAudioFocus(focusRequest)
== AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "focused-1")
}
// Release it when speech finishes - in onDone() of your progress listener.
audioManager.abandonAudioFocusRequest(focusRequest)
One subtlety worth knowing: when your attributes are tagged CONTENT_TYPE_SPEECH, the system will not silently duck your audio for you. It notifies your focus listener with AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK instead, on the reasoning that half-volume speech is unintelligible whereas half-volume music is merely quieter. So handle that callback by pausing, not by turning yourself down.
Note that AudioFocusRequest requires API 26. Below that, use the older requestAudioFocus(listener, streamType, durationHint) overload behind a version check.
Real-World Pattern: Reading a Long Article Aloud
The most common production use of TTS is a "listen to this article" button. Naively feeding the whole body text to speak() hits the 4,000-character ceiling and does nothing. Splitting on a raw character count works but audibly chops words in half. Split on sentence boundaries instead, using BreakIterator, which understands punctuation rules per locale:
private fun sentencesOf(text: String, locale: Locale): List<String> {
val iterator = BreakIterator.getSentenceInstance(locale)
iterator.setText(text)
val sentences = mutableListOf<String>()
var start = iterator.first()
var end = iterator.next()
while (end != BreakIterator.DONE) {
sentences.add(text.substring(start, end).trim())
start = end
end = iterator.next()
}
return sentences.filter { it.isNotEmpty() }
}
private fun readAloud(article: String) {
val sentences = sentencesOf(article, Locale.US)
sentences.forEachIndexed { index, sentence ->
val mode = if (index == 0) TextToSpeech.QUEUE_FLUSH
else TextToSpeech.QUEUE_ADD
tts?.speak(sentence, mode, null, "sentence-$index")
}
}
Because each sentence carries its own utterance ID, onDone("sentence-7") tells you exactly where the reader is. That gives you three features almost for free:
- Progress — parse the index out of the ID and update a progress bar.
- Highlighting — highlight the sentence currently being read, karaoke style.
- Resume — remember the last completed index, and on resume re-queue only from there. There is no built-in pause/resume in the TTS API;
stop()clears the queue entirely, so tracking your own position is the only way to implement it.
Surviving Rotation: Don't Rebuild the Engine
Creating a TextToSpeech instance binds to a system service, which takes a noticeable moment. If the engine lives in your Activity, every screen rotation tears it down and rebuilds it — speech cuts off mid-sentence and the user watches the Speak button grey out again.
Move it into a ViewModel so it outlives configuration changes:
class SpeechViewModel(application: Application) : AndroidViewModel(application) {
private val _ready = MutableLiveData(false)
val ready: LiveData<Boolean> = _ready
private val tts = TextToSpeech(application) { status ->
_ready.postValue(status == TextToSpeech.SUCCESS)
}
fun speak(text: String) =
tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, "vm-1")
override fun onCleared() {
tts.stop()
tts.shutdown()
super.onCleared()
}
}
Pass the application context, never the Activity — holding an Activity reference in something that outlives the Activity is a textbook memory leak. onCleared() fires when the ViewModel is genuinely finished, not on every rotation, so the engine is created once per screen instead of once per orientation change.
If speech must continue while the user is in another app, an Activity or ViewModel is the wrong home entirely — run it in a foreground service with a notification, the same way a podcast player does.
Performance and Battery Notes
- Create one engine, reuse it. Constructing a
TextToSpeechper button press is the single most common performance bug in TTS code. Bind once, keep the reference. - Prefer local voices for repeated speech. Network voices sound better but each utterance is a request. For anything that speaks frequently, filter on
isNetworkConnectionRequiredand keep audio on-device. - Cache repeated phrases. If your app speaks the same strings over and over, synthesize them once with
synthesizeToFile()and play the file. Cheaper than re-synthesizing and it works offline. - Never call
speak()in a tight loop. Queue withQUEUE_ADDand let the engine drain it; flooding the queue causes dropped utterances. - Always
shutdown(). A leaked engine keeps a service bound and holds audio resources longer than it needs to.
How to Test It Properly
TTS behaves differently across devices more than most APIs, so test beyond your own phone:
- An emulator without Google APIs — confirms your "no engine available" path actually shows a message instead of failing silently.
- A non-English locale with the voice data uninstalled — exercises the
LANG_MISSING_DATAbranch. - Airplane mode — catches an accidental dependency on a network voice.
- Rotate mid-sentence — verifies your lifecycle handling.
- Start music, then speak — verifies audio focus.
- Paste 10,000 characters — verifies chunking rather than silence.
Enable Developer options → Text-to-speech output on a test device to switch engines, change the default locale, and preview voices without touching your code.
Troubleshooting: Why Your TTS Is Silent
| Symptom | Most likely cause | Fix |
|---|---|---|
Nothing happens, onInit gets ERROR | Missing <queries> block (Android 11+) | Add the TTS_SERVICE queries element |
| Nothing happens, no error at all | Text longer than 4,000 characters | Chunk the text |
| First button tap is ignored | Speaking before init finished | Enable the button inside onInit |
| Works in English, silent in other languages | LANG_MISSING_DATA ignored | Check the setLanguage() return value |
| Speech cuts off when the screen locks | shutdown() called in onPause() | Move it to onDestroy() |
| Works on a phone, silent on the emulator | No TTS engine in the system image | Use a Google APIs / Play image |
| Voice sounds robotic or fails offline | Falling back from a network voice | Filter on isNetworkConnectionRequired |
| App builds but crashes on launch | Missing android:exported | Add it to the launcher activity |
| Speech talks over the user's music | No audio focus requested | Request audio focus |
| Speech restarts or dies on rotation | Engine owned by the Activity | Move it to a ViewModel |
| Long pause before the first word | Engine rebuilt on every tap | Create it once and reuse it |
| Words cut in half between chunks | Split on character count | Split on sentence boundaries |
One more silent-failure pattern worth ruling out: if your app fetches the text to read from the network, add an offline state before blaming TTS — our guide to adding a no-internet-connection screen in Android Studio shows how.
Text to Speech vs Speech to Text
These get searched for interchangeably, but they are opposite features and use different classes:
- Text to speech — your app reads text aloud. Class:
TextToSpeech. No permission needed. This is what this guide covers. - Speech to text — the microphone transcribes the user's voice. Class:
SpeechRecognizeror theRecognizerIntent. Requires theRECORD_AUDIOpermission and a runtime permission request.
If you wanted the microphone, the <queries> action you need is android.speech.RecognitionService, not TTS_SERVICE.
When the Built-In Engine Isn't Enough: Gemini and Cloud TTS
Everything above uses the on-device engine, and for most apps that is the right call: free, offline, zero latency to a server. But device voices still sound like device voices. If your app narrates long-form content and voice quality is a selling point, the current upgrade path is a cloud TTS API:
- Gemini-TTS (via Google's Cloud Text-to-Speech or Vertex AI APIs) is the newest option: you steer style, pace, accent and emotion with natural-language prompts, and it supports single- and multi-speaker output. Gemini 2.5 Flash TTS is the low-latency/low-cost model; 2.5 Pro TTS targets audiobook- and podcast-grade narration.
- Classic Cloud TTS voices (Neural2, WaveNet, and similar tiers from Google, Amazon Polly, Azure, ElevenLabs) return an audio stream or file you play with
MediaPlayer/ExoPlayerinstead of callingspeak().
The trade-offs are real: every utterance is a metered network request, nothing works offline, and you now manage an API key on a server (never ship it in the APK). A sensible hybrid is cloud audio for headline content and the on-device engine as the offline fallback — the lifecycle, chunking, and audio-focus patterns in this guide apply unchanged to the playback side. And to be clear, android.speech.tts.TextToSpeech itself is not deprecated; only the old TextToSpeech.Engine intent constants are.
Production-Ready Checklist
Before you ship, walk this list. Every item maps to a bug that reaches real users:
| Check | Why it matters |
|---|---|
<queries> with TTS_SERVICE in the manifest | Without it the engine is invisible on Android 11+ |
onInit tested against SUCCESS | != ERROR is unreliable |
| UI disabled until init completes | Kills the "first tap does nothing" bug |
setLanguage() return value handled | Silent failure for non-English users |
Real utterance IDs on every speak() | No callbacks without them |
| Text chunked under 4,000 characters | Long text is rejected outright |
| Audio focus requested and abandoned | Otherwise you talk over the user's music |
| Engine held in a ViewModel or service | Survives rotation; no rebuild cost |
| Application context, never Activity | Prevents a memory leak |
stop() + shutdown() on final teardown | Releases the bound service |
| Fallback message when no engine exists | Some devices genuinely have none |
| Tested offline and in a second language | Catches network-voice assumptions |
Bottom Line
Android's built-in text-to-speech is one of the cheapest features you can add: no dependency, no permission, roughly 30 lines of Kotlin. The parts that bite are all lifecycle and configuration, not the API itself — declare TTS_SERVICE in <queries>, wait for SUCCESS before you enable the UI, check what setLanguage() actually returned, pass a real utterance ID, and call shutdown() when you are genuinely done.
Get those five right and the demo works. To make it feel like a real product, add the three that follow: request audio focus so you do not trample the user's music, split long text on sentence boundaries instead of character counts, and keep the engine in a ViewModel so rotation does not restart it. Run the checklist above before you ship.
Frequently Asked Questions
How do I implement text to speech in Android Studio?
Add a <queries> block declaring android.intent.action.TTS_SERVICE to AndroidManifest.xml, create a TextToSpeech object with an OnInitListener, wait for the listener to report TextToSpeech.SUCCESS, set a language with setLanguage(), then call speak(text, TextToSpeech.QUEUE_FLUSH, null, "utteranceId"). Call stop() and shutdown() in onDestroy() to release the engine. No library or permission is required.
Does text to speech need a permission in Android?
No. TextToSpeech needs no <uses-permission> entry and no runtime permission request. It does need a package visibility declaration on Android 11 (API 30) and above: a <queries> element with the action android.intent.action.TTS_SERVICE. Speech to text is different and does require the RECORD_AUDIO permission.
Why is my Android text to speech not working or silent?
The most common cause on modern Android is a missing <queries> block for TTS_SERVICE, which makes onInit() return ERROR. Other frequent causes are speaking before initialization completes, text longer than the 4,000-character limit, an uninstalled language pack reported as LANG_MISSING_DATA, calling shutdown() in onPause(), or running on an emulator image with no TTS engine installed.
How do I change the voice, language, or speed of Android text to speech?
Use setLanguage(Locale.FRENCH) for language, setPitch(1.0f) for pitch, and setSpeechRate(1.0f) for speed, where 1.0f is normal for both. For a specific speaker, iterate tts.voices and call setVoice(). Apply all of these before calling speak(), because they do not affect an utterance that is already playing.
What is the character limit for TextToSpeech.speak()?
TextToSpeech.getMaxSpeechInputLength() returns 4,000 characters on current Android versions. Longer text is rejected with no audio and no obvious error, so split it into chunks and queue them with QUEUE_ADD. Splitting on sentence boundaries sounds more natural than cutting at an exact character count.
Can I use TextToSpeech with Jetpack Compose?
Yes. Compose has no built-in wrapper, so hold the engine in remember and manage its lifecycle with DisposableEffect, creating the TextToSpeech instance in the effect body and calling stop() and shutdown() in onDispose. Storing the instance as state lets you keep the Speak button disabled until the engine is ready.
How do I save Android text to speech output as an audio file?
Call synthesizeToFile(text, params, file, utteranceId) with a File in cacheDir or filesDir, which needs no storage permission. The call is asynchronous, so wait for onDone() on an UtteranceProgressListener with the matching utterance ID before reading or sharing the resulting WAV file.
Is Android's built-in TextToSpeech still the best option in 2026?
For most apps, yes. The android.speech.tts.TextToSpeech class is not deprecated, works offline, and costs nothing. If voice quality is a core feature — audiobook narration, podcast-style reading — cloud APIs such as Gemini-TTS (via Google's Cloud Text-to-Speech or Vertex AI), Amazon Polly, or ElevenLabs produce far more natural voices, at the cost of network latency, per-character pricing, and no offline support. A common pattern is cloud audio when online with the on-device engine as fallback.




