Six months ago I was on a commute, listening to a business proposal through a PDF-to-speech app that read “Product Roadmap” as if it were the last clause of the previous sentence. I rewound the audio, listened again, and realized the app had no idea the document had a section break there. The PDF knew. The app did not.
That weekend I started reading the PDF specification. The metadata is there — font, size, weight, color, position, every character. Every existing app I had tried was throwing it away. Six months later, Audris is a buildable preview running on an iOS simulator (we are waiting on the real device until Apple Developer Program activates), an Android emulator, 185 vitest tests passing, fourteen git commits, and a marketing site with eight cornerstone blog posts. Real revenue is zero. The app has not shipped to either store.
This is the build-in-public retrospective. What worked, what didn’t, what we are still iterating on, and the lessons we’d want any solo developer attempting something similar to internalize before they start.
The premise
The frustration was concrete. I read a lot of long documents — proposals, contracts, research papers, the occasional book — and I do most of it on commutes and walks rather than at a desk. PDF-to-speech apps in the consumer market are oriented toward articles and books: text is text, the reading rate is constant, the voice is the differentiator. The cloud-first ones upload your document to a server, run synthesis remotely, and stream audio back. The on-device ones bundle a speech model locally but still treat the document as flat text.
The realization was that the PDF format has all the structural metadata you need to do better, and nobody is using it. Headings carry distinctive font sizes and weights. Section labels often come in distinctive colors. Bulleted lists carry their bullet glyphs in the text stream. Inline labels at the start of paragraphs and bullets carry their boldness explicitly. A reader that picks all of this up and converts it into audio cadence would be a genuinely different product — not just a voice catalog, but a different relationship to the document.
The counter-position to existing apps fell out naturally. Cloud-first apps sit at one end. Structure-aware, on-device, no-account, no-trial-funnel sits at the other end. The marketing line that landed was “documents that breathe.”
The 13-day spike
Solo dev. Expo subscription. Apple Developer Program enrollment in flight (it takes a few business days). No Mac yet — just a Windows machine for development and EAS Build for the iOS toolchain. The riskiest piece was ONNX Runtime on iOS, because that is the speech model’s entire runtime and there is no graceful fallback if it doesn’t compile.
Day 1–3: validate the toolchain. EAS Build produces an iOS .app artifact from the Expo prebuild and the native modules. Validated the .app exists, the AndroidManifest contains the expected service entries, and the iOS Info.plist gets the right Background Modes. We don’t yet have a Mac to actually run the .app on a real iPhone — that test waits for the Apple Developer Program activation — but the compile-and-build pipeline is green. ONNX Runtime is the long pole and we’ll find out for sure when we can run the binary.
Day 4: native module scaffolding. Three in-repo Expo Modules: audris-pdf for PDF extraction (PdfBox-Android on Android, PDFKit on iOS), audris-tts for synthesis (Supertonic 3 via ONNX Runtime, with Apple Speech as the iOS parallel implementation behind a shared protocol), audris-ocr for scanned-document OCR (deferred to the paid tier). Day 4 also gets lock-screen and Bluetooth controls wired up — androidx.media3.MediaSessionService on Android, MPRemoteCommandCenter on iOS.
Day 5–6: PDF extraction. The Android side is the harder one because PdfBox-Android’s default PDFTextStripper flattens everything to a string. We subclass and override the per-character callback to capture TextPosition objects: character, font name, font size, font weight, x and y position, width. The output is grouped into runs of contiguous text sharing the same font and approximate baseline. iOS is similar but goes through PDFKit’s attributed strings. Both sides emit the same binary packed format — 48 bytes per TextRun, designed to cross the React Native bridge ten times faster than JSON for documents with thousands of runs.
Day 7–12: the structure analyzer. Pure TypeScript, no native imports, runs in Node and in React Native identically. The pipeline implements every step described in the structure-aware reading post: document statistics (median body size, dominant color, dominant left margin), block grouping, role classification, inline label detection, header and footer suppression. By Day 12 the vitest suite hit fifty tests passing across synthetic fixtures covering all the SegmentRole cases.
Day 13: speech formatter. Takes the typed segment list and emits a SpeechPlan — chunks interleaved with PCM silence buffers for major structural breaks and Supertonic <breath> tags for organic ones. Tested against the same fixtures.
End of the spike: a working pipeline, tests, two native modules, a build that ships an .app artifact. No UI. No mascot. No real device runs yet on iOS. But the riskiest pieces were de-risked.
The architecture decisions that worked
Pure-TS pipeline. The structure analyzer, document statistics, speech formatter, segment renderer, and pause planner all live in TypeScript with zero native imports. They run in vitest in milliseconds. The 30-fixture regression suite caught real bugs that synthetic test cases had not: same-size-different-style line merging (two adjacent lines at the same font size but with different weights were being grouped as a single block; the fix splits on weight transitions), and tight-spaced labeled bullets (a bulleted list with **Label:** items where the line spacing was tight enough that the label-detection regex was matching across line boundaries; the fix anchors the regex to the run boundary, not the line). Both bugs were geometric heuristics that missed semantic cues. The fixtures forced the issue.
Binary buffer format for native→JS bridging. TextRun objects packed into 48-byte records and emitted as a single ArrayBuffer from the native side. JavaScript decodes lazily — TextRun is a thin view object over a slice of the buffer. Five to ten times faster than JSON across the React Native bridge, and the savings compound on long documents. A 50-page proposal that has fifteen thousand text runs would be a 5 MB JSON payload; as packed bytes it is 720 KB. The bridge crossing on a mid-tier Android phone goes from “noticeable hitch” to “imperceptible.”
Expo Modules API for the three native modules. Native code lives in modules/audris-pdf/, modules/audris-tts/, modules/audris-ocr/ rather than as npm packages. This made iteration much faster than the alternative of publishing to a private registry on every change. Day 4 lock-screen and Bluetooth controls dropped in cleanly behind androidx.media3.MediaSessionService (Android) and MPRemoteCommandCenter (iOS), with both surfaces driven by the same Zustand player store.
Apple Speech as the iOS fallback. ONNX Runtime on iOS is a real risk. The model has to compile, the runtime has to initialize, the per-call synthesis has to be fast enough that the first audio chunk plays in under two seconds. If any of that fails on a real device, we need a backup. Apple Speech ships built-in with the OS, supports high-quality bundled voices, and synthesizes on-device. We built it behind the same TtsEngineProtocol interface as the Supertonic engine. The voice picker treats the two as siblings. If Supertonic on iOS doesn’t compile cleanly, we ship with Apple Speech as the default on iOS and keep Supertonic for Android. The user-facing experience is the same; only the engine differs.
The architecture decisions we got wrong (or are still iterating)
The Rive mascot DIY plan. The original plan was to build the mascot ourselves in Rive Editor — a friendly little reading-companion character with amplitude-driven breathing and skip-direction tilt animations. After spending a weekend in the Rive Editor learning the state machine, it became clear that “build a Rive character” is its own three-week project and we were treating it as a side task. The compromise: ship a Skia silhouette stub that does amplitude-reactive breathing and tilts on skip, push the real Rive work to Phase 2. The stub renders well enough that nobody but us notices. Lesson: design tools are full applications. “Just use Rive” is not a small subtask; it is a discipline.
PdfBox-Android doesn’t expose color via TextPosition. Discovered mid-build. The Java TextPosition class gives you font, size, weight, position, and the unicode value — but not the rendered color of the glyph. Color in PDF is set via the graphics state at draw time, not on the glyph itself. To recover color you have to subclass PDFGraphicsStreamEngine and intercept the fill color when each glyph is drawn. We did not do this in v1. Workaround: hardcoded ARGB 0xff000000 for Android, which means the colored-bold-as-H2 heuristic fires only on iOS in v1.0. iOS PDFKit handles color correctly through NSForegroundColorAttributeName. v1.5 will add the PDFGraphicsStreamEngine subclass on Android to bring the two platforms to parity. Lesson: read the library source code before assuming the public API exposes what you need.
Parallel-track agent coordination. Phase 1 and Phase 2 of the build were landed by multiple Claude Code agents running in parallel — one on the player UI, one on the native module integration, one on the marketing site. The agents are fast and the parallelism cut the wall-clock time meaningfully. But git-index contention caused commit-message-to-content mismatches twice when multiple agents ran git commit simultaneously and one agent’s staged changes ended up under another agent’s commit message. The fix: per-agent atomic git add <specific-paths> listing every file explicitly, followed by git commit -F - reading the message from a heredoc rather than the index. Lesson: parallel build agents are powerful, but they share a git working tree. Treat it like shared state and use atomic operations on disjoint file sets.
The numbers
- 185 vitest tests passing
- 14 git commits across the main branch (plus more across short-lived feature branches that got squashed)
- About six weeks of solo evening and weekend work to reach the buildable preview
- Marketing site live at audris.app with eight cornerstone posts written
- App not yet shipped to either store
- Real revenue: $0 (pre-launch by design — there is no trial funnel and no payment flow until the product is genuinely ready)
The numbers are honest. They are also intentionally small. The plan was always to get the riskiest engineering done first, in a focused spike, and to defer everything that could be deferred. The mascot is deferred. Real-device iOS testing is deferred. The 30-PDF real-document corpus is partially built (twelve fixtures) and waits for beta testers to contribute the rest. Launch is deferred.
What was not deferred: the structure analyzer, the speech formatter, the binary bridge format, the native modules, the marketing site, the pricing model, the privacy posture, the typography on the marketing site. The things that define the product.
What’s next
The Apple Developer Program activation is the gating event for real-device iOS testing. The moment that account is live, we get TestFlight access, we can finally test the ONNX Runtime build on real iPhone hardware, and we can validate the Apple Speech fallback in production conditions. If the ONNX runtime on iOS holds up, the iOS launch can proceed in parallel with Android. If it doesn’t, we ship iOS on Apple Speech and Android on Supertonic, and we keep iterating the iOS Supertonic integration toward v1.5.
Beyond that:
- 30-PDF real-document corpus assembly during beta, contributed by testers from r/privacy and r/accessibility and a few accessibility-office contacts. The synthetic fixtures cover the cases we thought of. The real corpus will surface the cases we didn’t.
- Rive mascot in Phase 2 once we have a Rive artist or enough time to learn the tool properly.
- Five to ten beta testers from the privacy and accessibility communities. Closed beta, no marketing, real feedback on the actual reading experience.
- Public launch when the beta surfaces no critical issues and the corpus regression suite stays green.
Lessons
Spec before code. The 60-page SPEC.md was the single biggest accelerant. Every parallel agent could reference it. Every architectural decision had a canonical place to land. The structure analyzer heuristics are documented to a level where a different engineer could re-implement them. The pause and expression table is a literal table that the speech formatter reads. When agents disagreed about an interpretation, the spec resolved it. Writing the spec was three days of work in week one. It paid back twenty times over across the rest of the build.
Pure TypeScript for the intellectual property. The differentiator — the structure analyzer — is 5 KB of TypeScript. The native modules are mostly plumbing: extract text from a PDF, hand it to TypeScript; receive a SpeechPlan from TypeScript, render it through the speech engine. The IP lives in code that runs in vitest, runs in milliseconds, has a 185-test regression suite, and is platform-agnostic. The native side is the thinnest possible wrapper around the platform APIs. This is the right partition for a small team.
The free tier IS the trial. Counter-positioning against subscription trial-traps is a marketing differentiator and a product simplification at the same time. We do not have to build a trial-end notification flow. We do not have to build a “your trial expires in three days” email pipeline. We do not have to build a cancel-flow retention pop-up. The pricing model is simpler because we picked the model that does not require those mechanisms. The marketing message and the product surface line up.
Honest size matters. The ONNX runtime and Supertonic 3 model weights together are roughly 404 MB. That is a non-trivial install footprint on a phone. We chose to be loud about this on the marketing site rather than hide it. The pricing page acknowledges the size. The FAQ explains why. The competing apps that route through cloud TTS ship at 5 to 20 MB and are very visibly smaller in the app store. We can not win on install size and pretending otherwise would be the start of every dishonest marketing line we have spent six months avoiding.
The product is honest about its tradeoffs. The marketing is honest about the product. The pricing is honest about the model. None of those three things are easy to maintain if you are running a growth funnel that depends on inertia revenue. They are easier to maintain if you build for the people who actually read long documents and notice when audio sounds wrong.
Built by one person, for people who actually read.