# Audio

> Text to speech and speech to text, in OpenAI's shape.

Section: Core API. Canonical page: https://routehook.ai/docs/audio

Two routes, pointing opposite ways. `POST /v1/audio/speech` takes text and answers with audio bytes; `POST /v1/audio/transcriptions` takes audio and answers with text. Both take OpenAI's fields and return OpenAI's answer, so an existing client reaches them with nothing changed but the base URL and the model slug, with one exception on the transcription route, which is called out below.

> **Note: They are billed on different axes**
>
> Speech is charged **per character of the text you send**. The request itself is the meter, so the cost is known exactly before the call is made. Transcription is charged **per second of audio**. Neither answer has a body that could carry a usage block, which is why `X-Routehook-Cost` is the only place the charge appears.

## Speech

The response is the audio itself, streamed rather than buffered, so a long document does not sit in memory on either side. `Content-Type` is the upstream's own (`audio/mpeg` for an MP3), and `X-Content-Type-Options: nosniff` rides with it. `voice` is passed through rather than validated here, so a voice a vendor adds is usable the day it ships instead of the day we notice; an unknown one is refused upstream with a message naming the valid set.

## Generating speech

**cURL**

```bash
curl https://api.routehook.ai/v1/audio/speech \
  -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-transcribe",
    "input": "Routehook audio is live.",
    "voice": "alloy"
  }' \
  --output speech.mp3
```

**JavaScript**

```javascript
import OpenAI from "openai";
import { writeFile } from "node:fs/promises";

const client = new OpenAI({
  baseURL: "https://api.routehook.ai/v1",
  apiKey: process.env.ROUTEHOOK_API_KEY,
});

const speech = await client.audio.speech.create({
  model: "openai/gpt-transcribe",
  input: "Routehook audio is live.",
  voice: "alloy",
});

await writeFile("speech.mp3", Buffer.from(await speech.arrayBuffer()));
```

**Python**

```python
import os
from openai import OpenAI

client = OpenAI(
    base_url="https://api.routehook.ai/v1",
    api_key=os.environ["ROUTEHOOK_API_KEY"],
)

speech = client.audio.speech.create(
    model="openai/gpt-transcribe",
    input="Routehook audio is live.",
    voice="alloy",
)

speech.write_to_file("speech.mp3")
```

## POST /v1/audio/speech

Turn text into spoken audio and stream the bytes back.

Status: Available. Authentication: any live API key on the account.

## Speech parameters

| PARAMETER | TYPE | REQUIRED | DESCRIPTION |
| --- | --- | --- | --- |
| model | string | required | A slug from GET /v1/models whose category is audio. |
| input | string | required | The text to speak, up to 4,096 characters. This is the billable quantity. |
| voice | string | optional | Vendor voice id. Default alloy. Not an enum. A new voice needs no release from us. |
| response_format | mp3 \| opus \| aac \| flac \| wav \| pcm | optional | Container for the returned audio. Omitted leaves the model's default, which is usually mp3. |
| speed | number | optional | Playback rate, 0.25 to 4.0. Forwarded rather than clamped here. |

## The speech response

**200 OK**

```http
HTTP/1.1 200 OK
Content-Type: audio/mpeg
X-Content-Type-Options: nosniff
X-Routehook-Request-Id: req_7c41d9be
X-Routehook-Cost: 0.00045

<binary audio>
```

> **Note: There is no JSON envelope here**
>
> The body is the file. Nothing of ours is wrapped around it (no `{ data: … }`, no base64) because an envelope is a difference every existing client would have to write code for, and these routes exist to avoid exactly that. The charge is on `X-Routehook-Cost` and the request id on `X-Routehook-Request-Id`; `GET /v1/generation?id=req_…` has the rest.

## Transcription

Send the audio as base64 under `input_audio`, with or without a `data:` prefix, both are accepted, because a blob assembled by hand and one produced by a browser's `FileReader` differ only in that prefix. `format` names the container and is worth sending: the upstream decides how to decode from the filename, so a WAV labelled `mp3` is rejected by the decoder rather than by us.

