Podcast Generator

View as Markdown

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:

  • A Next.js app where a user uploads a PDF and picks a target language
  • A background pipeline (PDF → text → script → audio) that runs as durable, resumable steps
  • A host/guest podcast script generated directly in the target language
  • Per-line audio segments stitched into a playable episode with a transcript view
  • Support for 11 languages (10 Indian + English), including auto-summarization for documents too long to fit in a single prompt

Business Value

  • Turn reports, research papers, and policy documents into audio for commuting or accessibility
  • Localize long-form written content into regional languages without a human translator
  • Repurpose existing documentation or course material into a more engaging audio format

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

How It Works

1

Parse the PDF

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

2

Summarize if needed

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.

3

Generate a two-host script

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.

4

Voice every line

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, so a slow TTS call or a large document doesn’t block the request or time out the browser.

1. Prerequisites

  • Node.js 18 or higher
  • A Sarvam API key, sign up on the Sarvam AI Dashboard to get one
  • A free Inngest account, used to run the PDF-to-podcast pipeline as a durable background job
  • A PDF you want to turn into a podcast

Redis and UploadThing are also referenced in the app’s configuration but are optional for local development. See Configure Environment Variables below for what each one actually does out of the box.

2. Clone and Install

$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:

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:

$npm run dev
$npm run inngest

Open 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 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.

1try {
2 const createRes = await fetch(BASE_URL, {
3 method: 'POST',
4 headers: HEADERS,
5 body: JSON.stringify({
6 job_parameters: { output_format: 'md', language: 'en-IN' },
7 }),
8 });
9 if (!createRes.ok) {
10 throw new Error(`Failed to create job: ${await createRes.text()}`);
11 }
12 const { job_id } = await createRes.json();
13
14 // Get a presigned URL and upload the PDF to it
15 await fetch(`${BASE_URL}/upload-files`, {
16 method: 'POST',
17 headers: HEADERS,
18 body: JSON.stringify({ job_id, files: [file.name] }),
19 });
20 // ... PUT the file to the presigned URL ...
21
22 // Start the job
23 const startRes = await fetch(`${BASE_URL}/${job_id}/start`, {
24 method: 'POST',
25 headers: HEADERS,
26 body: JSON.stringify({}),
27 });
28 if (!startRes.ok) {
29 throw new Error(`Failed to start job: ${await startRes.text()}`);
30 }
31
32 // Poll until the job reaches a terminal state, then download the result
33 await pollStatus(job_id);
34 const downloadRes = await fetch(`${BASE_URL}/${job_id}/download-files`, {
35 method: 'POST',
36 headers: HEADERS,
37 body: JSON.stringify({}),
38 });
39 if (!downloadRes.ok) {
40 throw new Error(`Failed to get download URLs: ${await downloadRes.text()}`);
41 }
42 const downloadData = await downloadRes.json();
43} catch (error) {
44 console.error('Document Intelligence error:', error);
45 return NextResponse.json(
46 { error: error instanceof Error ? error.message : 'Failed to process PDF' },
47 { status: 500 }
48 );
49}

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 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:

1try {
2 const response = await fetch('https://api.sarvam.ai/v1/chat/completions', {
3 method: 'POST',
4 headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
5 body: JSON.stringify({
6 messages: [{ role: 'user', content: prompt }],
7 model: 'sarvam-105b',
8 temperature: 0.7,
9 max_tokens: 4096,
10 }),
11 });
12
13 if (!response.ok) {
14 throw new Error(`Sarvam API error: ${response.status} - ${await response.text()}`);
15 }
16
17 const data = await response.json();
18 if (!data.choices?.[0]?.message?.content) {
19 throw new Error('No content received from Sarvam API');
20 }
21
22 return data.choices[0].message.content;
23} catch (error) {
24 console.error('Error calling Sarvam API:', error);
25 throw error;
26}

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 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:

1try {
2 const response = await fetch('https://api.sarvam.ai/text-to-speech', {
3 method: 'POST',
4 headers: {
5 'Content-Type': 'application/json',
6 'Accept': 'application/json',
7 'api-subscription-key': apiKey,
8 },
9 body: JSON.stringify({
10 text,
11 target_language_code: language,
12 speaker: selectedVoice,
13 pace: 1.0,
14 temperature: 0.6,
15 speech_sample_rate: 22050,
16 model: 'bulbul:v3',
17 }),
18 });
19
20 if (!response.ok) {
21 throw new Error(`TTS API error: ${response.status} - ${await response.text()}`);
22 }
23
24 const data = await response.json();
25 if (!data.audios?.[0]) {
26 throw new Error('No audio data received from TTS API');
27 }
28
29 return data.audios[0];
30} catch (error) {
31 console.error('Error calling TTS API:', error);
32 throw error;
33}

Like the chat call above, this is wrapped in a helper (textToSpeech) that also retries on 429s 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 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:

1for (let i = 0; i < script.length; i++) {
2 const segment = script[i];
3 const audioResult = await step.run(`generate-audio-segment-${i + 1}`, async () => {
4 try {
5 const audioBase64 = await textToSpeech(segment.text, targetLanguage, segment.speaker);
6
7 // Upload the generated audio and get back a short-lived URL
8 const { url: uploadThingUrl } = await uploadBase64Audio(audioBase64);
9
10 return {
11 speaker: segment.speaker,
12 text: segment.text,
13 audioUrl: uploadThingUrl,
14 success: true,
15 };
16 } catch (error) {
17 console.error(`Error generating audio for segment ${i + 1}:`, error);
18
19 // Don't rethrow: mark this segment as failed so the whole job doesn't fail.
20 // Inngest still retries the step on its own for transient/network errors.
21 return {
22 speaker: segment.speaker,
23 text: segment.text,
24 audioUrl: '',
25 success: false,
26 };
27 }
28 });
29 audioGenerationResults.push(audioResult);
30
31 // A durable sleep between calls, resumable, unlike a plain setTimeout
32 if (i < script.length - 1) {
33 await step.sleep(`wait-after-segment-${i + 1}`, '1.2s');
34 }
35}

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

LanguageCode
English (India)en-IN
Hindihi-IN
Tamilta-IN
Telugute-IN
Bengalibn-IN
Gujaratigu-IN
Marathimr-IN
Malayalamml-IN
Kannadakn-IN
Punjabipa-IN
Odiaod-IN

Default Voices (Bulbul v3)

RoleVoice
Hostpriya
Guestshubh

Bulbul v3 supports a much larger set of speakers. See Text-to-Speech 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:

1const VOICE_MAPPINGS: { [key: string]: { host: string; guest: string } } = {
2 'hi-IN': { host: 'priya', guest: 'shubh' },
3 'ta-IN': { host: 'ishita', guest: 'karun' },
4 // ...
5};

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 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.
  • 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.

Additional Resources

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! 🚀