> ## Documentation Index
> Fetch the complete documentation index at: https://assemblyai.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Streaming TTS on OpenRouter

> Synthesize speech with AssemblyAI voices through OpenRouter's OpenAI-compatible speech API, on your OpenRouter key and bill.

## Overview

[OpenRouter](https://openrouter.ai/) is a model gateway: one API key and one bill across many providers, behind an OpenAI-compatible interface. Selecting the model `assemblyai/assemblyai-tts-1` on its [speech endpoint](https://openrouter.ai/docs/guides/overview/multimodal/tts) routes the request to AssemblyAI's [OpenAI-compatible speech route](https://assemblyai.com/docs/tts/openai-compatibility.md), and the audio streams back as it is synthesized.

You send OpenAI-shaped fields. The one AssemblyAI option OpenRouter forwards, `language`, travels in a `provider.options.assemblyai` block.

### Routing through OpenRouter, or calling us directly

**Route through OpenRouter** when you already buy inference through it and want speech on the same key, bill, and client, or when you're comparing speech models and want to swap providers by changing one string.

**Call Streaming TTS directly** when you need what the gateway doesn't pass through:

* The [WebSocket API](https://assemblyai.com/docs/tts/api-reference/websocket-api.md), with one session per call and barge-in without reconnecting, and the native [LiveKit](https://assemblyai.com/docs/tts/livekit.md) and [Pipecat](https://assemblyai.com/docs/tts/pipecat.md) plugins built on it.
* [Word timings](https://assemblyai.com/docs/tts/word-boundaries.md), for captions and for knowing how much of a reply the user heard.
* Audio other than 24 kHz PCM and MP3: WAV, G.711 mu-law and A-law, and [sample rates](https://assemblyai.com/docs/tts/openai-compatibility.md#extensions) from 8 kHz to 48 kHz.
* [Cloned voices](https://assemblyai.com/docs/tts/cloned-voices.md) named by slug or as `{"id": ...}`, and consent renewal.
* [Temporary tokens](https://assemblyai.com/docs/tts/authenticate-with-a-temporary-token.md) for browsers, and the request id and `X-TTS-*` headers on every response.

> [card] [OpenRouter TTS docs](https://openrouter.ai/docs/guides/overview/multimodal/tts)
>
> OpenRouter's reference for the speech endpoint.

> [card] [Streaming TTS quickstart](https://assemblyai.com/docs/tts/getting-started/quickstart.md)
>
> Use Streaming TTS directly, without the gateway.

## Quickstart

Set your OpenRouter key:

```bash theme={null}
export OPENROUTER_API_KEY=<your-key>
```

Then POST the text. The response body is the audio itself, not JSON:

> [tab] cURL
>
> ```bash theme={null}
> curl https://openrouter.ai/api/v1/audio/speech \
>   -H "Authorization: Bearer $OPENROUTER_API_KEY" \
>   -H "Content-Type: application/json" \
>   --output hello.pcm \
>   -d '{
>     "model": "assemblyai/assemblyai-tts-1",
>     "input": "Hello from AssemblyAI.",
>     "voice": "aai-kyla"
>   }'
> ```

> [tab] Python
>
> OpenRouter's speech endpoint takes the request shape of the OpenAI speech API, so the OpenAI SDK works with OpenRouter's base URL:
>
> ```python theme={null}
> import os
>
> from openai import OpenAI
>
> client = OpenAI(
>     base_url="https://openrouter.ai/api/v1",
>     api_key=os.environ["OPENROUTER_API_KEY"],
> )
>
> with client.audio.speech.with_streaming_response.create(
>     model="assemblyai/assemblyai-tts-1",
>     voice="aai-kyla",
>     input="Your appointment is confirmed for Tuesday at three thirty.",
>     response_format="pcm",
> ) as response:
>     print("generation id:", response.headers.get("x-generation-id"))
>     response.stream_to_file("appointment.pcm")
> ```

> [tab] JavaScript
>
> ```javascript theme={null}
> import fs from "node:fs/promises";
>
> const response = await fetch("https://openrouter.ai/api/v1/audio/speech", {
>   method: "POST",
>   headers: {
>     Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
>     "Content-Type": "application/json",
>   },
>   body: JSON.stringify({
>     model: "assemblyai/assemblyai-tts-1",
>     input: "Your appointment is confirmed for Tuesday at three thirty.",
>     voice: "aai-kyla",
>     response_format: "mp3",
>   }),
> });
> if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
> await fs.writeFile("appointment.mp3", Buffer.from(await response.arrayBuffer()));
> ```

`hello.pcm` holds raw 16-bit little-endian mono PCM at 24 kHz. To play it, run `ffplay -f s16le -ar 24000 -ch_layout mono hello.pcm`, or ask for `"response_format": "mp3"`.

## Request fields

| OpenRouter field | Values | Notes |
| - | - | - |
| `model` | `assemblyai/assemblyai-tts-1` | The language model names of the direct route, such as `assemblyai-tts-es`, don't exist on OpenRouter |
| `input` | The text, 1 to 5,000 characters | Required. Split longer text and send it in parts |
| `voice` | A voice's slug, its ID, or an OpenAI voice name | A string. See [Voices](#voices) |
| `response_format` | `pcm` (default) or `mp3` | `pcm` is 16-bit mono at 24 kHz; `mp3` is 64 kbps mono at 24 kHz. Any other value is refused with `400` |
| `speed` | `1` | Any other value is refused with `400` |

> [!WARNING]
>
> The default `response_format` on OpenRouter is `pcm`, where the
> [direct route's](https://assemblyai.com/docs/tts/openai-compatibility.md) is `mp3`. Code that moves between
> them and leaves the format out gets a different format back. Set
> `response_format` explicitly.

OpenRouter accepts other fields of the OpenAI speech request, such as `instructions`, and AssemblyAI ignores them. A successful request is not evidence that a field took effect.

### Voices

`voice` takes the same strings as on the [direct route](https://assemblyai.com/docs/tts/openai-compatibility.md#voices):

* **The library slug of a voice in the [catalog](https://assemblyai.com/docs/tts/voices.md)**, such as `aai-kyla`, or `aai-constanza` for Mexican Spanish.
* **A voice's ID**, a UUID, as the [voice catalog](https://www.assemblyai.com/dashboard/text-to-speech/voices) copies it.
* **An OpenAI voice name** such as `alloy`, mapped to an AssemblyAI voice as the [direct route's table](https://assemblyai.com/docs/tts/openai-compatibility.md#voices) shows.

A bare name without the `aai-` prefix, such as `kyla`, is refused. OpenRouter accepts `voice` only as a string, so the `{"id": ...}` form for cloned voices is refused before it reaches AssemblyAI.

When `voice` is left out, the request still succeeds, with a default voice. Name the voice to get the same one every time.

### AssemblyAI options

Options specific to AssemblyAI go under `provider.options.assemblyai`, keyed by our provider slug.

**language** `string`

The language to speak: a language name such as `spanish` or an ISO 639-1
code such as `es`, in any letter case. One of English, Spanish, German,
Italian, Portuguese or French. When you leave it out, the voice speaks the
language it was recorded in. Set it to the language of your text; see
[Voice and language](https://assemblyai.com/docs/tts/voices.md#voice-and-language).

> [tab] cURL
>
> ```bash theme={null}
> curl https://openrouter.ai/api/v1/audio/speech \
>   -H "Authorization: Bearer $OPENROUTER_API_KEY" \
>   -H "Content-Type: application/json" \
>   --output cita.mp3 \
>   -d '{
>     "model": "assemblyai/assemblyai-tts-1",
>     "input": "Su cita está confirmada para el martes a las tres y media.",
>     "voice": "aai-constanza",
>     "response_format": "mp3",
>     "provider": { "options": { "assemblyai": { "language": "spanish" } } }
>   }'
> ```

> [tab] Python
>
> ```python theme={null}
> import os
>
> from openai import OpenAI
>
> client = OpenAI(
>     base_url="https://openrouter.ai/api/v1",
>     api_key=os.environ["OPENROUTER_API_KEY"],
> )
>
> with client.audio.speech.with_streaming_response.create(
>     model="assemblyai/assemblyai-tts-1",
>     voice="aai-constanza",
>     input="Su cita está confirmada para el martes a las tres y media.",
>     response_format="mp3",
>     extra_body={"provider": {"options": {"assemblyai": {"language": "spanish"}}}},
> ) as response:
>     response.stream_to_file("cita.mp3")
> ```

> [!WARNING]
>
> Put `language` under `provider.options.assemblyai`. A top-level `language`
> field is accepted with `200` and does nothing, whatever its value.

`language` is the only option OpenRouter forwards. The direct route's other extensions, `sample_rate`, `voice_id` and `extend_biometric_consent`, and any other key, are dropped without an error.

## Response

A `200` carries the raw audio as its body, streamed as it is synthesized: the first bytes arrive long before the last, so a player can start on them. Write the body to a file or a player, and don't parse it as JSON.

| Header | Value |
| - | - |
| `Content-Type` | `audio/pcm;rate=24000;channels=1` for `pcm`, `audio/mpeg` for `mp3` |
| `X-Generation-Id` | OpenRouter's id for the request, such as `gen-tts-1793388133-mQQ8...` |

The `X-TTS-*` and `Server-Timing` headers of the direct route don't come through. These requests bill through your OpenRouter account, per character of `input`; the [model page](https://openrouter.ai/assemblyai/assemblyai-tts-1) has the price, and OpenRouter's generation API reports each request's cost by its `X-Generation-Id`.

## Errors

Errors use OpenRouter's body, with the status as `code`:

```json theme={null}
{
  "error": {
    "message": "Provider rejected the request: input: must not be blank",
    "code": 400
  }
}
```

When AssemblyAI refuses a request, OpenRouter prefixes our message with `Provider rejected the request:`. Some of our refusals arrive without their message, as `Provider returned 400`. For the cases below, the message you get depends on who refused the request:

| Request | Status | Message |
| - | - | - |
| `voice` is a bare name such as `kyla` | `400` | `Provider rejected the request: voice: must be an OpenAI voice name such as alloy, ...` |
| `input` is blank | `400` | `Provider rejected the request: input: must not be blank` |
| `language` is not one of the six languages | `400` | `Provider rejected the request: language: must be one of english, spanish, ...` |
| `voice` names no voice, such as `aai-nonexistent` | `400` | `Provider returned 400` |
| `input` has nothing to speak, such as only emoji | `400` | `Provider returned 400` |
| `input` is longer than 5,000 characters | `400` | `Input length (5001) exceeds the 5000-character limit for AssemblyAI TTS.` |
| `speed` is not `1` | `400` | `speed is not supported by AssemblyAI TTS; omit it or send 1.` |
| `response_format` is not `pcm` or `mp3`, or `voice` is not a string | `400` | OpenRouter's validation error, in a body of the form `{"success": false, "error": {...}}` |
| The OpenRouter key is invalid | `401` | `User not found.` |

OpenRouter doesn't bill failed generations, and documents its other statuses, such as `402` for insufficient credits and `429` for rate limits, in its [API reference](https://openrouter.ai/docs/api/api-reference/tts/create-speech).

## Limits

* **Text length.** 5,000 characters per request, the same as the direct route. OpenRouter enforces it before the request reaches us.
* **Data zone.** OpenRouter routes requests to `https://openrouter.ai/api/v1` to either of our [data zones](https://assemblyai.com/docs/tts/endpoints-and-data-zones.md), US or EU. To keep a request in Europe, send it to OpenRouter's [EU in-region endpoint](https://openrouter.ai/docs/guides/features/in-region-routing), `https://eu.openrouter.ai/api/v1`, which needs an OpenRouter Business or Enterprise plan; without one, it answers `403`.

## Related

* [OpenAI compatibility](https://assemblyai.com/docs/tts/openai-compatibility.md): the direct route, with every voice, format and extension.
* [Voices](https://assemblyai.com/docs/tts/voices.md): the AssemblyAI voices, languages, and how to find a voice's ID.
* [List models (OpenRouter)](https://assemblyai.com/docs/tts/api-reference/http-api/list-openrouter-models.md): the models document OpenRouter reads.
* [Sync STT on OpenRouter](https://assemblyai.com/docs/sync-stt/openrouter.md): speech-to-text with Universal-3.5 Pro through the same gateway.