> **Note: OpenAI SDK uploads work too**
>
> The route also accepts OpenAI's `multipart/form-data` shape with a `file` field and the same model, language, response_format, temperature and timestamp_granularities fields. That means `audio.transcriptions.create()` works after changing the base URL; the JSON form below remains useful for browsers and direct HTTP calls.

## Transcribing audio

**cURL**

```bash
curl https://api.routehook.ai/v1/audio/transcriptions \
  -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"model\": \"openai/gpt-live-transcribe\",
    \"input_audio\": { \"data\": \"$(base64 -w0 speech.mp3)\", \"format\": \"mp3\" }
  }"
```

**JavaScript**

```javascript
import { readFile } from "node:fs/promises";

const audio = await readFile("speech.mp3");

const response = await fetch("https://api.routehook.ai/v1/audio/transcriptions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "openai/gpt-live-transcribe",
    input_audio: { data: audio.toString("base64"), format: "mp3" },
  }),
});

const { text } = await response.json();
```

**Python**

```python
import base64
import os

import requests

with open("speech.mp3", "rb") as handle:
    audio = base64.b64encode(handle.read()).decode()

response = requests.post(
    "https://api.routehook.ai/v1/audio/transcriptions",
    headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"},
    json={
        "model": "openai/gpt-live-transcribe",
        "input_audio": {"data": audio, "format": "mp3"},
    },
)

print(response.json()["text"])
```

## POST /v1/audio/transcriptions

Transcribe audio to text, with optional word and segment timestamps.

Status: Available. Authentication: any live API key on the account.

## Transcription parameters

| PARAMETER | TYPE | REQUIRED | DESCRIPTION |
| --- | --- | --- | --- |
| model | string | required | A slug from GET /v1/models whose category is audio. |
| input_audio | object | required | { data, format }. Base64 audio, bare or with a data: prefix, and the container it is in. |
| language | string | optional | ISO-639-1 hint. Improves accuracy and latency where the language is known. |
| response_format | json \| text \| srt \| verbose_json \| vtt | optional | Shape of the transcript. Default json. |
| temperature | number | optional | Sampling temperature, 0 to 1. Lower is more literal. |
| timestamp_granularities | string[] | optional | word and/or segment. Only meaningful alongside verbose_json. |

## The transcription response

**200 OK**

```json
{
  "text": "Routehook audio is live."
}
```

## The other response formats are the vendor's own

Only `json` is a shape we publish. `verbose_json` (the one that carries `duration`, `segments` and word timings) is forwarded from the upstream exactly as it arrived, and `text`, `srt` and `vtt` come back as the file itself under `text/plain; charset=utf-8` rather than as a quoted string. Normalising them would mean guessing at fields for every model discovered after the code was written, so they are passed through instead. Parse `json` if you want a stable shape; ask for the others when you want the vendor's.

## Which models can do this

`GET /v1/models` reports a `category` of `audio` for every model on these two routes. The category tells you the route but not the direction (a model that transcribes cannot speak, and the reverse is also true), so read the model's own name for that. Naming a chat or an image model on either route is refused with `400 invalid_request` before any upstream call is made and before any credit is reserved, and the message names the category the model actually is. A target that could otherwise serve the model but does not do audio is skipped by the router rather than called.

## Cost

Speech reserves the exact charge before the call: the characters you sent at the model's per-character rate, plus the model's flat per-request price where it carries one. Nothing is estimated, because you have already handed over the thing being counted. Transcription cannot work that way (the billable quantity is the duration of the audio, which nobody knows until the upstream reports one), so the hold is sized from the upload, deliberately high, and replaced by the true charge seconds later. Where no duration is reported the estimate stands and the request row records that the figure was estimated rather than metered. Either way the reservation is released and nothing is charged if the call fails.

## Failures

| STATUS | CODE | MEANING |
| --- | --- | --- |
| 400 | invalid_request | Not an audio model, an input over 4,096 characters, or input_audio that is empty or not base64 |
| 402 | insufficient_credits | Refused before any upstream call |
| 409 | model_unavailable | No live endpoint can serve that audio model |
| 503 | upstream_unavailable | Upstream outage. Safe to retry |

## Related

- [Models](/docs/models): The catalogue, filtered by category.
- [Pricing](/docs/pricing): The axis each category bills on.
- [Errors](/docs/errors): The eight codes, and what to do about each.
