# Article to Audio

URL: /docs/experiments/article-to-audio
> Turn any article into MP3: HTMLRewriter extracts text, Workers AI Deepgram Aura-2 narrates it, R2 caches the audio
Tags: ai, workers-ai, tts, audio, htmlrewriter, r2, deepgram
Bindings: AI, R2
Markdown: /llms.mdx/experiments/article-to-audio/content.md



<Callout type="warning">
  This is an experimental Worker. Use it as a starting point for your own projects.
</Callout>

A "Listen to this article" endpoint for any URL. The Worker streams the page through [HTMLRewriter](https://developers.cloudflare.com/workers/runtime-apis/html-rewriter/) to pull out readable text, synthesizes speech with [Workers AI](https://developers.cloudflare.com/workers-ai/) Deepgram Aura-2, and caches the MP3 in [R2](https://developers.cloudflare.com/r2/) so repeat listens are instant.

## Features [#features]

* Readable-text extraction with HTMLRewriter: prefers `<article>` / `<main>`, collects `<p>` and `<h1>`–`<h3>`, skips `nav`, `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-en` or `@cf/deepgram/aura-2-es`
* MP3 cached in R2 under a SHA-256 key of model + language + URL, with an `X-Cache: HIT|MISS` header
* `/text` debug endpoint shows exactly what will be spoken

## API Reference [#api-reference]

### GET /listen [#get-listen]

Synthesize an article as MP3. Returns `audio/mpeg`.

<TypeTable
  type="{
  url: {
    description: &#x22;Article URL. Must use `http://` or `https://`.&#x22;,
    type: &#x22;string&#x22;,
    required: true,
  },
  lang: {
    description: &#x22;`en` (default, `@cf/deepgram/aura-2-en`) or `es` (`@cf/deepgram/aura-2-es`).&#x22;,
    type: &#x22;string&#x22;,
    required: false,
  },
}"
/>

#### Response headers [#response-headers]

* `Content-Type: audio/mpeg`
* `X-Cache: HIT` (served from R2) or `MISS` (synthesized for this request)
* `Cache-Control: public, max-age=86400`

#### Example Request [#example-request]

```bash
curl -o article.mp3 "https://your-worker.workers.dev/listen?url=https://blog.cloudflare.com/markdown-for-agents/"
```

Embed it directly in a page:

```html
<audio controls src="https://your-worker.workers.dev/listen?url=https://example.com/post"></audio>
```

### GET /text [#get-text]

Return the extracted text without synthesizing audio. Same `url` parameter as `/listen`.

#### Example Response [#example-response]

