Text-to-speech guide for Android

The MediaPipe Text-to-Speech task lets you synthesize natural-sounding speech audio from text on Android devices using the Kokoro-82M neural TTS model. These instructions show you how to use Text-to-Speech with Android apps written in Kotlin.

For more information about the capabilities, .litertlm model bundles, and supported voices of this task, see the Overview.

Code example

The MediaPipe Tasks example code is a basic implementation of a Text-to-Speech app for Android. The example synthesizes the text that you enter with a voice that you choose, either as a complete utterance or as a stream of audio chunks, and plays the audio as it arrives.

You can use the app as a starting point for your own Android app, or refer to it when modifying an existing app. The Text-to-Speech example code is hosted on GitHub.

Download the code

The following instructions show you how to create a local copy of the example code using the git command line tool.

To download the example code:

  1. Clone the git repository using the following command:
    git clone https://github.com/google-ai-edge/mediapipe-samples
    
  2. Optionally, configure your git instance to use sparse checkout, so you have only the files for the Text-to-Speech example app:
    cd mediapipe-samples
    git sparse-checkout init --cone
    git sparse-checkout set examples/text_to_speech/android
    

After creating a local version of the example code, get the model as described in the example's README. Then import the project into Android Studio and run the app. For instructions, see the Setup guide for Android.

Key components

The following files contain the main code for the Text-to-Speech example app:

  • MainActivity.kt - Creates the Text-to-Speech task, lists the available voices, runs unary and streaming synthesis, and implements the user interface.
  • TtsAudioPlayer.kt - Plays the synthesized audio with AudioTrack and can save it as a WAV file.

Setup

This section describes key steps for setting up your development environment and Android project to use Text-to-Speech. For general information on setting up your development environment for MediaPipe tasks, see the Setup guide for Android.

Dependencies

Add the MediaPipe Audio Tasks dependency to your module's build.gradle.kts (requires minSdk = 26, arm64-v8a):

dependencies {
    implementation("com.google.mediapipe:tasks-audio:latest.release")
}

Model

The MediaPipe Text-to-Speech task requires the kokoro_int8_gpu.litertlm model bundle. See the task overview Models section for details.

Place the downloaded .litertlm file on device storage (or copy it from app assets into context.filesDir / context.cacheDir) and pass its file system path to BaseOptions.builder().setModelAssetPath(...).

Create the task

Use TextToSpeech.create(options) to initialize the task with TextToSpeechOptions. Pass a writable cacheDirectory (such as context.cacheDir.absolutePath) so unpacked phonetic dictionary data and XNNPACK weight caches persist across app launches.

import com.google.mediapipe.tasks.audio.texttospeech.TextToSpeech
import com.google.mediapipe.tasks.audio.texttospeech.TextToSpeechOptions
import com.google.mediapipe.tasks.core.BaseOptions
import com.google.mediapipe.tasks.core.Delegate

val options = TextToSpeechOptions(
    baseOptions = BaseOptions.builder()
        .setModelAssetPath("/data/user/0/com.example.app/files/kokoro_int8_gpu.litertlm")
        .setDelegate(Delegate.CPU)
        .build(),
    defaultVoiceId = "af_heart",
    speakingRate = 1.0f,
    targetBucket = 64,
    cacheDirectory = context.cacheDir.absolutePath,
)

val tts = TextToSpeech.create(options)

Configuration options

This task has the following configuration options for Android apps:

Task options (TextToSpeechOptions)

Option Name Description Value Range Default Value
baseOptions.modelAssetPath Path (or URL on Web) to the single .litertlm model bundle containing the Kokoro-82M acoustic and vocoder graphs, phonetic lexicons, and voice embeddings. String / Path Required
baseOptions.delegate Hardware acceleration delegate used by the LiteRT runtime. When set to GPU, the Kokoro acoustic stage executes on CPU (XNNPACK) and the neural vocoder executes on GPU (OpenCL/Metal/WebGPU) with CPU fallback. Delegate.CPU, Delegate.GPU Delegate.CPU
speakingRate Speaking rate multiplier applied to synthesized Kokoro audio (1.0 represents normal speaking speed). Float (0.1, 4.0] 1.0
targetBucket Target sequence-length bucket for the Kokoro acoustic/vocoder graph (for example, 64, 128, or 256 tokens). Smaller buckets reduce synthesis latency for short sentences; 0 uses the model's default capacity. 0, 64, 128, 256 0
defaultVoiceId Default Kokoro voice profile identifier (for example, "af_heart", "ef_dora", or "zf_xiaobei"). Can be overridden per utterance using SynthesisOptions. String (Optional) "af_heart"
defaultLocale Optional default BCP 47 language tag (for example, "en-US", "es", "zh-CN"). When omitted, the task automatically infers the language from the selected Kokoro voice prefix. BCP 47 String (Optional) Inferred from voice
cacheDirectory Writable directory path used to persist unpacked phonetic dictionary assets and LiteRT XNNPACK weight caches across runs for faster cold starts. Directory Path (Optional) ""

Per-utterance options (SynthesisOptions)

Option Name Description Default Value
voiceId Overrides the voice profile for a single synthesize call (for example, switching from "af_heart" to "ef_dora" in under 1 ms without reloading model weights). null / None (uses task default)
locale Optional BCP 47 locale override for a single utterance. Leave unset (null / None) when switching voices so the engine automatically resolves the voice's target language from its prefix. null / None (inferred from voiceId)

