# Audio

llm.buzz serves speech to text and text to speech with OpenRouter's request and answer shapes, so the OpenAI and OpenRouter SDKs reach them with the base URL alone. Each audio model serves one of the two. Chat Completions, Responses and Messages refuse an audio model with a 400 that names its route.

Every audio route takes your key as a bearer token, as on every other route ([Authentication](authentication.md)).

## Find an audio model

`GET https://api.llm.buzz/api/v1/models` lists audio models as OpenRouter does. Its default, `output_modalities=text`, leaves them out: with `output_modalities=transcription` it lists the speech-to-text models, with `output_modalities=speech` the text-to-speech models, and with `output_modalities=all` every model.

```bash
curl "https://api.llm.buzz/api/v1/models?output_modalities=speech" \
  -H "Authorization: Bearer $LLM_BUZZ_KEY"
```

A speech-to-text model's `architecture` is `audio->transcription`, and a text-to-speech model's is `text->speech`, with its voices in `supported_voices`; the model's page on the [Models page](/models) lists them too, under Voices. `pricing.prompt` is the price in USD per second of audio or per character of input text, at your rate, and `pricing.completion` is `"0"`.

Three more keys say what a model takes. A speech-to-text model's `verbose_json` says whether it answers `response_format: "verbose_json"`. A text-to-speech model's `speech_speed` says whether it takes `speed`, and its `default_voice` names the voice that speaks when you send none, or is `null` when the model needs a `voice`.

The [Models page](/models) shows each audio model's price per million seconds of audio or per million characters, such as "$3.33/M seconds", and its Output filter picks out transcription and speech models.

## Speech to text

`POST https://api.llm.buzz/api/v1/audio/transcriptions` transcribes one audio file. Send it as OpenRouter's JSON body, the file in base64 in `input_audio`, or as an OpenAI multipart upload in `file`, as the OpenAI SDK does.

```python tab="Python"
from openai import OpenAI
import os

client = OpenAI(
    base_url="https://api.llm.buzz/api/v1",
    api_key=os.environ["LLM_BUZZ_KEY"],
)
with open("call.mp3", "rb") as audio:
    transcript = client.audio.transcriptions.create(
        model="openai/whisper-large-v3-turbo",
        file=audio,
    )
print(transcript.text)
```

```bash tab="curl (JSON)"
curl https://api.llm.buzz/api/v1/audio/transcriptions \
  -H "Authorization: Bearer $LLM_BUZZ_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "openai/whisper-large-v3-turbo",
       "input_audio": {"data": "'"$(base64 -w0 call.mp3)"'", "format": "mp3"}}'
```

```bash tab="curl (multipart)"
curl https://api.llm.buzz/api/v1/audio/transcriptions \
  -H "Authorization: Bearer $LLM_BUZZ_KEY" \
  -F model=openai/whisper-large-v3-turbo \
  -F file=@call.mp3
```

The fields are OpenRouter's. A multipart upload sends each one as a text part, `provider` and `trace` as JSON text, and `timestamp_granularities[]` once per value.

