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

# Migrating Text-to-Speech from Gemini to Sarvam

> Endpoint, parameter, and response-format differences between Gemini's native audio text-to-speech generation and Sarvam's Text-to-Speech API, with before/after code.

This guide covers migrating Gemini text-to-speech calls to Sarvam. If you're building a full voice agent with live, bidirectional audio rather than generating speech from text one call at a time, start from the [LiveKit](/api-reference-docs/integration/build-voice-agent-with-live-kit) or [Pipecat](/api-reference-docs/integration/build-voice-agent-with-pipecat) voice agent build guides instead: they aren't a 1:1 API swap and aren't covered here.

**In short:** replace `generation_config.speech_config` (a list of `{voice, speaker, language}` objects) plus `response_format: {"type": "audio"}` with two flat params, `speaker` and `target_language_code`, and read the audio back from a plain `audios` array instead of unwrapping `interaction.output_audio.data`.

## Quick start

Base URL and auth header change the same way for every Gemini migration, so they're folded in here rather than in a separate page:

|                     | Gemini API                                  | Sarvam                            |
| ------------------- | ------------------------------------------- | --------------------------------- |
| Base URL            | `https://generativelanguage.googleapis.com` | `https://api.sarvam.ai`           |
| Auth header         | `x-goog-api-key: <api_key>`                 | `api-subscription-key: <api_key>` |
| Auth failure status | `401`                                       | `403`                             |

With that, replace the synthesis call and add the required language code:

```python
import wave
import base64
from google import genai

client = genai.Client(api_key="YOUR_GEMINI_API_KEY")

interaction = client.interactions.create(
    model="gemini-3.1-flash-tts-preview",
    input="Welcome to our platform!",
    response_format={"type": "audio"},
    generation_config={
        "speech_config": [{"voice": "Kore"}],
    },
)

def wave_file(filename, pcm, channels=1, rate=24000, sample_width=2):
    with wave.open(filename, "wb") as wf:
        wf.setnchannels(channels)
        wf.setsampwidth(sample_width)
        wf.setframerate(rate)
        wf.writeframes(pcm)

wave_file("output.wav", base64.b64decode(interaction.output_audio.data))
```

```javascript
import { GoogleGenAI } from "@google/genai";
import fs from "fs";

const ai = new GoogleGenAI({ apiKey: "YOUR_GEMINI_API_KEY" });

const interaction = await ai.interactions.create({
  model: "gemini-3.1-flash-tts-preview",
  input: "Welcome to our platform!",
  response_format: { type: "audio" },
  generation_config: { speech_config: [{ voice: "Kore" }] },
});

function writeWavFile(filename, pcm, channels = 1, rate = 24000, sampleWidth = 2) {
  const header = Buffer.alloc(44);
  header.write("RIFF", 0);
  header.writeUInt32LE(36 + pcm.length, 4);
  header.write("WAVEfmt ", 8);
  header.writeUInt32LE(16, 16);
  header.writeUInt16LE(1, 20);
  header.writeUInt16LE(channels, 22);
  header.writeUInt32LE(rate, 24);
  header.writeUInt32LE(rate * channels * sampleWidth, 28);
  header.writeUInt16LE(channels * sampleWidth, 32);
  header.writeUInt16LE(sampleWidth * 8, 34);
  header.write("data", 36);
  header.writeUInt32LE(pcm.length, 40);
  fs.writeFileSync(filename, Buffer.concat([header, pcm]));
}

writeWavFile("output.wav", Buffer.from(interaction.output_audio.data, "base64"));
```

```bash
curl -X POST "https://generativelanguage.googleapis.com/v1beta/interactions" \
  -H "x-goog-api-key: YOUR_GEMINI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gemini-3.1-flash-tts-preview",
    "input": "Welcome to our platform!",
    "response_format": {"type": "audio"},
    "generation_config": {"speech_config": [{"voice": "Kore"}]}
  }'
```

```python
import base64
from sarvamai import SarvamAI

client = SarvamAI(api_subscription_key="YOUR_SARVAM_API_KEY")

response = client.text_to_speech.convert(
    text="Welcome to our platform!",
    target_language_code="en-IN",
    speaker="shubh",
    model="bulbul:v3",
    pace=1.0,
)

with open("output.wav", "wb") as f:
    f.write(base64.b64decode("".join(response.audios)))
```

