# Audio & speech

> Text-to-speech with POST /v1/audio/speech and speech-to-text with POST /v1/audio/transcriptions: voices, formats, timestamps, and billing.

Source: https://infro.io/docs/modalities/audio

> **Note:** INFRO is in private early access. Running these examples requires an early-access account, an active trial or subscription entitlement, and your own provider connection. Self-serve purchasing is not generally open. See [Early access](https://infro.io/early-access).

Two endpoints cover audio. `/v1/audio/speech` turns text into spoken audio, and `/v1/audio/transcriptions` turns an uploaded recording into text. Both are synchronous — there is no job to poll, unlike [video](https://infro.io/docs/modalities/video).

The shapes differ from the rest of the API in one way each: speech returns raw audio bytes rather than JSON, and transcription takes a `multipart/form-data` upload rather than a JSON body. Both are served by connections whose provider kind speaks OpenAI's audio API — OpenAI, Azure OpenAI, Groq, and OpenAI-compatible endpoints.

```text
POST https://api.infro.io/v1/audio/speech
POST https://api.infro.io/v1/audio/transcriptions
```

## Text to speech

- `model` (string, required) — A speech model, e.g. `openai/tts-1-hd`, or `elevenlabs/eleven-v3`, `cartesia/sonic-3`, or `minimax/speech-2.6` where one of your connections serves them through an OpenAI-compatible speech API. Latency, voice range, and language coverage differ sharply — compare them in the [catalog](https://infro.io/docs/models).
- `input` (string, required) — The text to speak, up to 1,000,000 characters (the request body is limited to 1 MB; your provider may allow far less). Punctuation drives prosody, so keep sentence marks intact.
- `voice` (string, required) — Voice ID from the model's voice list, up to 128 characters. IDs are vendor-specific and are not portable, which is why `fallbacks` across TTS vendors rarely works — a voice that exists on one vendor does not exist on another.
- `speed` (number) — `0.5`–`2.0`. Forwarded when set; the provider's default applies otherwise. Models without a rate control ignore it or refuse it.
- `format` (string) — Container and codec of the audio: `mp3` (default), `wav`, `opus`, `flac`, or `pcm`. Sent to the provider as its `response_format`; the exact sample rate and encoding of `pcm` are the provider's.
- `response_format` (string) — `audio` (default) returns the audio bytes as the response body. `url` stores the audio and returns JSON with a signed link that stops working 24 hours after the request, like generated images.

**curl**

```bash
curl https://api.infro.io/v1/audio/speech \
  -H "Authorization: Bearer $INFRO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "elevenlabs/eleven-v3",
    "input": "Your deployment finished. Two of forty checks were skipped.",
    "voice": "aria",
    "format": "mp3"
  }' \
  --output speech.mp3
```

**Python**

```python
import os

import requests

API_KEY = os.environ["INFRO_API_KEY"]

with requests.post(
    "https://api.infro.io/v1/audio/speech",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={
        "model": "elevenlabs/eleven-v3",
        "input": "Your deployment finished. Two of forty checks were skipped.",
        "voice": "aria",
        "format": "mp3",
    },
    stream=True,
    timeout=120,
) as res:
    res.raise_for_status()
    with open("speech.mp3", "wb") as f:
        for chunk in res.iter_content(chunk_size=8192):
            f.write(chunk)
```

**TypeScript**

```typescript
import { writeFile } from "node:fs/promises";

const res = await fetch("https://api.infro.io/v1/audio/speech", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.INFRO_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "cartesia/sonic-3",
    input: "Your deployment finished. Two of forty checks were skipped.",
    voice: "aria",
    format: "mp3",
  }),
});

if (!res.ok) throw new Error(await res.text());

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

**Dart**

```dart
import 'dart:convert';
import 'dart:io';

import 'package:http/http.dart' as http;

Future<void> main() async {
  final apiKey = Platform.environment['INFRO_API_KEY']!;

  final res = await http.post(
    Uri.parse('https://api.infro.io/v1/audio/speech'),
    headers: {
      'Authorization': 'Bearer $apiKey',
      'Content-Type': 'application/json',
    },
    body: jsonEncode({
      'model': 'elevenlabs/eleven-v3',
      'input': 'Your deployment finished. Two of forty checks were skipped.',
      'voice': 'aria',
      'format': 'mp3',
    }),
  );

  await File('speech.mp3').writeAsBytes(res.bodyBytes);
}
```

### Returning a link instead of audio

With `"response_format": "url"` the gateway reads the whole file from your provider, stores it, and answers with JSON. `audio.url` is signed and stops working at `audio.expires_at`. Usage is billed the same way in both modes, and the `X-INFRO-*` headers are sent on this response too.

```json
{
  "id": "req_7d2e4b90",
  "created": 1755950000,
  "model": "openai/tts-1-hd",
  "route": "primary",
  "route_name": null,
  "provider": "openai",
  "connection": "conn_4f10a2c7",
  "audio": {
    "url": "https://api.infro.io/media/aud/2026-09-24/org_.../req_7d2e4b90.mp3?exp=1756036400&sig=...",
    "expires_at": "2026-09-25T12:00:00.000Z",
    "mime_type": "audio/mpeg"
  },
  "usage": {
    "characters": 58,
    "cost": 0.00174,
    "price_source": "list"
  }
}
```

### Streaming playback

INFRO passes the provider's audio body through as it arrives, with the matching `Content-Type`. There is no separate streaming parameter — read the body as a stream instead of buffering it, as the Python tab above does, and playback can start as soon as your provider sends the first bytes.

Because the body is audio, the INFRO fields travel as response headers: `X-INFRO-Cost` (the estimated provider cost in USD, empty when the model is unpriced), `X-INFRO-Route`, `X-INFRO-Provider`, and `X-INFRO-Request-Id`.

`mp3` and `pcm` are the practical choices for play-while-downloading. `wav` writes a length header some players wait for, and `flac` has to be decoded in full before it is useful, so both are better suited to files you save rather than play live.

## Speech to text

- `file` (file, required) — The recording, sent as `multipart/form-data`. `mp3`, `wav`, `m4a`, `flac`, `ogg`, and `webm` are accepted, up to 25 MB; a larger file is refused with `400 invalid_request_error`, and a request body over 26 MB with `413 request_too_large` before it is read.
- `model` (string, required) — A transcription model, e.g. `openai/whisper-v3-turbo`, or `deepgram/nova-4` or `assemblyai/universal-3` where one of your connections serves them through an OpenAI-compatible transcription API — see the [catalog](https://infro.io/docs/models).
- `language` (string) — ISO 639-1 hint, e.g. `en` or `de`, forwarded to the provider. Omit to let the provider detect it.
- `timestamps` (boolean) — When `true`, INFRO asks the provider for segment timestamps (`verbose_json` with segment granularity) and the response includes a `segments` array with `start` and `end` offsets in seconds.
- `diarize` (boolean) — Implies `timestamps`. INFRO does not send a diarization option to the provider; a segment carries a `speaker` label only if the provider's response includes one.

**curl**

```bash
curl https://api.infro.io/v1/audio/transcriptions \
  -H "Authorization: Bearer $INFRO_API_KEY" \
  -F file=@standup.m4a \
  -F model="deepgram/nova-4" \
  -F language="en" \
  -F timestamps=true \
  -F diarize=true
```

**Python**

```python
import os

import requests

API_KEY = os.environ["INFRO_API_KEY"]

with open("standup.m4a", "rb") as audio:
    res = requests.post(
        "https://api.infro.io/v1/audio/transcriptions",
        headers={"Authorization": f"Bearer {API_KEY}"},
        files={"file": ("standup.m4a", audio, "audio/mp4")},
        data={
            "model": "deepgram/nova-4",
            "language": "en",
            "timestamps": "true",
            "diarize": "true",
        },
        timeout=600,
    )

res.raise_for_status()
transcript = res.json()

print(transcript["text"])
print(transcript["usage"]["cost"])
```

**TypeScript**

```typescript
import { openAsBlob } from "node:fs";

const form = new FormData();
form.append("file", await openAsBlob("standup.m4a"), "standup.m4a");
form.append("model", "deepgram/nova-4");
form.append("language", "en");
form.append("timestamps", "true");
form.append("diarize", "true");

const res = await fetch("https://api.infro.io/v1/audio/transcriptions", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.INFRO_API_KEY}` },
  body: form,
});

if (!res.ok) throw new Error(await res.text());

const transcript = await res.json();
console.log(transcript.text, transcript.usage.cost);
```

### Transcription response

```json
{
  "text": "Deploys are green. Two checks were skipped on the mobile build.",
  "language": "en",
  "model": "deepgram/nova-4",
  "route": "primary",
  "route_name": null,
  "provider": "openai-compatible",
  "connection": "conn_4f0a9d17",
  "segments": [
    {
      "id": 0,
      "start": 0.0,
      "end": 4.8,
      "speaker": "speaker_0",
      "text": "Deploys are green."
    },
    {
      "id": 1,
      "start": 4.8,
      "end": 12.4,
      "speaker": "speaker_1",
      "text": "Two checks were skipped on the mobile build."
    }
  ],
  "usage": {
    "seconds": 12.4,
    "cost": 0.0014,
    "price_source": "custom"
  }
}
```

`text` is always present and is the full transcript. `language` is what the provider reports, or `null`. `segments` appears only when `timestamps` (or `diarize`) is true, and `speaker` only when the provider labels speakers. `route`, `provider`, and `connection` name where the request ran; `usage.cost` is `null` when the model is unpriced.

> **Note:** INFRO does not store synthesized audio, input text, uploaded recordings, or transcripts from these two endpoints; it records request metadata (model, provider, status, duration or characters, estimated cost). See [Privacy & data](https://infro.io/docs/privacy).

## Billing

| Endpoint | Unit | Where cost is reported |
| --- | --- | --- |
| `/v1/audio/speech` | Per 1M characters of `input` | The `X-INFRO-Cost` response header, and the request log |
| `/v1/audio/transcriptions` | Per hour of audio, metered by the second | `usage.seconds` and `usage.cost` in the response |

Providers typically bill speech per character, and INFRO's estimate counts every character of `input`, including whitespace and punctuation. Transcription is estimated on the duration the provider reports, so silence and music in a recording count the same as speech — trim dead air before uploading if you are processing hours of it. Vendor list rates are in the [catalog](https://infro.io/providers).

## Sound effects and music

`POST /v1/audio/sound-effects` and `POST /v1/audio/music` take `{ model, prompt, duration_seconds?, response_format? }`; music also takes `instrumental` and `lyrics` where the model does (MiniMax sings the `lyrics` it is given and needs them; Lyria makes instrumental clips of a fixed length). Each model's lengths are checked before the call — ElevenLabs sound effects 0.5–30 s, Stable Audio 2.5 1–190 s — and a model that sets its own length refuses `duration_seconds`. The default answer is JSON with a signed link to the file, kept 24 hours; `response_format: "audio"` returns the file itself with its cost in `X-INFRO-Cost`. Priced per second, or per track. Music licences are the provider's own and narrower than "commercial use" on some plans; read them before you publish.

The text of a speech request, and the prompt of a sound or music request, passes your organization's input guardrails like a chat message: a blocking rule refuses it before any call, and a redacting rule changes what the provider receives.

## Long recordings

Transcription holds the connection open for the whole job, and a provider that does not answer within your connection's timeout counts as a failed attempt. Uploads are also capped at 25 MB. Split anything past about an hour into chunks on silence boundaries, transcribe them in parallel, and concatenate the text — offsetting each chunk's `segments` by its start time keeps timestamps continuous. Status codes and retry guidance are in [Errors](https://infro.io/docs/api/errors), and per-key throughput in [Rate & spend limits](https://infro.io/docs/api/limits).
