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

# Generate speech

> 24 kHz English text to speech over raw bytes, SSE, or the OpenAI-compatible /v1/audio/speech; \$30 per million characters

The Text to Speech API turns text into audio with [OmniVoice](/models#omnivoice), k2-fsa's open text-to-speech model, served by Hopper. OmniVoice generates English speech at 24 kHz; the default voice is `sarah`. Three endpoints share the same generation options: `POST /tts/bytes` returns raw audio bytes, `POST /tts/sse` streams base64 chunks over server-sent events, and `POST /v1/audio/speech` accepts the OpenAI request shape. All three bill at \$30 per million characters of submitted transcript.

## POST /tts/bytes

The native request shape. Send a JSON body; the response is raw audio.

| Field                       | Type    | Constraint                                                                                         | Default     |
| --------------------------- | ------- | -------------------------------------------------------------------------------------------------- | ----------- |
| `model_id`                  | string  | Required. Must be an active TTS model (`omnivoice`).                                               | —           |
| `transcript`                | string  | Required, 1–5,000 characters.                                                                      | —           |
| `voice`                     | object  | Required, exactly `{"mode": "id", "id": "<voice_id>"}`.                                            | —           |
| `language`                  | string  | ISO code, e.g. `en`. OmniVoice serves English.                                                     | none        |
| `output_format.container`   | string  | Required: `wav`, `mp3`, or `raw`.                                                                  | —           |
| `output_format.sample_rate` | int     | Required: 8000, 16000, 22050, 24000, 44100, or 48000. OmniVoice generates at 24 kHz.               | —           |
| `output_format.encoding`    | string  | wav/raw only: `pcm_f32le`, `pcm_s16le`, `pcm_mulaw`, `pcm_alaw`. Rejected for mp3.                 | `pcm_s16le` |
| `output_format.bit_rate`    | int     | mp3 only: 32000, 64000, 96000, 128000, 192000, 256000, or 320000.                                  | 128000      |
| `generation_config.speed`   | number  | 0.6–1.5. Must be 1 when streaming.                                                                 | 1           |
| `generation_config.volume`  | number  | 0.5–2.0.                                                                                           | unset       |
| `pronunciation_dict_id`     | string  | Applies a [pronunciation dictionary](/tts/pronunciation). Unknown id returns 404 `dict_not_found`. | none        |
| `stream`                    | boolean | `true` relays audio bytes progressively as they are generated.                                     | `false`     |

Generate speech and write it to a file:

<CodeGroup>
  ```python Python theme={null}
  import requests

  resp = requests.post(
      "https://api.withhopper.com/tts/bytes",
      headers={"Authorization": "Bearer sk_hopper_..."},
      json={
          "model_id": "omnivoice",
          "transcript": "Your appointment is confirmed for Tuesday at 2 PM.",
          "voice": {"mode": "id", "id": "sarah"},
          "output_format": {"container": "wav", "sample_rate": 24000, "encoding": "pcm_s16le"},
      },
  )
  resp.raise_for_status()
  open("speech.wav", "wb").write(resp.content)
  print(resp.headers["x-hopper-request-cost-usd"])  # exact cost in USD
  ```

  ```typescript TypeScript theme={null}
  import { writeFile } from "node:fs/promises";

  const resp = await fetch("https://api.withhopper.com/tts/bytes", {
    method: "POST",
    headers: {
      Authorization: "Bearer sk_hopper_...",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model_id: "omnivoice",
      transcript: "Your appointment is confirmed for Tuesday at 2 PM.",
      voice: { mode: "id", id: "sarah" },
      output_format: { container: "wav", sample_rate: 24000, encoding: "pcm_s16le" },
    }),
  });
  await writeFile("speech.wav", Buffer.from(await resp.arrayBuffer()));
  console.log(resp.headers.get("x-hopper-request-cost-usd")); // exact cost in USD
  ```

  ```bash cURL theme={null}
  curl https://api.withhopper.com/tts/bytes \
    -H "Authorization: Bearer sk_hopper_..." \
    -H "Content-Type: application/json" \
    -d '{
      "model_id": "omnivoice",
      "transcript": "Your appointment is confirmed for Tuesday at 2 PM.",
      "voice": {"mode": "id", "id": "sarah"},
      "output_format": {"container": "wav", "sample_rate": 24000, "encoding": "pcm_s16le"}
    }' \
    -o speech.wav
  ```
</CodeGroup>

The response `Content-Type` matches the container: `audio/wav`, `audio/mpeg`, or `application/octet-stream`. Every response carries `x-hopper-request-cost-usd` and `x-hopper-credits-remaining-usd`. TTS cost is known before generation, so both headers are exact.

## Streaming restrictions

<Warning>
  Streaming supports wav and raw only. `container: "mp3"` over a stream returns 400 `invalid_output_format`; `generation_config.speed` other than 1 over a stream returns 400 `invalid_generation_config`. This applies to `/tts/bytes` with `stream: true`, to `/tts/sse` always, and to `/v1/audio/speech` with `stream: true`.
</Warning>

## POST /tts/sse

Same request body as `/tts/bytes`. The response always streams, regardless of the `stream` field: `Content-Type: text/event-stream` with two event shapes.

```
data: {"type":"chunk","data":"<base64 audio>"}

data: {"type":"done"}
```

`chunk` repeats until the audio is complete; `done` is the final event. Decode each chunk and feed it to playback as it arrives:

```python theme={null}
import base64, json, requests

with requests.post(
    "https://api.withhopper.com/tts/sse",
    headers={"Authorization": "Bearer sk_hopper_..."},
    json={
        "model_id": "omnivoice",
        "transcript": "Your appointment is confirmed for Tuesday at 2 PM.",
        "voice": {"mode": "id", "id": "sarah"},
        "output_format": {"container": "raw", "sample_rate": 24000, "encoding": "pcm_s16le"},
    },
    stream=True,
) as resp:
    for line in resp.iter_lines():
        if not line.startswith(b"data: "):
            continue
        event = json.loads(line[6:])
        if event["type"] == "chunk":
            pcm = base64.b64decode(event["data"])  # raw PCM16 @ 24 kHz
        elif event["type"] == "done":
            break
```

## POST /v1/audio/speech

The OpenAI-compatible endpoint. Point an existing OpenAI TTS integration at `https://api.withhopper.com` and it works unchanged: `model` maps to `model_id`, `input` to `transcript`, and `voice` (a string) to `{"mode": "id", "id": voice}`. `speed` and `stream` pass through. `response_format` selects a fixed output format:

| `response_format` | Container | Encoding    | Sample rate | Bit rate |
| ----------------- | --------- | ----------- | ----------- | -------- |
| `mp3` (default)   | mp3       | —           | 44100       | 128000   |
| `wav`             | wav       | `pcm_s16le` | 24000       | —        |
| `pcm`             | raw       | `pcm_s16le` | 24000       | —        |

`opus`, `aac`, and `flac` return 400. Response bytes and spend headers are identical to `/tts/bytes`.

## Errors

TTS endpoints are POST-only (405 `method_not_allowed` otherwise) and require a JSON object body (400 `invalid_json`). Auth, rate-limit, and credit errors match the rest of the API — see [Errors](/platform/errors).

| Status | Code                                     | When                                                                                    |
| ------ | ---------------------------------------- | --------------------------------------------------------------------------------------- |
| 404    | `model_not_found`                        | `model_id` is not a TTS model                                                           |
| 503    | `model_offline`                          | No healthy upstream for the model                                                       |
| 404    | `voice_not_found`                        | Voice id does not exist or is not visible to your organization                          |
| 409    | `voice_profile_missing`                  | Voice is not prepared for this model — see [voice profiles](/tts/voices#voice-profiles) |
| 404    | `dict_not_found`                         | `pronunciation_dict_id` does not exist                                                  |
| 400    | `transcript_too_long`                    | Dictionary alias expansion exceeds 20,000 characters                                    |
| 400    | `invalid_output_format`                  | mp3 requested over a stream, or encoding/bit\_rate mismatched to the container          |
| 400    | `invalid_generation_config`              | speed or volume out of range, or speed ≠ 1 on a stream                                  |
| 504    | `upstream_timeout`                       | Generation exceeded 300 s                                                               |
| 502    | `upstream_unavailable`, `upstream_error` | Upstream failed                                                                         |

## Billing

Cost is charged per character of the submitted transcript, before any pronunciation-dictionary expansion. A 1,000-character transcript costs \$0.03 at \$30 per million characters. The exact charge is returned on every response in `x-hopper-request-cost-usd`.