Inspect available voices

Call listVoices() on the initialized TextToSpeech instance to enumerate all Kokoro voices packaged in the .litertlm bundle:

val voices = tts.listVoices()
for (voice in voices) {
    println("${voice.voiceId} (${voice.displayName}) - locale=${voice.locale}, ${voice.sampleRateHz} Hz")
}

Run the task

You can run synthesis synchronously on a background thread with synthesize(text, options) or non-blockingly from a Kotlin Coroutine with synthesizeAsync(text, options).

Synchronous and coroutine synthesis

// Non-blocking coroutine call (recommended for UI / ViewModel scopes):
val audioResult = tts.synthesizeAsync(
    "Hello from MediaPipe Text to Speech on Android! Multi-sentence inputs are chunked automatically."
)

// Or blocking synchronous call on a background worker thread:
val syncResult = tts.synthesize("Hello from a background thread.")

Fast bilingual voice switching

To switch voices or languages on a single utterance, pass SynthesisOptions with the target voiceId. Leave locale unset (null) so the Kokoro engine automatically resolves the language from the voice prefix in < 1 ms:

import com.google.mediapipe.tasks.audio.texttospeech.SynthesisOptions

// Speak English with "af_heart", then immediately switch to Spanish with "ef_dora":
val enAudio = tts.synthesizeAsync(
    "Good morning! Let's practice Spanish.",
    SynthesisOptions(voiceId = "af_heart"),
)
val esAudio = tts.synthesizeAsync(
    "¡Buenos días! Practiquemos español.",
    SynthesisOptions(voiceId = "ef_dora"),
)

Streaming speech synthesis

For lower Time-To-First-Audio (TTFA) on multi-sentence text, call synthesizeStream(text, options) to receive a cold Kotlin Flow<AudioChunk>. Each AudioChunk (samples, sampleRateHz, chunkIndex, isFinal) is emitted as soon as its clause finishes vocoding, so you can write it directly to an AudioTrack configured in AudioTrack.MODE_STREAM while subsequent clauses are synthesized:

import kotlinx.coroutines.flow.collect

tts.synthesizeStream(
    "Streaming synthesis plays the first clause immediately. Subsequent clauses synthesize in the background.",
    SynthesisOptions(voiceId = "af_heart"),
).collect { chunk ->
    // Write chunk.samples to a streaming AudioTrack as each clause arrives:
    streamingAudioTrack.write(chunk.samples, 0, chunk.samples.size, AudioTrack.WRITE_BLOCKING)
}

When streaming tokens incrementally from an on-device LLM, open a TtsStream with openStream(), push text deltas as tokens arrive with pushText(), call finishInput() at the end of the LLM response, and pull AudioChunks with readNext():

tts.openStream(options = SynthesisOptions(voiceId = "af_heart")).use { stream ->
    stream.pushText("Hello from an ")
    stream.pushText("on-device LLM! ")
    stream.pushText("Each completed clause streams out.")
    stream.finishInput()

    while (true) {
        val chunk = stream.readNext() ?: break
        streamingAudioTrack.write(chunk.samples, 0, chunk.samples.size, AudioTrack.WRITE_BLOCKING)
    }
}

Barge-in cancellation

If the user interrupts speech playback ("barge-in"), you can abort an in-flight unary or streaming synthesis call immediately:

  • When collecting synthesizeStream, canceling the collecting Job (or using short-circuit operators such as take(1) or first()) automatically cancels and closes the underlying TtsStream.
  • When using synthesizeAsync, canceling the calling Kotlin Job or CoroutineScope automatically invokes tts.cancel().
  • When using blocking synthesize or TtsStream.readNext(), call tts.cancel() (or stream.cancel()) directly from any thread. cancel() is lock-free and aborts the active Kokoro inference pass within milliseconds.
val job = coroutineScope.launch {
    tts.synthesizeStream("A long paragraph being streamed...").collect { chunk ->
        streamingAudioTrack.write(chunk.samples, 0, chunk.samples.size, AudioTrack.WRITE_BLOCKING)
    }
}

// On user barge-in:
job.cancel() // Automatically cancels the active TtsStream

Handle and play results

synthesize and synthesizeAsync return an AudioResult containing:

  • samples: FloatArray of mono PCM samples normalized to [-1.0f, 1.0f]
  • sampleRateHz: Int (24000 Hz for Kokoro-82M)
  • durationSeconds: Float computed duration of the generated clip in seconds

You can play the returned AudioResult using Android's AudioTrack in ENCODING_PCM_FLOAT mode, and close the TextToSpeech instance when finished:

import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack

fun playAudioResult(result: AudioResult) {
    val byteSize = result.samples.size * Float.SIZE_BYTES
    val audioTrack = AudioTrack.Builder()
        .setAudioAttributes(
            AudioAttributes.Builder()
                .setUsage(AudioAttributes.USAGE_MEDIA)
                .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
                .build()
        )
        .setAudioFormat(
            AudioFormat.Builder()
                .setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
                .setSampleRate(result.sampleRateHz)
                .setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
                .build()
        )
        .setBufferSizeInBytes(byteSize)
        .setTransferMode(AudioTrack.MODE_STATIC)
        .build()

    audioTrack.write(result.samples, 0, result.samples.size, AudioTrack.WRITE_BLOCKING)
    audioTrack.play()
}

// Release underlying C++ resources when done (TextToSpeech implements AutoCloseable):
tts.close()