Open any PDF business proposal. Skim the first page with your eyes. Notice what your brain does before you have consciously read a word: it picks up the bold heading and gives it weight. It treats the teal subsection title as a checkpoint. It pauses at each bullet. It hears **Product:** as a label, slightly emphasized, and the rest of the line as the explanation. It does all of this in a few hundred milliseconds, and then you read.
Every other PDF-to-speech app I have tried throws all of that away.
They take the document, ask the operating system for “the text,” and stream the resulting flat string through a TTS engine at a steady rate. The bold section header gets the same cadence as a sentence in the middle of a paragraph. The bulleted label “Product:” runs straight into the description with no breath, no emphasis, no acknowledgment that it is a label at all. After ninety seconds of listening you cannot tell where you are in the document.
This is the problem Audris is built to solve. The fix has a name — structure-aware reading — and the rest of this post explains what that actually means and how we implemented it.
What a PDF actually is
To get this right, you have to start from how PDFs store text. A PDF does not contain “paragraphs” or “headings.” It contains glyph-positioning commands: draw glyph U+0050 in font Helvetica-Bold at size 18 at coordinates (72, 720) on page 1. That command tells you exactly which character was drawn, at which position, in which font, at which size. It does not tell you that this character is part of a heading.
What it tells you, though, is enough — if you know how to listen to it. The font, size, weight, color, and position of every character together encode every visual cue a human reader uses to navigate a document. The job of a structure-aware reader is to recover the structure from those cues.
We do this in five steps.
Step 1 — Extract every character with its formatting
On Android we use PdfBox-Android and override PDFTextStripper to capture TextPosition objects rather than the default flattened string. On iOS we do the equivalent dance with PDFKit. Either way, the output is a list of what we call a TextRun: a contiguous group of characters that share the same font, size, weight, color, and approximate position.
For a typical 10-page business proposal, the extractor produces a few thousand runs.
Step 2 — Compute the document’s “body” baseline
The document tells us what its own normal looks like. We compute four statistics over all the runs:
bodySize— the median font size across the entire document, excluding outliers.bodyWeight— the most common font weight (almost always 400, “regular”).bodyColor— the most common color (almost always near-black).leftMargin— the dominant left edge, found by histogramming x-coordinates.
These four numbers become the reference frame for everything else. We are not asking “is this text 14pt?” — we are asking “is this text larger than the document’s own body text?”
This matters because PDFs vary. One document’s body might be 10pt; another’s 12pt. A title at 16pt is small in the first document and modest in the second. Computing the baseline per-document is what lets the analyzer generalize.
Step 3 — Group runs into blocks
A “block” is a contiguous group of lines with the same indentation, the same font, and a vertical gap less than 1.5 times the line height. That last rule is doing real work: it separates one paragraph from the next, and one heading from the body underneath it.
After this step, the thousands of runs collapse down to maybe a hundred blocks, each one a candidate “thing” — a paragraph, a heading, a bullet, a quote.
Step 4 — Classify each block
This is where the heuristics live. Audris assigns each block a SegmentRole using a priority-ordered cascade:
IF block.fontSize >= bodySize * 1.6 AND block.isBold:
role = TITLE_H1
ELIF block.fontSize >= bodySize * 1.3 AND block.isBold:
role = TITLE_H2
ELIF block.isBold AND block.fontSize >= bodySize * 1.1:
role = TITLE_H3
ELIF block.color != bodyColor AND block.isBold:
role = TITLE_H2 // colored bold = a header
ELIF starts with a bullet glyph (•, ●, –, —, ◦, ▪, *):
role = BULLET
ELIF block in top or bottom 5% of page AND small font AND repeats:
role = HEADER_FOOTER
ELIF block is italic AND indented:
role = QUOTE
ELIF block is monospaced:
role = CODE
ELSE:
role = BODY
A few of these deserve a closer look.
The teal-bold H2 heuristic is the most distinctive rule. Many designed documents use a colored accent for subsection headers — teal in a business proposal, navy in a legal brief, terracotta in a magazine layout. The author chose that color precisely because it makes the heading visually distinct from body text. Audris notices the same signal: if a block is bold and uses a color that is not the document’s body color, that is almost always a section header, even if the font size is identical to body text. Other readers miss this entirely because they never look at color.
Bullet detection is intentionally permissive. We match the common visible glyphs, and we also fall back to “indented relative to body, with a list-like rhythm” for documents that use typographic bullets. The role matters because the speech formatter pauses differently before a bullet, and reads bullet labels with different emphasis.
Header and footer suppression is one of the few places Audris is opinionated about removing content. Page numbers, running titles, and copyright lines repeat across pages in fixed positions; reading them aloud once a minute is jarring. We detect them by combining position (top or bottom 5% of the page), font size (smaller than body), and recurrence (the same block content appears at the same position on three or more pages).
Step 5 — Detect inline labels within paragraphs and bullets
This is the rule that surprises people the most when they hear it. Inside a body paragraph or bullet, Audris looks for a run of bold text at the beginning followed by : or —. When it finds one, it splits the block:
**Product:** ~95% built. No "will we ship it" risk.
becomes:
- LABEL: “Product:”
- BODY: “~95% built. No will we ship it risk.”
The label gets a slight tempo slowdown (rate 0.97 instead of 1.0), a touch of emphasis on the colon, and a short pause after — about 250 ms — before the rest of the sentence continues. In audio, this is the difference between “Product ninety-five percent built no will we ship it risk” and “Product. (beat) Ninety-five percent built. No will we ship it risk.”
The label is short. The pause is short. The effect is the difference between a robot reading and a friend reading.
Turning structure into audio
Classification only gets us halfway. The other half is the speech formatter, which takes the segment list and emits a SpeechPlan — a sequence of TTS chunks interleaved with two kinds of pauses.
The first kind is PCM silence. For major structural breaks — before a TITLE_H1 we want 1.5 seconds; before a TITLE_H2 we want 1.1 seconds; after a quote block we want 700 ms — we generate raw silence buffers at Supertonic’s sample rate and concatenate them. PCM silence is exact and predictable. Commas and ellipses produce different pause lengths across different voices, so we never rely on them for long pauses.
The second kind is expression tags. Supertonic 3 supports inline <breath>, <laugh>, and <sigh> tags that synthesize as natural human prosody, not synthetic silence. We prefix <breath> to the first chunk after a major heading or quote — it sounds like the narrator catching their breath before continuing — and inject one mid-paragraph if the paragraph runs over 300 characters.
The combination is what gives Audris its distinct cadence. Big structural pauses are PCM silence (exact). Small organic breaks are <breath> tags (natural). The listener never hears either one as a mechanism — they hear someone reading.
Why this is hard, and why competitors do not do it
Every step of this pipeline is solvable individually. PdfBox-Android has been around for years. Font heuristics are not novel. PCM silence is trivial. Expression tags are documented in the Supertonic 3 README.
The thing that makes it hard is putting all of them on a phone, fast enough that the first audio chunk plays in under two seconds, and tuning the heuristics so they hold up across the messy variety of real PDFs.
The reason competitors do not do this is less about difficulty and more about incentive. Cloud TTS apps with subscription funnels are optimized for breadth of voices, brand-name AI partnerships, and shiny features like AI summaries. Structure-aware cadence on a per-document basis takes engineering time that does not show up on a feature list. If you charge $99 a year and your competitors all read documents like a monotone, there is no commercial pressure to do better.
We chose differently because we are not optimizing for that funnel. Audris is built for people who actually listen to long documents and notice when the audio sounds wrong.
What this enables next
The pipeline is the foundation. Everything else Audris does sits on top of it.
Because we have a typed segment tree, we can build a tap-to-jump table of contents that takes you to any heading. Because we know where bullets begin, the “skip to next bullet” gesture actually skips one bullet at a time, not approximately. Because we know where sections end, we can offer a sleep timer that stops at the end of a section rather than mid-sentence. Because we know which words are labels, we can let advanced users override pronunciation for technical terms without bleeding into the rest of the text.
And because every step of this happens on your device, none of it requires an account, a network connection, or anyone other than you knowing what is in the document.
That second part is what the next post is about.