> For clean Markdown of any page, append `.md` to the page URL.
> For a complete documentation index, see https://docs.sarvam.ai/llms.txt.
> For full documentation content in one file, see https://docs.sarvam.ai/llms-full.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.sarvam.ai/_mcp/server.

# Podcast Generator

> Build a full-stack app that converts PDF documents into natural two-host podcasts using Sarvam Document Digitization, a Sarvam chat model, and Bulbul v3 TTS, in 11 languages (10 Indian + English).

## **Overview**

This cookbook builds a full-stack app that turns any PDF into a listenable, two-host audio podcast. It chains together three Sarvam AI APIs, **Document Digitization** to extract the document's text, a **Sarvam chat model** to turn that text into a conversational script, and **Bulbul v3 TTS** to voice each line, and runs the whole pipeline as a background job so the UI stays responsive while a long document is processed.

**What you'll build:**

<ul>
  <li>
    A Next.js app where a user uploads a PDF and picks a target language
  </li>

  <li>
    A background pipeline (PDF → text → script → audio) that runs as durable, resumable steps
  </li>

  <li>
    A host/guest podcast script generated directly in the target language
  </li>

  <li>
    Per-line audio segments stitched into a playable episode with a transcript view
  </li>

  <li>
    Support for 11 languages (10 Indian + English), including auto-summarization for documents too long to fit in a single prompt
  </li>
</ul>

Browse the full source for this app in the sarvam-ai-cookbook repo.

Jump to Clone and Install to set this demo up on your machine.

### Business Value

<ul>
  <li>
    Turn reports, research papers, and policy documents into audio for commuting or accessibility
  </li>

  <li>
    Localize long-form written content into regional languages without a human translator
  </li>

  <li>
    Repurpose existing documentation or course material into a more engaging audio format
  </li>
</ul>

Convert textbook chapters or study notes into a podcast-style discussion, in the student's preferred language.

Turn internal reports or whitepapers into a quick audio briefing for people who'd rather listen than read.

Repackage long-form articles as audio, opening up an entirely new distribution channel from existing text content.

## **How It Works**

The uploaded PDF is sent to Sarvam's **Document Digitization** API, which extracts its text as Markdown, preserving reading order and table structure.

If the extracted text is too long to fit in a single prompt, it's split into chunks and each chunk is summarized by a Sarvam chat model before moving on, so very large documents don't break the pipeline.

The (possibly summarized) text is sent to a Sarvam chat model with a prompt that asks for a natural **host/guest** conversation, returned as structured JSON, directly in the target language.

Each line of the script is converted to speech with **Bulbul v3 TTS**, using a distinct voice for the host and the guest, then assembled into a single playable episode with a synced transcript.

