Advertisement
Android Phones

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

Aditya SinghUpdated
Text To Speech in Android Studio

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 deprecated HashMap version.
  • 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();
    }
}
Advertisement

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

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 TextToSpeech per 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 isNetworkConnectionRequired and 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 with QUEUE_ADD and 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:

  1. An emulator without Google APIs — confirms your "no engine available" path actually shows a message instead of failing silently.
  2. A non-English locale with the voice data uninstalled — exercises the LANG_MISSING_DATA branch.
  3. Airplane mode — catches an accidental dependency on a network voice.
  4. Rotate mid-sentence — verifies your lifecycle handling.
  5. Start music, then speak — verifies audio focus.
  6. 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

SymptomMost likely causeFix
Nothing happens, onInit gets ERRORMissing <queries> block (Android 11+)Add the TTS_SERVICE queries element
Nothing happens, no error at allText longer than 4,000 charactersChunk the text
First button tap is ignoredSpeaking before init finishedEnable the button inside onInit
Works in English, silent in other languagesLANG_MISSING_DATA ignoredCheck the setLanguage() return value
Speech cuts off when the screen locksshutdown() called in onPause()Move it to onDestroy()
Works on a phone, silent on the emulatorNo TTS engine in the system imageUse a Google APIs / Play image
Voice sounds robotic or fails offlineFalling back from a network voiceFilter on isNetworkConnectionRequired
App builds but crashes on launchMissing android:exportedAdd it to the launcher activity
Speech talks over the user's musicNo audio focus requestedRequest audio focus
Speech restarts or dies on rotationEngine owned by the ActivityMove it to a ViewModel
Long pause before the first wordEngine rebuilt on every tapCreate it once and reuse it
Words cut in half between chunksSplit on character countSplit 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: SpeechRecognizer or the RecognizerIntent. Requires the RECORD_AUDIO permission 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/ExoPlayer instead of calling speak().

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:

CheckWhy it matters
<queries> with TTS_SERVICE in the manifestWithout it the engine is invisible on Android 11+
onInit tested against SUCCESS!= ERROR is unreliable
UI disabled until init completesKills the "first tap does nothing" bug
setLanguage() return value handledSilent failure for non-English users
Real utterance IDs on every speak()No callbacks without them
Text chunked under 4,000 charactersLong text is rejected outright
Audio focus requested and abandonedOtherwise you talk over the user's music
Engine held in a ViewModel or serviceSurvives rotation; no rebuild cost
Application context, never ActivityPrevents a memory leak
stop() + shutdown() on final teardownReleases the bound service
Fallback message when no engine existsSome devices genuinely have none
Tested offline and in a second languageCatches 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.

CodingTutorialKotlinAndroid Studio

Related Articles

Advertisement