```json
{
  "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&#x60; would make. &#x2A;*`truncated`** is `true` when the article was longer than the 6,000-character cap.

#### Error Responses [#error-responses]

```json
{ "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 [#implementation-details]

<Steps>
  <Step>
    ### Check the cache [#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.
  </Step>

  <Step>
    ### Extract readable text [#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.
  </Step>

  <Step>
    ### Chunk on sentence boundaries [#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.
  </Step>

  <Step>
    ### Synthesize and store [#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.
  </Step>
</Steps>

## Use Cases [#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 [#limitations]

* **Length cap**: Only the first \~6,000 characters (6 chunks, roughly 6 minutes of audio) are read; `/text` reports `truncated`
* **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 [#use-in-your-project]

<UseInYourProject
  experiment="article-to-audio"
  bindings="[&#x22;Workers AI (AI binding)&#x22;, &#x22;R2 bucket (AUDIO_CACHE)&#x22;]"
  platform="[&#x22;HTMLRewriter&#x22;, &#x22;Workers AI&#x22;, &#x22;R2&#x22;, &#x22;Web Crypto&#x22;, &#x22;Intl.Segmenter&#x22;]"
  files="[
  &#x22;src/lib/extract.ts&#x22;,
  &#x22;src/lib/text.ts&#x22;,
  &#x22;src/lib/tts.ts&#x22;,
  &#x22;src/lib/cache.ts&#x22;,
  &#x22;src/constants/defaults.ts&#x22;,
  &#x22;src/types/article.ts&#x22;,
  &#x22;examples/usage.ts&#x22;,
]"
  sources="[{&#x22;path&#x22;:&#x22;src/lib/extract.ts&#x22;,&#x22;title&#x22;:&#x22;extract.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import {\n  BLOCK_SELECTOR,\n  CONTAINER_SELECTOR,\n  FETCH_TIMEOUT_MS,\n  SKIP_SELECTOR,\n  USER_AGENT,\n} from \&#x22;../constants/defaults\&#x22;;\nimport type { ExtractedArticle } from \&#x22;../types/article\&#x22;;\nimport { ArticleCollector } from \&#x22;./text\&#x22;;\n\nexport class NotHtmlError extends Error {}\n\n/** Runs `exit` at the element's end tag, or immediately for elements that have none (e.g. <svg/>). */\nfunction scoped(element: Element, enter: () => void, exit: () => void): void {\n  enter();\n  try {\n    element.onEndTag(exit);\n  } catch {\n    exit();\n  }\n}\n\n/** Fetches a page and streams it through HTMLRewriter to collect readable article text. */\nexport async function fetchArticle(url: string): Promise<ExtractedArticle> {\n  const res = await fetch(url, {\n    headers: { \&#x22;User-Agent\&#x22;: USER_AGENT, Accept: \&#x22;text/html,application/xhtml+xml\&#x22; },\n    redirect: \&#x22;follow\&#x22;,\n    signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n  });\n  if (!res.ok) {\n    throw new Error(`Upstream responded with HTTP ${res.status}`);\n  }\n  const contentType = res.headers.get(\&#x22;content-type\&#x22;) ?? \&#x22;\&#x22;;\n  if (!/html/i.test(contentType)) {\n    throw new NotHtmlError(\n      `Expected an HTML page but got ${contentType || \&#x22;unknown content type\&#x22;}`\n    );\n  }\n\n  const collector = new ArticleCollector();\n  const rewriter = new HTMLRewriter()\n    .on(SKIP_SELECTOR, {\n      element: (el) =>\n        scoped(\n          el,\n          () => collector.enterSkip(),\n          () => collector.exitSkip()\n        ),\n    })\n    .on(CONTAINER_SELECTOR, {\n      element: (el) =>\n        scoped(\n          el,\n          () => collector.enterContainer(),\n          () => collector.exitContainer()\n        ),\n    })\n    .on(\&#x22;title\&#x22;, {\n      element: (el) =>\n        scoped(\n          el,\n          () => collector.enterTitle(),\n          () => collector.exitTitle()\n        ),\n    })\n    .on(BLOCK_SELECTOR, {\n      element: (el) =>\n        scoped(\n          el,\n          () => collector.startBlock(el.tagName),\n          () => collector.endBlock()\n        ),\n    })\n    .onDocument({ text: (chunk) => collector.text(chunk.text) });\n\n  await rewriter.transform(res).arrayBuffer();\n  return collector.result();\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/text.ts&#x22;,&#x22;title&#x22;:&#x22;text.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { CHUNK_CHARS, MAX_CHUNKS } from \&#x22;../constants/defaults\&#x22;;\nimport type { ExtractedArticle, PreparedArticle } from \&#x22;../types/article\&#x22;;\n\nconst NAMED_ENTITIES: Record<string, string> = {\n  amp: \&#x22;&\&#x22;,\n  lt: \&#x22;<\&#x22;,\n  gt: \&#x22;>\&#x22;,\n  quot: '\&#x22;',\n  apos: \&#x22;'\&#x22;,\n  nbsp: \&#x22; \&#x22;,\n  mdash: \&#x22;—\&#x22;,\n  ndash: \&#x22;–\&#x22;,\n  hellip: \&#x22;…\&#x22;,\n  lsquo: \&#x22;‘\&#x22;,\n  rsquo: \&#x22;’\&#x22;,\n  ldquo: \&#x22;“\&#x22;,\n  rdquo: \&#x22;”\&#x22;,\n};\n\n/** HTMLRewriter text chunks are raw HTML, so entities must be decoded manually. */\nexport function decodeEntities(input: string): string {\n  return input.replace(/&(#x[0-9a-f]+|#\\d+|[a-z]+);/gi, (match, entity: string) => {\n    if (entity[0] === \&#x22;#\&#x22;) {\n      const code =\n        entity[1] === \&#x22;x\&#x22; || entity[1] === \&#x22;X\&#x22;\n          ? parseInt(entity.slice(2), 16)\n          : parseInt(entity.slice(1), 10);\n      return Number.isFinite(code) && code > 0 && code <= 0x10ffff\n        ? String.fromCodePoint(code)\n        : match;\n    }\n    return NAMED_ENTITIES[entity.toLowerCase()] ?? match;\n  });\n}\n\nexport function normalizeWhitespace(input: string): string {\n  return input.replace(/\\s+/g, \&#x22; \&#x22;).trim();\n}\n\n/**\n * Collects readable text from HTMLRewriter callbacks. Kept free of HTMLRewriter types so it can be\n * unit-tested by replaying element/text events.\n */\nexport class ArticleCollector {\n  private skipDepth = 0;\n  private containerDepth = 0;\n  private titleDepth = 0;\n  private blockTags: string[] = [];\n  private buffer = \&#x22;\&#x22;;\n  private titleText = \&#x22;\&#x22;;\n  private firstHeading: string | null = null;\n  private containerBlocks: string[] = [];\n  private allBlocks: string[] = [];\n\n  enterSkip(): void {\n    this.skipDepth++;\n  }\n\n  exitSkip(): void {\n    this.skipDepth = Math.max(0, this.skipDepth - 1);\n  }\n\n  enterContainer(): void {\n    this.containerDepth++;\n  }\n\n  exitContainer(): void {\n    this.flush();\n    this.containerDepth = Math.max(0, this.containerDepth - 1);\n  }\n\n  enterTitle(): void {\n    this.titleDepth++;\n  }\n\n  exitTitle(): void {\n    this.titleDepth = Math.max(0, this.titleDepth - 1);\n  }\n\n  startBlock(tag: string): void {\n    if (this.skipDepth > 0) return;\n    this.flush();\n    this.blockTags.push(tag.toLowerCase());\n  }\n\n  endBlock(): void {\n    if (this.blockTags.length === 0) return;\n    this.flush();\n    this.blockTags.pop();\n  }\n\n  text(chunk: string): void {\n    if (this.titleDepth > 0) this.titleText += chunk;\n    if (this.skipDepth > 0 || this.blockTags.length === 0) return;\n    this.buffer += chunk;\n  }\n\n  private flush(): void {\n    const tag = this.blockTags[this.blockTags.length - 1];\n    let block = normalizeWhitespace(decodeEntities(this.buffer));\n    this.buffer = \&#x22;\&#x22;;\n    if (!block || !tag) return;\n\n    if (tag !== \&#x22;p\&#x22;) {\n      if (tag === \&#x22;h1\&#x22; && !this.firstHeading) this.firstHeading = block;\n      if (!/[.!?:;…]$/.test(block)) block += \&#x22;.\&#x22;;\n    }\n    this.allBlocks.push(block);\n    if (this.containerDepth > 0) this.containerBlocks.push(block);\n  }\n\n  /** Prefers text inside <article>/<main>; falls back to every block on the page. */\n  result(): ExtractedArticle {\n    this.flush();\n    const blocks = this.containerBlocks.length > 0 ? this.containerBlocks : this.allBlocks;\n    const title = normalizeWhitespace(decodeEntities(this.titleText)) || this.firstHeading;\n    return { title: title || null, text: blocks.join(\&#x22;\\n\&#x22;) };\n  }\n}\n\nfunction splitLongSentence(sentence: string, maxChars: number): string[] {\n  const pieces: string[] = [];\n  let current = \&#x22;\&#x22;;\n  for (const word of sentence.split(\&#x22; \&#x22;)) {\n    for (let i = 0; i < word.length; i += maxChars) {\n      const part = word.slice(i, i + maxChars);\n      if (current && current.length + 1 + part.length > maxChars) {\n        pieces.push(current);\n        current = part;\n      } else {\n        current = current ? `${current} ${part}` : part;\n      }\n    }\n  }\n  if (current) pieces.push(current);\n  return pieces;\n}\n\n/** Splits text into sentence-bounded chunks of at most maxChars, keeping at most maxChunks. */\nexport function chunkText(\n  text: string,\n  maxChars = CHUNK_CHARS,\n  maxChunks = MAX_CHUNKS\n): { chunks: string[]; truncated: boolean } {\n  const input = normalizeWhitespace(text).slice(0, maxChars * maxChunks * 2);\n  const segmenter = new Intl.Segmenter(undefined, { granularity: \&#x22;sentence\&#x22; });\n  const chunks: string[] = [];\n  let current = \&#x22;\&#x22;;\n\n  for (const { segment } of segmenter.segment(input)) {\n    const sentence = segment.trim();\n    if (!sentence) continue;\n    const pieces = sentence.length > maxChars ? splitLongSentence(sentence, maxChars) : [sentence];\n\n    for (const piece of pieces) {\n      if (current && current.length + 1 + piece.length > maxChars) {\n        chunks.push(current);\n        if (chunks.length === maxChunks) return { chunks, truncated: true };\n        current = piece;\n      } else {\n        current = current ? `${current} ${piece}` : piece;\n      }\n    }\n  }\n\n  if (current) chunks.push(current);\n  return { chunks, truncated: input.length < normalizeWhitespace(text).length };\n}\n\nexport function prepareArticle(article: ExtractedArticle): PreparedArticle {\n  const { chunks, truncated } = chunkText(article.text);\n  const text = chunks.join(\&#x22; \&#x22;);\n  return { title: article.title, text, chunks, chars: text.length, truncated };\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/tts.ts&#x22;,&#x22;title&#x22;:&#x22;tts.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { DEFAULT_LANG, SUPPORTED_LANGS, TTS_MODELS } from \&#x22;../constants/defaults\&#x22;;\nimport type { Lang } from \&#x22;../types/article\&#x22;;\n\nexport function validateLang(input: string | undefined): Lang | null {\n  const lang = (input ?? DEFAULT_LANG).trim().toLowerCase();\n  return (SUPPORTED_LANGS as readonly string[]).includes(lang) ? (lang as Lang) : null;\n}\n\n/** Aura is typed as returning a string but actually resolves to a ReadableStream of MP3 bytes. */\nexport async function readAudio(output: unknown): Promise<Uint8Array> {\n  if (output instanceof Uint8Array) return output;\n  if (output instanceof ArrayBuffer) return new Uint8Array(output);\n  const body = output instanceof Response ? output.body : output;\n  if (body instanceof ReadableStream) {\n    const bytes = new Uint8Array(await new Response(body).arrayBuffer());\n    if (bytes.length > 0) return bytes;\n  }\n  throw new Error(\&#x22;TTS model returned no audio\&#x22;);\n}\n\nexport function concatBytes(parts: Uint8Array[]): Uint8Array {\n  const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));\n  let offset = 0;\n  for (const part of parts) {\n    out.set(part, offset);\n    offset += part.length;\n  }\n  return out;\n}\n\n/** Synthesizes each chunk in parallel and joins the MP3 segments in order. */\nexport async function synthesizeSpeech(ai: Ai, chunks: string[], lang: Lang): Promise<Uint8Array> {\n  const parts = await Promise.all(\n    chunks.map(async (text) => readAudio(await ai.run(TTS_MODELS[lang], { text, encoding: \&#x22;mp3\&#x22; })))\n  );\n  return concatBytes(parts);\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/cache.ts&#x22;,&#x22;title&#x22;:&#x22;cache.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { CACHE_KEY_PREFIX } from \&#x22;../constants/defaults\&#x22;;\n\n/** R2 key for a synthesized article: SHA-256 of model, lang, and URL. */\nexport async function cacheKey(url: string, lang: string, model: string): Promise<string> {\n  const digest = await crypto.subtle.digest(\n    \&#x22;SHA-256\&#x22;,\n    new TextEncoder().encode(`${model}\\n${lang}\\n${url}`)\n  );\n  const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, \&#x22;0\&#x22;)).join(\n    \&#x22;\&#x22;\n  );\n  return `${CACHE_KEY_PREFIX}${hex}.mp3`;\n}&#x22;},{&#x22;path&#x22;:&#x22;src/constants/defaults.ts&#x22;,&#x22;title&#x22;:&#x22;defaults.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;/** Deepgram Aura-2 streams raw MP3 frames (no ID3/Xing header), so segments concatenate cleanly. */\nexport const TTS_MODELS = {\n  en: \&#x22;@cf/deepgram/aura-2-en\&#x22;,\n  es: \&#x22;@cf/deepgram/aura-2-es\&#x22;,\n} as const;\nexport const SUPPORTED_LANGS = [\&#x22;en\&#x22;, \&#x22;es\&#x22;] as const;\nexport const DEFAULT_LANG = \&#x22;en\&#x22;;\n\n/**\n * Aura rejects input over 2,000 characters. Chunks run in parallel and the longest one dominates\n * latency, so ~1k-char chunks keep a 6-chunk article around 30s.\n */\nexport const CHUNK_CHARS = 1_000;\nexport const MAX_CHUNKS = 6;\nexport const MAX_CHARS = CHUNK_CHARS * MAX_CHUNKS;\n\nexport const FETCH_TIMEOUT_MS = 15_000;\nexport const USER_AGENT =\n  \&#x22;Mozilla/5.0 (compatible; ArticleToAudio/1.0; +https://workers.cloudflare.com)\&#x22;;\nexport const ALLOWED_SCHEMES = [\&#x22;http:\&#x22;, \&#x22;https:\&#x22;] as const;\n\nexport const CACHE_KEY_PREFIX = \&#x22;audio/\&#x22;;\nexport const AUDIO_CACHE_CONTROL = \&#x22;public, max-age=86400\&#x22;;\n\nexport const SKIP_SELECTOR = \&#x22;nav, footer, aside, script, style, noscript, template, svg, form\&#x22;;\nexport const CONTAINER_SELECTOR = \&#x22;article, main, [role=main]\&#x22;;\nexport const BLOCK_SELECTOR = \&#x22;p, h1, h2, h3\&#x22;;&#x22;},{&#x22;path&#x22;:&#x22;src/types/article.ts&#x22;,&#x22;title&#x22;:&#x22;article.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import type { SUPPORTED_LANGS } from \&#x22;../constants/defaults\&#x22;;\n\nexport type Lang = (typeof SUPPORTED_LANGS)[number];\n\nexport interface ExtractedArticle {\n  title: string | null;\n  text: string;\n}\n\nexport interface PreparedArticle extends ExtractedArticle {\n  chunks: string[];\n  chars: number;\n  truncated: boolean;\n}\n\nexport interface TextResponse {\n  url: string;\n  title: string | null;\n  text: string;\n  chars: number;\n  chunks: number;\n  truncated: boolean;\n}&#x22;},{&#x22;path&#x22;:&#x22;examples/usage.ts&#x22;,&#x22;title&#x22;:&#x22;usage.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;/**\n * Minimal paste example for docs (\&#x22;Use in your project\&#x22;).\n * Not part of the Worker runtime — copy into your own project.\n *\n * Typical API: GET /listen?url=https://blog.cloudflare.com/markdown-for-agents/\n */\nimport { fetchArticle } from \&#x22;../src/lib/extract\&#x22;;\nimport { prepareArticle } from \&#x22;../src/lib/text\&#x22;;\nimport { synthesizeSpeech } from \&#x22;../src/lib/tts\&#x22;;\nimport { cacheKey } from \&#x22;../src/lib/cache\&#x22;;\nimport { TTS_MODELS } from \&#x22;../src/constants/defaults\&#x22;;\n\nexport default {\n  async fetch(request: Request, env: { AI: Ai; AUDIO_CACHE: R2Bucket }): Promise<Response> {\n    const url = new URL(request.url).searchParams.get(\&#x22;url\&#x22;);\n    if (!url) return Response.json({ error: \&#x22;Missing url\&#x22;, code: \&#x22;INVALID_URL\&#x22; }, { status: 400 });\n\n    const key = await cacheKey(url, \&#x22;en\&#x22;, TTS_MODELS.en);\n    const cached = await env.AUDIO_CACHE.get(key);\n    if (cached) return new Response(cached.body, { headers: { \&#x22;Content-Type\&#x22;: \&#x22;audio/mpeg\&#x22; } });\n\n    const article = prepareArticle(await fetchArticle(url));\n    const audio = await synthesizeSpeech(env.AI, article.chunks, \&#x22;en\&#x22;);\n    await env.AUDIO_CACHE.put(key, audio, { httpMetadata: { contentType: \&#x22;audio/mpeg\&#x22; } });\n    return new Response(audio, { headers: { \&#x22;Content-Type\&#x22;: \&#x22;audio/mpeg\&#x22; } });\n  },\n};&#x22;}]"
/>

## Deployment [#deployment]

<Steps>
  <Step>
    ### Click the deploy button [#click-the-deploy-button]

    [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shrinathsnayak/cloudflare-experiments/tree/main/apps/experiments/article-to-audio)
  </Step>

  <Step>
    ### Deploy [#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:

    ```bash
    npx wrangler r2 bucket create article-to-audio-cache
    npx wrangler deploy
    ```
  </Step>

  <Step>
    ### Test your deployment [#test-your-deployment]

    ```bash
    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`.
  </Step>
</Steps>

## Local Development [#local-development]

```bash
cd apps/experiments/article-to-audio
npm install
npm run dev
```

```bash
curl "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 [#configuration]

```json
{
  "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 [#cloudflare-features-used]

* **[Workers](https://developers.cloudflare.com/workers/)** - Edge compute runtime
* **[HTMLRewriter](https://developers.cloudflare.com/workers/runtime-apis/html-rewriter/)** - Streaming HTML parsing for text extraction
* **[Workers AI](https://developers.cloudflare.com/workers-ai/models/aura-2-en/)** - Deepgram Aura-2 text-to-speech
* **[R2](https://developers.cloudflare.com/r2/)** - Cache for generated MP3 files

## Next Steps [#next-steps]

<Cards>
  <Card title="Speech to Text Transcriber" href="/experiments/speech-to-text-transcriber">
    The reverse direction: audio to text with Whisper
  </Card>

  <Card title="R2 Storage" href="/experiments/r2-storage">
    More on R2 bucket bindings
  </Card>

  <Card title="GitHub Repository" href="https://github.com/shrinathsnayak/cloudflare-experiments/tree/main/apps/experiments/article-to-audio">
    View the complete source code
  </Card>
</Cards>