All four steps run inside a background job orchestrated by [Inngest](https://inngest.com), so a slow TTS call or a large document doesn't block the request or time out the browser.

## **1. Prerequisites**

<ul>
  <li>
    Node.js 18 or higher
  </li>

  <li>
    A Sarvam API key, sign up on the 

    <a href="https://dashboard.sarvam.ai/" target="_blank">Sarvam AI Dashboard</a>

     to get one
  </li>

  <li>
    A free 

    <a href="https://inngest.com/" target="_blank">Inngest</a>

     account, used to run the PDF-to-podcast pipeline as a durable background job
  </li>

  <li>
    A PDF you want to turn into a podcast
  </li>
</ul>

Redis and UploadThing are also referenced in the app's configuration but are optional for local development. See [Configure Environment Variables](#3-configure-environment-variables) below for what each one actually does out of the box.

## **2. Clone and Install**

```bash
git clone https://github.com/sarvamai/sarvam-ai-cookbook.git
cd sarvam-ai-cookbook/examples/sarvam-podcast-generator
npm install
```

```bash
git clone https://github.com/sarvamai/sarvam-ai-cookbook.git
cd sarvam-ai-cookbook/examples/sarvam-podcast-generator
npm install
```

## **3. Configure Environment Variables**

Create a `.env` file in the project root:

```env
SARVAM_API_KEY=your_sarvam_api_key_here
REDIS_URL=your_redis_url
UPLOADTHING_TOKEN=your_uploadthing_token
INNGEST_SIGNING_KEY=your_inngest_signing_key
```

As shipped, the app's job store always keeps job state in an in-memory map, and its audio upload helper always stores generated audio as in-memory base64 data URLs, regardless of what you set for `REDIS_URL` or `UPLOADTHING_TOKEN`. This is fine for local, single-process development, but it won't survive a server restart or work across multiple serverless instances. Wire up real Redis and UploadThing clients in `lib/job-store.ts` and `lib/upload-audio.ts` before deploying.

Because audio is stored as raw base64 instead of a real file URL, each TTS segment's "URL" is actually several hundred KB of inline data, and Inngest persists that as the step's return value. On long podcasts this can occasionally hit Inngest's per-step output size limit (`output_too_large` in the `inngest dev` logs); Inngest's automatic step retry recovers from it with no data loss, just extra latency. A real UploadThing token resolves this at the source, since steps would then store a short URL instead of the raw audio.

## **4. Run It**

Start the Next.js app and the Inngest dev server in two terminals:

```bash
npm run dev
```

```bash
npm run inngest
```

Open [http://localhost:3000](http://localhost:3000), pick a target language, upload a PDF, and watch the status update as the pipeline runs. The Inngest dev dashboard at [http://localhost:8288](http://localhost:8288) shows every step, script generation, each TTS call, retries, in real time, which is the fastest way to see what the pipeline is actually doing.

## **5. Pipeline Walkthrough**

### Step 1: Parse the PDF

`app/api/parse-pdf/route.ts` drives Document Digitization's job API directly: create a job, get a presigned upload URL, upload the PDF, start the job, poll until it completes, then download and unzip the Markdown output.

```typescript
try {
  const createRes = await fetch(BASE_URL, {
    method: 'POST',
    headers: HEADERS,
    body: JSON.stringify({
      job_parameters: { output_format: 'md', language: 'en-IN' },
    }),
  });
  if (!createRes.ok) {
    throw new Error(`Failed to create job: ${await createRes.text()}`);
  }
  const { job_id } = await createRes.json();

  // Get a presigned URL and upload the PDF to it
  await fetch(`${BASE_URL}/upload-files`, {
    method: 'POST',
    headers: HEADERS,
    body: JSON.stringify({ job_id, files: [file.name] }),
  });
  // ... PUT the file to the presigned URL ...

  // Start the job
  const startRes = await fetch(`${BASE_URL}/${job_id}/start`, {
    method: 'POST',
    headers: HEADERS,
    body: JSON.stringify({}),
  });
  if (!startRes.ok) {
    throw new Error(`Failed to start job: ${await startRes.text()}`);
  }

  // Poll until the job reaches a terminal state, then download the result
  await pollStatus(job_id);
  const downloadRes = await fetch(`${BASE_URL}/${job_id}/download-files`, {
    method: 'POST',
    headers: HEADERS,
    body: JSON.stringify({}),
  });
  if (!downloadRes.ok) {
    throw new Error(`Failed to get download URLs: ${await downloadRes.text()}`);
  }
  const downloadData = await downloadRes.json();
} catch (error) {
  console.error('Document Intelligence error:', error);
  return NextResponse.json(
    { error: error instanceof Error ? error.message : 'Failed to process PDF' },
    { status: 500 }
  );
}
```

`pollStatus` throws if the job reaches a `Failed` state, so that failure is caught here too. The route returns the caught error as a `500` with a JSON `error` field instead of crashing the request.

This app calls the REST endpoints directly so it can unzip the output itself. The [`sarvamai` SDK](/api/api-guides-tutorials/document-digitization/overview#quick-start) wraps the same job lifecycle behind `create_job()` / `upload_file()` / `start()` / `wait_until_complete()` / `download_output()`, which is simpler to use if you're starting a pipeline from scratch.

Sarvam's role: This step runs entirely on Sarvam's Document Digitization API. Sarvam does the actual PDF parsing, layout analysis, table structure, and reading order, and hands back clean Markdown; the route above is just a REST client driving that job's create → upload → start → poll → download lifecycle.

### Step 2: Generate the script

`inngest/podcast-generation.ts` sends the extracted text to `sarvam-105b` via the Chat Completion API, with a prompt that requests a JSON object containing a `script` array of `{speaker, text}` turns:

```typescript
try {
  const response = await fetch('https://api.sarvam.ai/v1/chat/completions', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      messages: [{ role: 'user', content: prompt }],
      model: 'sarvam-105b',
      temperature: 0.7,
      max_tokens: 4096,
    }),
  });

  if (!response.ok) {
    throw new Error(`Sarvam API error: ${response.status} - ${await response.text()}`);
  }

  const data = await response.json();
  if (!data.choices?.[0]?.message?.content) {
    throw new Error('No content received from Sarvam API');
  }

  return data.choices[0].message.content;
} catch (error) {
  console.error('Error calling Sarvam API:', error);
  throw error;
}
```

This is itself wrapped in a helper (`callSarvamChat`) that also retries on a `429` with exponential backoff and retries once more on a bare network failure before giving up, so a transient rate limit or dropped connection doesn't fail the whole pipeline run. The prompt asks for at least 10-15 host/guest exchanges and instructs the model to reply with **only** JSON, no Markdown code fences, which the app strips defensively anyway before calling `JSON.parse`. If the document's text is too large for a single prompt, it's chunked and each chunk is summarized by the same chat model first, and the summaries (not the raw text) become the input to script generation.

This is the same [Chat Completion API](/api/api-guides-tutorials/chat-completion/overview) used for regular conversational completions, there's no separate "structured output" endpoint. The app gets JSON back by asking for it in the prompt and defensively parsing the response.

Sarvam's role: Sarvam's `sarvam-105b` chat model is what actually writes the podcast. It turns the (possibly summarized) document text into a host/guest conversation in the target language and returns the structured JSON the app parses; the same model also produces the chunk summaries for oversized documents earlier in the pipeline.

### Step 3: Voice each line

Every script line is sent to Bulbul v3, with the host and guest mapped to different speakers:

```typescript
try {
  const response = await fetch('https://api.sarvam.ai/text-to-speech', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      'api-subscription-key': apiKey,
    },
    body: JSON.stringify({
      text,
      target_language_code: language,
      speaker: selectedVoice,
      pace: 1.0,
      temperature: 0.6,
      speech_sample_rate: 22050,
      model: 'bulbul:v3',
    }),
  });

  if (!response.ok) {
    throw new Error(`TTS API error: ${response.status} - ${await response.text()}`);
  }

  const data = await response.json();
  if (!data.audios?.[0]) {
    throw new Error('No audio data received from TTS API');
  }

  return data.audios[0];
} catch (error) {
  console.error('Error calling TTS API:', error);
  throw error;
}
```

Like the chat call above, this is wrapped in a helper (`textToSpeech`) that also retries on `429`s with backoff. The app maps `host` to the `priya` voice and `guest` to `shubh` for every supported language, so the two speakers stay distinguishable throughout the episode. See [Available Options](#available-options) below for the full voice list if you want to customize this.

Sarvam's role: Every audio segment is synthesized by Sarvam's Bulbul v3 text-to-speech model. Sarvam handles the actual voice generation for each line, host and guest included, this step is what turns the written script into the podcast's audio.

### Step 4: Orchestrate with Inngest

The three steps above run inside one Inngest function (`generatePodcastFunction`), with each unit of work wrapped in `step.run(...)` so Inngest can retry it independently on failure without redoing the rest of the pipeline:

```typescript
for (let i = 0; i < script.length; i++) {
  const segment = script[i];
  const audioResult = await step.run(`generate-audio-segment-${i + 1}`, async () => {
    try {
      const audioBase64 = await textToSpeech(segment.text, targetLanguage, segment.speaker);

      // Upload the generated audio and get back a short-lived URL
      const { url: uploadThingUrl } = await uploadBase64Audio(audioBase64);

      return {
        speaker: segment.speaker,
        text: segment.text,
        audioUrl: uploadThingUrl,
        success: true,
      };
    } catch (error) {
      console.error(`Error generating audio for segment ${i + 1}:`, error);

      // Don't rethrow: mark this segment as failed so the whole job doesn't fail.
      // Inngest still retries the step on its own for transient/network errors.
      return {
        speaker: segment.speaker,
        text: segment.text,
        audioUrl: '',
        success: false,
      };
    }
  });
  audioGenerationResults.push(audioResult);

  // A durable sleep between calls, resumable, unlike a plain setTimeout
  if (i < script.length - 1) {
    await step.sleep(`wait-after-segment-${i + 1}`, '1.2s');
  }
}
```

`step.sleep` and per-segment `step.run` calls are what make the pipeline resilient: if the process restarts mid-podcast, Inngest resumes from the last completed step instead of starting over. The inner `try/catch` deliberately swallows a single segment's failure instead of rethrowing it, marking that segment `success: false` so the episode still assembles with a few dropped lines rather than failing the whole job. (After the loop, the function does still throw if literally zero segments came back.)

## **Available Options**

### Supported Languages

| Language        | Code    |
| --------------- | ------- |
| English (India) | `en-IN` |
| Hindi           | `hi-IN` |
| Tamil           | `ta-IN` |
| Telugu          | `te-IN` |
| Bengali         | `bn-IN` |
| Gujarati        | `gu-IN` |
| Marathi         | `mr-IN` |
| Malayalam       | `ml-IN` |
| Kannada         | `kn-IN` |
| Punjabi         | `pa-IN` |
| Odia            | `od-IN` |

### Default Voices (Bulbul v3)

| Role  | Voice   |
| ----- | ------- |
| Host  | `priya` |
| Guest | `shubh` |

Bulbul v3 supports a much larger set of speakers. See [Text-to-Speech](/api/api-guides-tutorials/text-to-speech/overview) for the full list if you want each language to use a different voice pair instead of the same one across all 11.

## **Customization**

### Use different voices per language

`VOICE_MAPPINGS` in `inngest/podcast-generation.ts` maps every language to the same `{ host: 'priya', guest: 'shubh' }` pair. Give a language its own entry to use a different voice for it:

```typescript
const VOICE_MAPPINGS: { [key: string]: { host: string; guest: string } } = {
  'hi-IN': { host: 'priya', guest: 'shubh' },
  'ta-IN': { host: 'ishita', guest: 'karun' },
  // ...
};
```

### Change how long the podcast is

The script-generation prompt in `generatePodcastScript` asks for an "8-10 minute" conversation with "10-15 dialog exchanges." Adjust that wording, and the `script.length < 10` padding logic just below it, to target a shorter or longer episode.

### Move off the in-memory stubs

Before deploying beyond local testing, replace the in-memory `Map` in `lib/job-store.ts` with a real Redis client, and the base64 stub in `lib/upload-audio.ts` / `lib/uploadthing.ts` with a real UploadThing client. Both files are already structured around `REDIS_URL` and `UPLOADTHING_TOKEN`, they're just not wired up to real clients in the version shipped in the repo.

## **Tips & Best Practices**

* **Document length**: Very large PDFs can push the extracted text past what fits in a single script-generation prompt. The app automatically chunks and summarizes in that case, but extremely long documents may still lose detail in the process, consider trimming to the most relevant sections first.
* **Rate limiting**: TTS and chat completion calls are spaced out with durable `step.sleep` calls (1.2s between TTS calls, 1.5s between chunk summaries) to stay under rate limits. If you see `429` errors anyway, increase these delays.
* **API key security**: Load `SARVAM_API_KEY` from an environment variable rather than hardcoding it, especially outside local experimentation.
* **Production storage**: Don't ship the in-memory job store or base64 audio stub to production, see [Move off the in-memory stubs](#move-off-the-in-memory-stubs) above.

## **Error Handling**

You may encounter these errors while using the underlying APIs:

* **403 Forbidden** (`invalid_api_key_error`), invalid API key. Use a valid key from the [Sarvam AI Dashboard](https://dashboard.sarvam.ai/).
* **429 Too Many Requests** (`insufficient_quota_error` / `rate_limit_exceeded_error`), credits exhausted or rate limit hit. The app already retries both chat completion and TTS calls with exponential backoff; if it still fails, slow down the pipeline further.
* **422 Unprocessable Entity** (`max_page_limit_exceeded`), the uploaded PDF exceeds Document Digitization's page limit for a single job.
* **500 Internal Server Error** (`internal_server_error`), issue on our servers. Try again later; contact support if persistent.

For the full error-code table and SDK exception reference, see [Errors & Troubleshooting](https://docs.sarvam.ai/api/getting-started/errors-troubleshooting).

## **Additional Resources**

* **Documentation**: [docs.sarvam.ai](https://docs.sarvam.ai)
* **Related APIs**: [Document Digitization](/api/api-guides-tutorials/document-digitization/overview) · [Chat Completion](/api/api-guides-tutorials/chat-completion/overview) · [Text-to-Speech](/api/api-guides-tutorials/text-to-speech/overview)
* **Full source**: [sarvam-podcast-generator on GitHub](https://github.com/sarvamai/sarvam-ai-cookbook/tree/main/examples/sarvam-podcast-generator)
* **Community**: [Join the Discord Community](https://discord.com/invite/5rAsykttcs)

## **Final Notes**

* Keep your API key secure, prefer environment variables over hardcoding.
* The in-memory job store and audio stub are fine for trying the app locally, but need to be swapped for Redis and UploadThing before deploying.
* The pipeline's chunking, retries, and durable sleeps mean it degrades gracefully rather than failing outright on large documents or transient rate limits.

**Keep Building!** 🚀