```javascript
import { SarvamAIClient } from "sarvamai";
import fs from "fs";

const client = new SarvamAIClient({ apiSubscriptionKey: "YOUR_SARVAM_API_KEY" });

const response = await client.textToSpeech.convert({
  text: "Welcome to our platform!",
  target_language_code: "en-IN",
  speaker: "shubh",
  model: "bulbul:v3",
  pace: 1.0,
});

fs.writeFileSync("output.wav", Buffer.from(response.audios.join(""), "base64"));
```

```bash
curl -X POST https://api.sarvam.ai/text-to-speech \
  -H "api-subscription-key: YOUR_SARVAM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Welcome to our platform!",
    "target_language_code": "en-IN",
    "speaker": "shubh",
    "model": "bulbul:v3"
  }'
```

Gemini's `output_audio.data` is raw PCM you wrap in a WAV header yourself. Sarvam's `convert()` returns a JSON object; the audio lives in `response.audios` as a list of base64-encoded, already-WAV-formatted strings. Join and decode before writing to disk, or use the Python SDK's `sarvamai.play.save()` helper, which does this for you.

The REST response is JSON with a base64 `audios` array, already WAV-formatted; no manual header construction needed. Decode `.audios[0]` before writing it out; see the [REST API guide](/api-reference-docs/api-guides-tutorials/text-to-speech/rest-api) for the full decode snippet.

The rest of this page covers the full endpoint and parameter mapping, streaming, and common mistakes.

## Endpoint map

The tables below aren't a checklist to apply field-by-field: Quick Start above already shows the whole call swapped over in one go. Use these tables as a reference for what changed and why, not as a sequence of edits to make yourself.

|                       | Gemini API                                                                                  | Sarvam                        |
| --------------------- | ------------------------------------------------------------------------------------------- | ----------------------------- |
| Synchronous synthesis | `POST /v1beta/interactions` (with `response_format: {"type": "audio"}`)                     | `POST /text-to-speech`        |
| HTTP streaming        | `POST /v1beta/interactions` with `stream: true`                                             | `POST /text-to-speech/stream` |
| WebSocket streaming   | Not applicable to this endpoint; live bidirectional audio goes through the Live API instead | `GET /text-to-speech/ws`      |

## Parameter concept map

| Gemini API                                                                                   | Sarvam                                                                      | Note                                                                                                     |
| -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `model` (TTS-specific, e.g. `gemini-3.1-flash-tts-preview`)                                  | `model` (`bulbul:v2` or `bulbul:v3`)                                        | Dedicated TTS endpoint with two models, not a general model configured for audio output                  |
| `generation_config.speech_config` (a list, one entry per voice)                              | `speaker` (plain name, e.g. `shubh`)                                        | One flat param instead of a list-of-objects, even for a single voice                                     |
| `speech_config[].language` (optional)                                                        | `target_language_code` (BCP-47; **required**)                               | Guarantees correct pronunciation, especially for code-mixed Indian-language text                         |
| `speech_config` with two entries, each tied to a speaker name in the prompt (max 2 speakers) | Not applicable                                                              | Generate each speaker separately and stitch the audio; independent language and pace control per speaker |
| Response: `interaction.output_audio.data` (base64 raw PCM, wrap in a WAV header yourself)    | Response: `response.audios` (base64 WAV strings, ready to decode and write) | No manual WAV header construction needed                                                                 |

## Streaming

|                                  | Gemini API                                                                                                             | Sarvam                                                                                                                                                                                                |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HTTP streaming                   | `stream=True` on the same `interactions.create()` call, currently only on `gemini-3.1-flash-tts-preview`               | [HTTP Stream API](/api-reference-docs/api-guides-tutorials/text-to-speech/streaming-api/http-stream) (`POST /text-to-speech/stream`), which streams on every model                                    |
| Streamed chunk format            | Events; audio in `event.delta.data` (base64) when `event.event_type == "step.delta"` and `event.delta.type == "audio"` | Audio returned progressively over the same request, no separate event-type check needed                                                                                                               |
| Persistent multi-turn connection | Not applicable to this endpoint                                                                                        | [WebSocket API](/api-reference-docs/api-guides-tutorials/text-to-speech/streaming-api/web-socket): `ws.configure(...)` once, then repeated `ws.convert(text)` and `ws.flush()` on the same connection |

## What's different by design

