Article to Audio
Turn any article into MP3: HTMLRewriter extracts text, Workers AI Deepgram Aura-2 narrates it, R2 caches the audio
This is an experimental Worker. Use it as a starting point for your own projects.
A "Listen to this article" endpoint for any URL. The Worker streams the page through HTMLRewriter to pull out readable text, synthesizes speech with Workers AI Deepgram Aura-2, and caches the MP3 in R2 so repeat listens are instant.
Features
- Readable-text extraction with HTMLRewriter: prefers
<article>/<main>, collects<p>and<h1>–<h3>, skipsnav,footer,aside,script,style,noscript,template,svg,form - Sentence-bounded chunking with
Intl.Segmenter(up to 6 chunks of ≤1,000 characters) - Parallel text-to-speech with
@cf/deepgram/aura-2-enor@cf/deepgram/aura-2-es - MP3 cached in R2 under a SHA-256 key of model + language + URL, with an
X-Cache: HIT|MISSheader /textdebug endpoint shows exactly what will be spoken
API Reference
GET /listen
Synthesize an article as MP3. Returns audio/mpeg.
Prop
Type
Response headers
Content-Type: audio/mpegX-Cache: HIT(served from R2) orMISS(synthesized for this request)Cache-Control: public, max-age=86400
Example Request
curl -o article.mp3 "https://your-worker.workers.dev/listen?url=https://blog.cloudflare.com/markdown-for-agents/"Embed it directly in a page:
<audio controls src="https://your-worker.workers.dev/listen?url=https://example.com/post"></audio>GET /text
Return the extracted text without synthesizing audio. Same url parameter as /listen.
Example Response
{
"url": "https://blog.cloudflare.com/markdown-for-agents/",
"title": "Introducing Markdown for Agents | Cloudflare Blog",
"text": "Introducing Markdown for Agents. Celso Martinho and Will Allen ...",
"chars": 5433,
"chunks": 6,
"truncated": true
}chunks is the number of TTS calls /listen would make. truncated is true when the article was longer than the 6,000-character cap.
Error Responses
{ "error": "No readable article text found at url", "code": "NO_CONTENT" }400-INVALID_URL,INVALID_LANG,NO_CONTENT(the URL is not an HTML page)404-NO_CONTENT(no readable text found)502-FETCH_ERROR(page fetch failed or returned a non-2xx status),TTS_ERROR(speech synthesis failed)
Implementation Details
Check the cache
src/lib/cache.ts hashes model + lang + url with Web Crypto SHA-256. If AUDIO_CACHE has audio/<hash>.mp3, the object body streams straight back with X-Cache: HIT, without fetching the page or calling AI.
Extract readable text
src/lib/extract.ts registers HTMLRewriter handlers that call into ArticleCollector (src/lib/text.ts). The collector tracks skip and container depth, decodes HTML entities (HTMLRewriter text chunks are raw HTML), and adds a period after headings so speech pauses naturally.
Chunk on sentence boundaries
chunkText uses Intl.Segmenter with granularity: "sentence" to pack sentences into chunks of at most 1,000 characters, splitting very long sentences on word boundaries.
Synthesize and store
src/lib/tts.ts calls Aura-2 with encoding: "mp3" for every chunk in parallel. Aura returns raw MP3 frames with no ID3 or Xing header, so the segments are concatenated byte-for-byte. The result is written to R2 with the URL, language, and title as custom metadata.
Use Cases
- Blogs and docs - Add a "Listen" button that points an
<audio>tag at/listen - Read-later apps - Turn saved links into a podcast-style queue
- Accessibility - Offer an audio version of long-form content
- Commutes - Generate MP3s of newsletters or articles to listen offline
Limitations
- Length cap: Only the first ~6,000 characters (6 chunks, roughly 6 minutes of audio) are read;
/textreportstruncated - Latency: A cache miss for a full-length article takes roughly 30 seconds; cached responses return in milliseconds
- Static HTML only: JavaScript-rendered pages aren't executed, so client-side apps may yield
NO_CONTENT - Heuristic extraction: Pages without
<article>/<main>fall back to every paragraph on the page, which may include boilerplate - Languages: Aura-2 on Workers AI supports English and Spanish
- No cache invalidation: Edited articles keep their old audio until the R2 object is deleted
Use in your project
Copy these files into an existing Worker. Prefer Deployment to try the full experiment first. Source: apps/experiments/article-to-audio.
import { BLOCK_SELECTOR, CONTAINER_SELECTOR, FETCH_TIMEOUT_MS, SKIP_SELECTOR, USER_AGENT,} from "../constants/defaults";import type { ExtractedArticle } from "../types/article";import { ArticleCollector } from "./text";export class NotHtmlError extends Error {}/** Runs `exit` at the element's end tag, or immediately for elements that have none (e.g. <svg/>). */function scoped(element: Element, enter: () => void, exit: () => void): void { enter(); try { element.onEndTag(exit); } catch { exit(); }}/** Fetches a page and streams it through HTMLRewriter to collect readable article text. */export async function fetchArticle(url: string): Promise<ExtractedArticle> { const res = await fetch(url, { headers: { "User-Agent": USER_AGENT, Accept: "text/html,application/xhtml+xml" }, redirect: "follow", signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!res.ok) { throw new Error(`Upstream responded with HTTP ${res.status}`); } const contentType = res.headers.get("content-type") ?? ""; if (!/html/i.test(contentType)) { throw new NotHtmlError( `Expected an HTML page but got ${contentType || "unknown content type"}` ); } const collector = new ArticleCollector(); const rewriter = new HTMLRewriter() .on(SKIP_SELECTOR, { element: (el) => scoped( el, () => collector.enterSkip(), () => collector.exitSkip() ), }) .on(CONTAINER_SELECTOR, { element: (el) => scoped( el, () => collector.enterContainer(), () => collector.exitContainer() ), }) .on("title", { element: (el) => scoped( el, () => collector.enterTitle(), () => collector.exitTitle() ), }) .on(BLOCK_SELECTOR, { element: (el) => scoped( el, () => collector.startBlock(el.tagName), () => collector.endBlock() ), }) .onDocument({ text: (chunk) => collector.text(chunk.text) }); await rewriter.transform(res).arrayBuffer(); return collector.result();}Deployment
Deploy
The deploy flow provisions the article-to-audio-cache R2 bucket and the AI binding from wrangler.json. For a manual deploy, create the bucket first:
npx wrangler r2 bucket create article-to-audio-cache
npx wrangler deployTest your deployment
curl -sD - -o article.mp3 "https://your-worker.workers.dev/listen?url=https://blog.cloudflare.com/markdown-for-agents/"Run it twice: the second response has X-Cache: HIT.
Local Development
cd apps/experiments/article-to-audio
npm install
npm run devcurl "http://localhost:8787/text?url=https://blog.cloudflare.com/markdown-for-agents/"
curl -o article.mp3 "http://localhost:8787/listen?url=https://blog.cloudflare.com/markdown-for-agents/"R2 is simulated locally. The AI binding always runs remotely, so you need a logged-in Wrangler session.
Configuration
{
"ai": { "binding": "AI" },
"r2_buckets": [{ "binding": "AUDIO_CACHE", "bucket_name": "article-to-audio-cache" }]
}Tune CHUNK_CHARS, MAX_CHUNKS, TTS_MODELS, and the HTMLRewriter selectors in src/constants/defaults.ts.
Cloudflare Features Used
- Workers - Edge compute runtime
- HTMLRewriter - Streaming HTML parsing for text extraction
- Workers AI - Deepgram Aura-2 text-to-speech
- R2 - Cache for generated MP3 files