DEV Community

Cover image for I find reading hard, so I built a text-to-speech reader for Android — here's how
yramstech
yramstech

Posted on

I find reading hard, so I built a text-to-speech reader for Android — here's how

I've always found reading hard. Long documents slide off my attention, and I lose my place constantly. What I really wanted was something that would read to me and show me the words as it went — so my eyes and ears stayed in sync. Nothing did exactly that, so I built it.

It's called ReadAloud, it's on Google Play, and this post is the "why" and the interesting bits of the "how."

The moment it became real

The first person I showed a rough build to was my Sister, Praise. She'd come to town to officiate a Women's Premier League match at Auntie Aku Astro Turf Park, and I pulled out my phone between everything else. She watched a paragraph read itself aloud with each word lighting up and got genuinely excited — that was the push I needed. She became tester #1. My colleague Reggie became tester #2. Between them they found the rough edges I'd stopped seeing, and the app settled into something stable.

What it is

A text-to-speech reader for PDFs, EPUB, DOCX, plain text and web articles. It reads aloud in natural voices, highlights each word as it speaks, and auto-scrolls to follow along. There's offline listening, English/French/Spanish, speed-reading (RSVP), a vocabulary builder, and reading stats.

The stack: Kotlin, Jetpack Compose + Material 3, MVVM + Clean Architecture, Hilt, Room, DataStore, WorkManager, minSdk 26. Now the parts that were actually interesting to build.

1. Word-by-word highlighting

This is the whole product, so it had to be right. On-device voices are easy — Android's TextToSpeech gives you onRangeStart (API 26+), which fires per spoken range:

override fun onRangeStart(utteranceId: String, start: Int, end: Int, frame: Int) {
    // highlight the substring [start, end) in the reader
    _currentRange.value = start to end
}
Enter fullscreen mode Exit fullscreen mode

The catch: the natural cloud voices people actually want don't emit onRangeStart. So for cloud synthesis I wrap each word in an SSML <mark> and ask Google Cloud TTS to return timepoints:

<speak><mark name="w0"/>Every <mark name="w1"/>word <mark name="w2"/>counts.</speak>
Enter fullscreen mode Exit fullscreen mode

Each timepoint maps a mark → a timestamp; I pair that back to the word's character range and drive the highlight from MediaPlayer.currentPosition. Some families (Studio, Chirp) reject SSML outright, so those fall back to a syllable-weighted time estimate. Two code paths, one visible behaviour — I call it "dual highlighting."

The lesson learned the hard way: highlighting regressed silently more than once as I refactored the reader. It never crashed — it just quietly stopped tracking. Now any change to the reading pipeline runs a highlight check before it ships.

2. Keeping API keys off the device

Cloud TTS costs money per character, so the API key is a liability if it ships in the APK (anyone can unzip a .aab and grep for AIza…). My rule: no third-party keys in the binary.

Instead the app fetches config from a small server. Every request is HMAC-signed, and every response is encrypted:

fun sign(method: String, path: String, ts: Long, deviceId: String, body: String): String {
    val message = "$method|$path|$ts|$deviceId|$body"
    val mac = Mac.getInstance("HmacSHA256")
    mac.init(SecretKeySpec(APP_SECRET.toByteArray(), "HmacSHA256"))
    return mac.doFinal(message.toByteArray()).joinToString("") { "%02x".format(it) }
}
Enter fullscreen mode Exit fullscreen mode

The server verifies the signature, then returns the keys as an AES-256-GCM blob whose key is HKDF(masterKey, salt = deviceId) — so every device gets a distinct derived key. The app decrypts, caches, and uses them. Rotating a key, blocking a device, or flipping maintenance mode is a server change, not an app release.

A debugging war story from this exact system: a debug build once compiled without the shared secret and silently baked in a placeholder. Every signed request came back 401, cloud voices vanished, and it looked like a server outage. It wasn't — the fix was a rebuild. The takeaway I now live by: fail loudly when a required secret is missing, especially in debug.

3. PDFs that don't scramble

PDF text extraction is famously messy. Two-column layouts interleave line-by-line, and running heads/footers leak into the middle of sentences. I run a column-aware reordering pass (cluster text runs by x-position, read each column top-to-bottom) and strip repeated page furniture.

The subtle part is resume + highlights. If I anchored them to page/character offsets, they'd break the moment extraction changed. Instead they're content-anchored — tied to the surrounding text — so your place and your highlights survive a re-extraction or a settings change that reflows the page.

4. Offline listening

A commute shouldn't need signal. A WorkManager job synthesizes the whole book once in your chosen voice, storing the MP3 chunks plus a sidecar of word timings:

data class OfflineChunkAudio(val bytes: ByteArray, val timings: List<WordTiming>?)
Enter fullscreen mode Exit fullscreen mode

Playback then runs fully offline, with the same word highlighting as the live path, because the timings travel with the audio.

What I'd tell my past self

  • The core feature is the product. Highlighting is 5% of the code and 95% of the value — treat it like a first-class citizen, with tests.
  • Accessibility helps everyone. Bionic reading, adjustable spacing, colour overlays, a sleep timer — I added them for myself and they made the app better for everyone.
  • Ship the boring safety. Server-held keys, "fail loud" on missing secrets, content-anchored state. Each one saved me later.
  • Real testers beat assumptions. Praise and Reggie caught things I was blind to.

Try it

ReadAloud is on Google Play: https://play.google.com/store/apps/details?id=com.bless.readaloud.app

It's free; premium unlocks the most natural voices and offline audio. If reading is ever a chore for you the way it is for me, I hope it helps — and I'd love your feedback.

Built with Kotlin + Compose. Happy to go deeper on any of the above in the comments.

Top comments (0)