Inspiration

I wanted to listen to books I already owned. Not audiobooks I would have to buy again, and not the ones a catalogue happened to have, but the EPUB and TXT files already sitting on my disk.

Every reader app that offers this sends the text to a server to be spoken. That is a strange trade to make for a book: you hand over what you are reading, sentence by sentence, and you get nothing back that the phone could not have produced itself. Phones have been able to run neural text-to-speech locally for a couple of years now. Nobody had put it in a reader.

What it does

Opens TXT, EPUB, PDF and MOBI, and reads them aloud in neural voices that run on the device.

  • Narration continues with the screen locked, through the lock-screen player, and chains from one chapter into the next
  • A sleep timer that pauses rather than stops, so you resume where you dozed off
  • Every voice can be auditioned before you pay, with no purchase and no model downloaded
  • Backup and restore that moves a whole library, reading progress included, between Android and iOS
  • Import over Wi-Fi from a computer, no cable and no account

Nothing leaves the phone. There is no login, and the neural models run locally, so it works in airplane mode.

How I built it

Two native apps, deliberately not one cross-platform app: Kotlin/Compose on Android, SwiftUI on iOS. Neural narration is sherpa-onnx, with models downloaded on demand rather than bundled, so the install stays small and a free user never carries weight they cannot use.

Cross-platform parity is maintained as an invariant rather than hoped for. File names, type names and pure-algorithm outputs mirror each other, and four contracts are pinned by tests: the backup wire format, the text chunker's 420-character boundaries, the progress arithmetic, and the analytics event names. The backup tests are the ones I am happiest with. Each platform contract-tests against a real export produced by the other, checked into the repo, so a format drift fails a build instead of a user's restore.

The parsers are hand-rolled. EPUB is plain zip plus DOM with no third-party library, and MOBI is a PalmDB/PalmDOC reader written from the format spec. PDF is a separate visual path with no text extraction, which is why PDFs are the one format that cannot be narrated.

Challenges I ran into

The first-run experience was the product's biggest bug, and it was invisible from the code. Two weeks of analytics said that of sixty new users, fourteen ever imported a book and thirty-five saw a paywall. The single most common first action in the entire app was a paywall view. Those users opened the system file picker four or five times, imported nothing, and never came back. An empty shelf with a file picker was the whole first-run experience, and it was converting people into leavers. A fresh install now seeds one public-domain book through the ordinary import path, so it is a real book afterwards: renameable, narratable, deletable.

Getting that right was fussier than it sounds. The seeding flag has two meanings when absent, since nobody wrote that key before the feature existed, so an existing user's shelf looked identical to a fresh install and the naive version dropped a book into libraries people had built themselves. A language with no sample yet must skip without spending its one chance, or the Chinese book that shipped later could never reach anyone who installed before it. And seeding deliberately fires no import event, because that metric is how the change gets judged.

A silently empty restore. On iOS, URL.standardizedFileURL is not consistent between a path that exists and one that does not. It resolved /private/var/... down to /var/... for a staging directory that had just been created, and left /private in place on a file about to be written inside it. The backup importer compared those two with a string prefix, concluded every entry was a zip-slip attack, skipped all of them, and reported success. The unit suite passed, including the real-Android-export contract test, because on the simulator both forms agree. Paths are now compared component-wise after resolving symlinks on both sides, and the release checklist runs that suite against hardware.

A regex that only broke on device. The JVM's \s excludes U+3000, the full-width space Chinese novels indent with, while Android's ICU includes it. A tidy-up that replaced a spelled-out character class with \s bricked every TXT and MOBI open on real devices while all the JVM tests stayed green.

Voice previews had to be pre-rendered. The point of the voice screen is that anyone can audition every voice, but a free user has no models on the device to synthesize with. So each voice ships a rendered clip, produced by a script that mirrors the app's own model, speaker id, speed and sentence-pause joining, so the sample is what you would actually get. Its one deliberate deviation is levelling every clip to the same loudness: the models disagree by up to 11 dB, and in a screen whose entire job is comparison, that decides the comparison on its own.

Accomplishments that I'm proud of

Deleting things. The voice screen once had three controls that could each decide what spoke: an "Automatic (best)" picker, the ticked row, and a "smart voice by language" toggle. A user reported the obvious consequence, that there was no way to tell which one applied. All three are gone, and the rule is now one sentence. The voice you tick is the voice that reads.

The paywall's Listen button came out of the same habit. Two weeks of analytics showed most people who saw the paywall had never played a voice preview: they were being asked to buy neural voices without having heard one. The button pauses narration, dismisses the sheet, and opens the voice screen, so you arrive there without the book reading over the samples.

What I learned

Instrumentation earns its keep at exactly the moment it tells you the thing you would never have guessed. Both of the changes above came from eight events and a dashboard, not from intuition, and both were about the parts of the app I had thought least about.

The other lesson is that a passing test suite describes the machine you tested on. The empty restore and the bricked TXT parser were green on the simulator and the JVM respectively, and both were broken on the thing users actually hold.

What's next

Getting the two platforms' release cadence back in step, and pushing the Chinese library further now that there is a sample book to land on.

Built With

  • android
  • avfoundation
  • cloudflare-workers
  • epub
  • exoplayer
  • gradle
  • hilt
  • ios
  • jetpack-compose
  • kotlin
  • kotlin-coroutines
  • media3
  • onnxruntime
  • pdfkit
  • posthog
  • revenuecat
  • room
  • sentry
  • sherpa-onnx
  • swift
  • swift-concurrency
  • swiftdata
  • swiftui
  • text-to-speech
  • xcodegen
Share this project:

Updates

Submission history