* **One flat call, no list to build.** `speaker` and `target_language_code` are passed directly, rather than nested inside a `generation_config.speech_config` list, even for a single voice.
* **A dedicated TTS endpoint.** `POST /text-to-speech` is purpose-built for speech synthesis, rather than a general interactions endpoint configured to return audio instead of text.
* **Ready-to-use WAV output.** `response.audios` is already WAV-formatted; there's no separate step to wrap raw PCM samples in a WAV header yourself.
* **Explicit language targeting.** Every Sarvam request carries `target_language_code`, so pronunciation and prosody are correct by design on every call, especially valuable for Hindi, Hinglish, and other code-mixed Indian-language text.
* **Indian language depth is Sarvam's specific strength.** Sarvam covers Hindi plus 9 other Indian languages (Bengali, Tamil, Telugu, Kannada, Malayalam, Marathi, Gujarati, Punjabi, Odia), natively tuned for code-mixed input like Hinglish.

## Full example

A small wrapper function, before and after, showing the complete set of changes together:

```python
import wave
import base64
from google import genai

client = genai.Client(api_key="YOUR_GEMINI_API_KEY")

def wave_file(filename, pcm, channels=1, rate=24000, sample_width=2):
    with wave.open(filename, "wb") as wf:
        wf.setnchannels(channels)
        wf.setsampwidth(sample_width)
        wf.setframerate(rate)
        wf.writeframes(pcm)

def synthesize(text: str, out_path: str) -> None:
    interaction = client.interactions.create(
        model="gemini-3.1-flash-tts-preview",
        input=text,
        response_format={"type": "audio"},
        generation_config={"speech_config": [{"voice": "Kore"}]},
    )
    wave_file(out_path, base64.b64decode(interaction.output_audio.data))
```

```javascript
import { GoogleGenAI } from "@google/genai";
import fs from "fs";

const ai = new GoogleGenAI({ apiKey: "YOUR_GEMINI_API_KEY" });

function writeWavFile(filename, pcm, channels = 1, rate = 24000, sampleWidth = 2) {
  const header = Buffer.alloc(44);
  header.write("RIFF", 0);
  header.writeUInt32LE(36 + pcm.length, 4);
  header.write("WAVEfmt ", 8);
  header.writeUInt32LE(16, 16);
  header.writeUInt16LE(1, 20);
  header.writeUInt16LE(channels, 22);
  header.writeUInt32LE(rate, 24);
  header.writeUInt32LE(rate * channels * sampleWidth, 28);
  header.writeUInt16LE(channels * sampleWidth, 32);
  header.writeUInt16LE(sampleWidth * 8, 34);
  header.write("data", 36);
  header.writeUInt32LE(pcm.length, 40);
  fs.writeFileSync(filename, Buffer.concat([header, pcm]));
}

async function synthesize(text, outPath) {
  const interaction = await ai.interactions.create({
    model: "gemini-3.1-flash-tts-preview",
    input: text,
    response_format: { type: "audio" },
    generation_config: { speech_config: [{ voice: "Kore" }] },
  });
  writeWavFile(outPath, Buffer.from(interaction.output_audio.data, "base64"));
}
```

```bash
synthesize() {
  local text="$1" out_path="$2"
  local audio_b64
  audio_b64=$(curl -sS -X POST "https://generativelanguage.googleapis.com/v1beta/interactions" \
    -H "x-goog-api-key: YOUR_GEMINI_API_KEY" \
    -H "Content-Type: application/json" \
    -d "{\"model\": \"gemini-3.1-flash-tts-preview\", \"input\": \"$text\", \"response_format\": {\"type\": \"audio\"}, \"generation_config\": {\"speech_config\": [{\"voice\": \"Kore\"}]}}" \
    | grep -o '"data":"[^"]*"' | cut -d'"' -f4)
  # Raw PCM; wrap in a WAV header (see the Python/JavaScript examples) to make it playable directly.
  echo "$audio_b64" | base64 -d > "$out_path.pcm"
}
```

```python
import base64
from sarvamai import SarvamAI
from sarvamai.core.api_error import ApiError

client = SarvamAI(api_subscription_key="YOUR_SARVAM_API_KEY")

def synthesize(text: str, out_path: str, language_code: str = "en-IN") -> None:
    try:
        response = client.text_to_speech.convert(
            text=text,
            target_language_code=language_code,
            speaker="shubh",
            model="bulbul:v3",
        )
    except ApiError as e:
        if e.status_code == 403:
            raise RuntimeError("Invalid API key. Check your credentials.") from e
        elif e.status_code == 429:
            raise RuntimeError("Rate limit exceeded. Wait and retry.") from e
        elif e.status_code == 503:
            raise RuntimeError("Service overloaded. Retry with backoff.") from e
        raise

    with open(out_path, "wb") as f:
        f.write(base64.b64decode("".join(response.audios)))
```

