Cup - Create an Audiobook from a URL

Cup - Create an Audiobook from a URL interface with a URL field and an Upload & listen button.

I want to be able to listen to news stories, blog posts, or other texts instead of reading them on a screen.

Existing options fell short for me:

  • Screen readers are optimized for using interfaces. They announce interactive elements and therefore don't allow for uninterrupted listening.
  • Conventional read-aloud tools all sound robotic.
  • Android's Gemini assistant lets you "live share" your screen with the assistant, but then it can only access what is visible on screen (and it also sometimes paraphrases the text).
  • ...and none of those tools would remember my listening progress or have playlists.

So I built Cup, a full-stack application that turns the content behind a URL into an audiobook: natural-sounding narration with a clean, readable text synchronized to it.

Listen to "A turbulent AI era—and the critical choices ahead" by Bill Gates to hear how it sounds:

Conversion Pipeline: From URL to Synchronized Audio #

A conversion has two phases. Preparation runs once per URL and produces the text; speech is generated afterwards, segment by segment, as the player asks for it.

Preparation (once per URL)
  Source URL
    -> rendered web page
    -> (LLM) narration content selection
    -> narration document and segments

Playback (per segment, on demand)
  Segment text
    -> (LLM) speech generation
    -> MP3 audio segment
  • The application accepts the URL of an HTML page.
  • Playwright loads it through Cloudflare Browser Run and captures the rendered HTML and metadata, including content produced by JavaScript.
  • An LLM selects the content worth narrating.
  • The content is turned into a narration document and split into segments (internally called "synchronization units").
  • When the player needs a segment, a TTS model generates speech for it and the result is stored as an independent MP3.

The audiobook therefore consists of a narration document and one audio segment per unit. There is no single assembled audio file: the player plays the segments in sequence and highlights the corresponding text.

Both phases run as durable workflows (Cloudflare Workflows): one for preparation, and one per audio segment. Each step has explicit timeouts and retries. A failed preparation can be retried for the same conversion, and a failed segment can be retried on its own without affecting the audio that already exists.

Content Selection: Keeping the Non-Deterministic Part Small #

Web pages mix the content a listener wants to hear with navigation, ads, consent UI, recommendations, and other unrelated elements. Deterministic extraction rules cannot separate them reliably across websites.

Therefore, the application uses an LLM to select the elements worth narrating.
In simple terms, it asks: "If this page were an audiobook, what would belong in it?"

A simple approach would be to feed the whole HTML into the model and ask it to return the narration text itself.
However, that would create two problems:

  • It could change the wording or hallucinate content.
  • Returning the complete text would use more output tokens, increasing cost and latency.

Instead, the model is restricted to selecting element IDs, keeping the surrounding pipeline deterministic:

  1. Tagging: Deterministic code parses the HTML and adds a unique data-* identifier to every selectable element.
  2. Selection: A text-generation model receives bounded chunks of that annotated HTML and returns only the identifiers of elements worth narrating.
  3. Filtering: Deterministic code validates the returned identifiers and selects the corresponding original HTML elements.

The prompt describes the content worth keeping, including titles, creator information, headings, prose, and meaningful quotations.
The response contains only identifiers:

{
  "element_ids": ["0", "1"]
}

The response must conform to a strict JSON Schema. Zod validates it before deterministic code applies the selection.

Lesson: limit non-determinism to what requires inference.
It reduces cost, reduces the impact of hallucinations, and improves testability.

Evals: Preventing Regressions and Enabling Informed Decisions #

I built a manually curated reference dataset from HTML sources across different publishers, including news pages and blog posts. For each source, I reviewed the application's narration output and edited it into what I considered the ideal output.

vitest-evals compares candidates against these input/output pairs; the resulting segments must match the references exactly.
Each candidate consists of its model, prompt, tool schema, reasoning effort, temperature, token limit, and retry policy.
Comparing candidates against the evals allows me to make informed decisions about which model and prompt to use, to prevent regressions, and to measure the (cost) effect of changes in the prompt or model configuration.

I initially chose Qwen 3.8 27B (via Cloudflare Workers AI) after evaluating different models and configurations.
It met the required quality at low cost; cheaper models didn't satisfy the evals.

Latency turned out to be a problem, though: content selection was slow and repeatedly hit request timeouts. I compared other models and providers, then switched to Gemini 3.8 Flash through Cloudflare AI Gateway to bring that latency down. It matched the expected output in all existing eval cases while completing much faster. Having those evals made it possible to check that the speed improvement preserved the selection quality.

Lesson: Create evals early to avoid regressions and to enable informed decisions.

Technology Stack #

Cup runs almost entirely on Cloudflare: Workers hosts the web application and API, Workflows orchestrates conversions, and Durable Objects and R2 persist state and generated artifacts.

PurposeTechnologies
Web application. Renders the SPA and manages routing, server state, forms, and styling.React, Base UI, Vite, TanStack Router, TanStack Query, TanStack Form, Linaria CSS
Mobile app. Packages the same UI with native sign-in, sharing, and background playback.Capacitor
API. Exposes the HTTP API and validates its request and response schemas.Hono, OpenAPI, @hono/zod-openapi
Page rendering and extraction. Loads pages, including JavaScript-rendered content, and captures their HTML and metadata.Playwright, Cloudflare Browser Run
Narration content selection. Selects the original page elements worth narrating.Cloudflare AI Gateway, Google AI Studio, gemini-3.8-flash
Text-to-speech. Routes speech requests and generates an audio segment for each synchronization unit.Cloudflare AI Gateway, Google AI Studio, gemini-3.8-flash-lite-tts
Conversion orchestration. Runs preparation and per-segment speech generation with explicit timeouts and retries.Cloudflare Workflows
Application state. Stores accounts, trial grants, conversions, and duration accounting.Cloudflare Durable Objects, SQLite, Drizzle ORM
Object storage. Stores narration documents and audio segments.Cloudflare R2
Sign-in. Authenticates users and issues the tokens the API verifies.Supabase Auth, Google sign-in, jose
Operator access. Protects administrative routes.Cloudflare Zero Trust Access
Email. Sends account-deletion notices.Resend
Validation and date/time. Validates structured data and handles date and time values.Zod, Temporal

Development and Testing #

PurposeTechnologies
Workspace and builds. Manages dependencies and orchestrates builds.pnpm, Turborepo
Cloudflare development. Runs and deploys the Cloudflare Worker.Wrangler
Infrastructure. Manages the configuration of external services.Terraform
Formatting, type checks, linting. Enforces formatting and applies static analysis (type checks, linting).Oxfmt, TypeScript, Oxlint
Tests and evals. Covers unit behavior, end-to-end flows, and LLM output quality.Vitest, Playwright Test, vitest-evals

Future Work #

  • Bring the costs down further. Text-to-speech dominates the cost. Switching to a lighter Gemini TTS model and generating only what is listened to helped a lot, but voice quality per dollar remains the main trade-off.
  • Support more input formats. e.g. PDFs.
  • Add payments. Accounts have a free allowance, but there is no way to buy more yet.
  • Improve synchronization beyond paragraphs. Currently, the application synchronizes at paragraph boundaries. It should support sentence-level synchronization.
  • Use different voices for authors and quotations. The application could detect the gender of the author and use a corresponding voice, and it could detect quotations and use separate voices for them.
  • Narrate images and other visuals. The application could use a vision model to describe images and other visuals and narrate those descriptions.
  • Evals for TTS quality. Content-selection evals and deterministic tests covering the pipeline through segment output give me high confidence in the text sent to the TTS model. The generated audio itself still needs evals to check how faithfully and naturally it narrates that text.