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 } The catch: the natural cloud voices people actually want don't emit onRangeStart . So for cloud synthesis I wrap each word in an SSML and ask Google Cloud TTS to return timepoints: Every word counts. 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) } } 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 ?) 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)
Comments
No comments yet. Start the discussion.