| Field | Rule |
|---|---|
| `model` | Required: a speech-to-text model. Another model is a 400 on `model`. |
| `input_audio` | The JSON form: `data`, the file in base64, and `format`. A `url` is a 400: send the bytes. |
| `file` | The multipart form: the audio file. A `source_url` is a 400. |
| `response_format` | `json`, the default, answers `text` and `usage`. `verbose_json`, on a model whose `verbose_json` is `true`, adds `task`, `language`, `duration`, `segments`, and `words` when the provider sends them; on another model it is a 400. `text`, `srt`, `vtt` and any other value are a 400. |
| `language` | An ISO-639-1 code such as `en`. Omitted, the provider detects the language. |
| `temperature` | 0 to 1. |
| `timestamp_granularities` | `segment` and `word`, with `verbose_json` only. |
| `diarize`, `keyterms` | `diarize: true` and any `keyterms` are a 400: no model here diarizes or takes key terms. |
| `provider` | Only `zdr` and `data_collection` apply, as described in [Data policy](models-and-routing.md#data-policy). `order`, `only`, `ignore` and `options` are accepted and not applied, as on OpenRouter for audio. |
| `user`, `session_id`, `trace`, `prompt` | Accepted. `user` is recorded as the end-user label; the others are not sent to the provider. |

The file is at most 25 MiB: a larger file is a 413, and so is a JSON body over 39,234,221 bytes. It is one of `wav`, `mp3`, `flac`, `ogg` (Opus or Vorbis), `webm`, `m4a`, `aac`, or `pcm` (headerless signed 16-bit little-endian mono at 16 kHz). The format comes from `input_audio.format`, or from a multipart file's extension, else its content type.

llm.buzz reads the file and measures its duration before it calls a provider. A file it cannot read to the end, an empty file, or one too small to hold its duration (under 1,000 bits per second) is a 400 on `input_audio.data` or `file` that names the formats it takes. Such a file is never sent to a provider and never billed.

The answer:

```json
{"text": "Thanks for calling.", "usage": {"seconds": 12.48, "cost": 0.000041558}}
```

`usage.seconds` is what the call billed, and `usage.cost` its price in USD at your rate. With `verbose_json`, the provider's `task`, `language`, `duration`, `segments` and `words` come with it unchanged.

## Text to speech

`POST https://api.llm.buzz/api/v1/audio/speech` speaks one text and answers with the audio.

These samples send no `voice`, so the model's default voice speaks. To choose a voice, send `voice` with one of the IDs listed under Voices on the model's page, or as `supported_voices` by `GET https://api.llm.buzz/api/v1/models?output_modalities=speech`. The Python sample posts over plain HTTP with `httpx`, which the OpenAI Python SDK installs, because the OpenAI SDKs require `voice`.

```python tab="Python"
import os
import httpx

reply = httpx.post(
    "https://api.llm.buzz/api/v1/audio/speech",
    headers={"Authorization": f"Bearer {os.environ['LLM_BUZZ_KEY']}"},
    json={"model": "hexgrad/kokoro-82m", "input": "Your order has shipped.", "response_format": "mp3"},
    timeout=60,
)
reply.raise_for_status()
with open("reply.mp3", "wb") as file:
    file.write(reply.content)
```

```bash tab="curl"
curl https://api.llm.buzz/api/v1/audio/speech \
  -H "Authorization: Bearer $LLM_BUZZ_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "hexgrad/kokoro-82m", "input": "Your order has shipped.", "response_format": "mp3"}' \
  -o reply.mp3
```

| Field | Rule |
|---|---|
| `model` | Required: a text-to-speech model. Another model is a 400 on `model`. |
| `input` | Required: one string of 1 to 4,096 characters, counted as Unicode code points. An empty or longer string, or an array of turns, is a 400 on `input`. |
| `voice` | One of the model's voices, listed as `supported_voices` by `GET https://api.llm.buzz/api/v1/models?output_modalities=speech`; another voice is a 400 on `voice`. Omitted, the model's `default_voice` speaks, and a model whose `default_voice` is `null` answers a 400 on `voice`. With the OpenAI SDKs, which require `voice`, pass one of those IDs. |
| `response_format` | `pcm`, the default, or `mp3`. Any other value is a 400. |
| `speed` | 0.25 to 4.0, on a model whose `speech_speed` is `true`. A speed other than 1 on another model is a 400 on `speed`. |
| `input_references` | Voice cloning: a 400 unless empty. |
| `instructions` | Accepted and ignored, as on OpenRouter for these models. |
| `provider`, `user`, `session_id`, `trace` | As on speech to text. |

The answer is the whole audio, not streamed: `Content-Type: audio/mpeg` for `mp3`, or `audio/pcm` for `pcm` (signed 16-bit little-endian mono at the provider's sample rate, 24 kHz for `hexgrad/kokoro-82m`), with `Cache-Control: no-store`. A provider answer that is not valid audio in the format you asked for moves the call to the next provider and is not billed; from the last provider, the call bills the characters, as [Billing](#billing) says.

## Cost and the generation record

Every audio answer carries `X-Generation-Id`: `gen-` and 32 hex digits. A text-to-speech answer has no JSON body for its cost, so read it from the generation record. The record covers any finished request of your organization, not only audio: any request's generation ID is the `gen-` form of its `x-request-id`.

```bash
curl "https://api.llm.buzz/api/v1/generation?id=gen-..." \
  -H "Authorization: Bearer $LLM_BUZZ_KEY"
```

The answer is OpenRouter's `{"data": {...}}`, read in your key's organization:

| Key | Value |
|---|---|
| `total_cost`, `usage` | What you were charged, in USD at your rate. |
| `api_type` | `stt` for speech to text, `tts` for text to speech, `completions` for Chat Completions, Responses and Messages, `embeddings`, `image` and `decisions`. |
| `model` | The model's name. |
| `provider_name` | The provider that served the call. |
| `latency`, `generation_time` | In milliseconds. |
| `data_region` | `europe` for a provider in the EU, else `global`. |

A generation ID of another organization, a malformed one, and one whose request has not finished all answer 404. A request without `id` answers 400.

## Billing

Speech to text is billed per second of audio and text to speech per character of input text, at the serving provider's price and your rate. Neither has an output price. `GET https://api.llm.buzz/api/v1/models/{author}/{slug}/endpoints` lists each provider's price.

- Speech to text bills the seconds of audio llm.buzz measures from the file, or the provider's own count when it is longer, up to the measured duration rounded up to a whole multiple of 30 seconds. Some providers count a minimum or whole chunks; such a count is billed up to that bound, never more.
- Text to speech bills the characters of `input`.
- Before any provider is called, llm.buzz reserves the most the call can cost. A budget, a key limit or credit that cannot cover it answers 402 or 403, and nothing is sent to a provider. [Errors](errors.md) lists the codes.
- An attempt that fails and moves to the next provider bills nothing. When the last provider fails after it received the request, the call bills the measured seconds or the characters, never the 30-second rounding: a 1.5-second file whose only provider answers 500 bills 1.5 seconds. When the last attempt never reaches its provider (the connection is refused, or DNS or TLS fails), the call bills nothing.
- Neither route reads an `Idempotency-Key`: a retry is a new request, served and billed again.

## Privacy

No audio or text of an audio call is kept in llm.buzz's prompt logs.

## Not supported

Streaming synthesis, URL inputs, diarization, key terms, voice cloning, multi-speaker input, the `text`, `srt` and `vtt` transcription formats, and audio output from chat models (`modalities: ["text", "audio"]`). Audio input to a chat model (`input_audio` content parts on Chat Completions) is a chat capability of its own, apart from these routes.