```javascript
import { SarvamAIClient, SarvamAIError } from "sarvamai";
import fs from "fs";

const client = new SarvamAIClient({ apiSubscriptionKey: "YOUR_SARVAM_API_KEY" });

async function synthesize(text, outPath, languageCode = "en-IN") {
  let response;
  try {
    response = await client.textToSpeech.convert({
      text,
      target_language_code: languageCode,
      speaker: "shubh",
      model: "bulbul:v3",
    });
  } catch (e) {
    if (e instanceof SarvamAIError) {
      if (e.statusCode === 403) throw new Error("Invalid API key. Check your credentials.");
      if (e.statusCode === 429) throw new Error("Rate limit exceeded. Wait and retry.");
      if (e.statusCode === 503) throw new Error("Service overloaded. Retry with backoff.");
    }
    throw e;
  }
  fs.writeFileSync(outPath, Buffer.from(response.audios.join(""), "base64"));
}
```

```bash
synthesize() {
  local text="$1" out_path="$2" language_code="${3:-en-IN}"
  local body status audio_b64
  body=$(curl -sS -w '\n%{http_code}' -X POST https://api.sarvam.ai/text-to-speech \
    -H "api-subscription-key: YOUR_SARVAM_API_KEY" \
    -H "Content-Type: application/json" \
    -d "{\"text\": \"$text\", \"target_language_code\": \"$language_code\", \"speaker\": \"shubh\", \"model\": \"bulbul:v3\"}")
  status="${body##*$'\n'}"
  case "$status" in
    403) echo "Invalid API key. Check your credentials." >&2; return 1 ;;
    429) echo "Rate limit exceeded. Wait and retry." >&2; return 1 ;;
    503) echo "Service overloaded. Retry with backoff." >&2; return 1 ;;
  esac
  # Minimal single-value extraction; use a real JSON parser for production code.
  audio_b64=$(echo "${body%$'\n'*}" | grep -o '"audios":\["[^"]*"' | cut -d'"' -f4)
  echo "$audio_b64" | base64 -d > "$out_path"
}
```

## Common mistakes

Gemini's TTS output is unwrapped PCM; without the `wave` module step, the resulting file has no valid header and won't play. Sarvam's `response.audios` entries are already complete WAV files once base64-decoded, so this extra wrapping step goes away entirely, not just changes shape.

It's required on every request, by design: this is what guarantees Sarvam applies the correct pronunciation and prosody instead of relying on the optional, per-entry `language` field Gemini lets you set inside `speech_config`. Include it from the start rather than treating it as optional.

There isn't a single-call two-speaker mode. Generate each speaker's dialogue as a separate `convert()` call with its own `speaker`, then stitch the resulting audio together in your application code.

Gemini's TTS uses a distinct model family (e.g. `gemini-3.1-flash-tts-preview`) from its chat models. Likewise, Sarvam's `model` here is `bulbul:v2`/`bulbul:v3`, not `sarvam-30b`/`sarvam-105b`; the chat and speech models aren't interchangeable on either platform.

Google's own docs note that Gemini's TTS models can occasionally emit text tokens instead of audio, which fails the request with a `500` error; they recommend building retry logic for it. Sarvam's TTS doesn't have that particular failure mode, so the retry logic in the examples on this page (403/429/503) is the only error handling you need to carry over.

## See also

* [Migration overview](/api-reference-docs/migrations/from-gemini/overview): base URL, auth, and SDK client changes
* [Migrating Chat Completion](/api-reference-docs/migrations/from-gemini/chat-completion)
* [Migrating Speech-to-Text](/api-reference-docs/migrations/from-gemini/speech-to-text)
* [Which Text-to-Speech API to Use](/api-reference-docs/api-guides-tutorials/text-to-speech/which-api-to-use)
* [TTS Best Practices](/api-reference-docs/api-guides-tutorials/text-to-speech/best-practices)
* [Voices](/api-reference-docs/api-guides-tutorials/text-to-speech/voices)
* [Errors & Troubleshooting](/api-reference-docs/errors-troubleshooting)