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

# Speech-to-Text

> Transcribe audio to text

# Speech-to-Text

Transcribe audio files to text. A single endpoint serves three modes — default async, opt-in streaming, opt-in sync — controlled by **query parameters**.

<Note>
  Edge endpoints accept your `pg_*` API key as a bearer token. See [Authentication](/authentication) for details. The cURL examples below pin Toronto (`yto-01`) for concreteness — substitute another region or discover the fastest one via `GET https://autorouter.polargrid.ai/v1/route`.
</Note>

## Transcribe Audio

```
POST /v1/audio/transcriptions
```

The multipart body carries **only the `file`**. Everything else is a query parameter.

### Query Parameters

| Parameter         | Type    | Required | Default | Description                                                            |
| ----------------- | ------- | -------- | ------- | ---------------------------------------------------------------------- |
| `model`           | string  | Yes      | —       | STT model (e.g. `whisper-large-v3-turbo`, `cohere-transcribe-03-2026`) |
| `language`        | string  | No       | —       | ISO-639-1 language code                                                |
| `prompt`          | string  | No       | —       | Context hint to guide transcription                                    |
| `response_format` | string  | No       | `json`  | `json`, `text`, `srt`, `vtt`, or `verbose_json`                        |
| `temperature`     | number  | No       | 0       | Sampling temperature (0.0-1.0)                                         |
| `punctuation`     | boolean | No       | —       | Force/forbid punctuation in the output                                 |
| `stream`          | boolean | No       | `false` | If `true`, return Server-Sent Events                                   |
| `sync`            | boolean | No       | `false` | If `true`, block until completion and return the result inline         |

### Three Modes

| Query flags    | Response                                                           | Use when                                                                              |
| -------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| *(none)*       | **`202 Accepted`** with `{ job_id, status, poll_url }`             | **Default.** Best for long files; poll with `GET /v1/audio/transcriptions?job_id=...` |
| `?stream=true` | `200` **SSE** of `transcript.text.delta` + `transcript.text.done`  | Best UX for real-time display                                                         |
| `?sync=true`   | `200` with formatted body (JSON / text / SRT / VTT / verbose JSON) | Small files when you can wait inline                                                  |

`stream=true` and `sync=true` are mutually exclusive.

### OpenAI SDK Drop-In

OpenAI's transcription endpoint is strictly synchronous, and stock OpenAI
SDKs send `model`, `language`, `response_format`, and `temperature` as
**multipart form fields** — they have no way to add query parameters. The
endpoint accepts both shapes:

* **Form fields are honored as fallbacks** for any query parameter you
  don't set (query always wins when both are present).
* **A request whose `model` arrives as a form field with no mode flags is
  served synchronously** — a stock OpenAI SDK gets the transcript inline,
  exactly as it does against OpenAI.

```python theme={null}
from openai import OpenAI

client = OpenAI(
    api_key="pg_your_api_key",
    base_url="https://api.yto-01.edge.polargrid.ai/v1",
)
result = client.audio.transcriptions.create(
    model="whisper-large-v3-turbo",
    file=open("recording.mp3", "rb"),
)
print(result.text)
```

<Note>
  The async-job default (`202` + `poll_url`) applies only to PolarGrid-style
  requests that pass `model` as a query parameter. If you build raw requests
  and want inline results, pass `?sync=true`.
</Note>

### Available Models

| Model                       | Description                                     |
| --------------------------- | ----------------------------------------------- |
| `whisper-large-v3-turbo`    | OpenAI Whisper, fast multilingual transcription |
| `cohere-transcribe-03-2026` | Cohere transcription, 14 languages              |

### Examples

#### Default — async job

<CodeGroup>
  ```bash cURL theme={null}
  # 1. Submit
  curl -X POST "https://api.yto-01.edge.polargrid.ai/v1/audio/transcriptions?model=whisper-large-v3-turbo&language=en" \
    -H "Authorization: Bearer pg_your_api_key" \
    -F "file=@recording.mp3"

  # {
  #   "job_id": "job_transcription_a1b2c3d4e5f6",
  #   "status": "accepted",
  #   "poll_url": "/v1/audio/transcriptions?job_id=job_transcription_a1b2c3d4e5f6"
  # }

  # 2. Poll
  curl "https://api.yto-01.edge.polargrid.ai/v1/audio/transcriptions?job_id=job_transcription_a1b2c3d4e5f6" \
    -H "Authorization: Bearer pg_your_api_key"
  ```

  ```javascript JavaScript theme={null}
  // SDK helper: submit and wait for the result.
  const result = await client.transcribeAndWait({
    file,
    model: 'whisper-large-v3-turbo',
    language: 'en',
  });
  console.log(result.text);
  ```

  ```python Python theme={null}
  result = await client.transcribe_and_wait(
      file=Path("recording.mp3"),
      model="whisper-large-v3-turbo",
      language="en",
  )
  print(result.text)
  ```
</CodeGroup>

#### Streaming — SSE

<CodeGroup>
  ```bash cURL theme={null}
  curl -N -X POST "https://api.yto-01.edge.polargrid.ai/v1/audio/transcriptions?stream=true&model=whisper-large-v3-turbo&language=en" \
    -H "Authorization: Bearer pg_your_api_key" \
    -F "file=@recording.mp3"
  ```

  ```javascript JavaScript theme={null}
  for await (const evt of client.transcribeStream({
    file,
    model: 'whisper-large-v3-turbo',
    language: 'en',
  })) {
    if (evt.type === 'transcript.text.delta') console.log('[partial]', evt.delta);
    if (evt.type === 'transcript.text.done') console.log(evt.text);  // authoritative
  }
  ```

  ```python Python theme={null}
  async for evt in client.transcribe_stream(
      Path("recording.mp3"),
      TranscriptionRequest(model="whisper-large-v3-turbo", language="en"),
  ):
      if evt.type == "transcript.text.delta":
          print(f"[partial] {evt.delta}")   # provisional; see Delta semantics
      elif evt.type == "transcript.text.done":
          print(evt.text)                   # authoritative transcript
  ```

  ```bash CLI theme={null}
  polargrid transcribe recording.mp3 --model whisper-large-v3-turbo --stream
  ```
</CodeGroup>

#### Sync — blocking request

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.yto-01.edge.polargrid.ai/v1/audio/transcriptions?sync=true&model=whisper-large-v3-turbo&language=en" \
    -H "Authorization: Bearer pg_your_api_key" \
    -F "file=@recording.mp3"
  ```

  ```javascript JavaScript theme={null}
  const transcription = await client.transcribe({
    file,
    model: 'whisper-large-v3-turbo',
    language: 'en',
    stream: false,  // hits the ?sync=true path
  });
  console.log(transcription.text);
  ```

  ```python Python theme={null}
  transcription = await client.transcribe(
      file=Path("recording.mp3"),
      request=TranscriptionRequest(
          model="whisper-large-v3-turbo",
          language="en",
          stream=False,
      ),
  )
  print(transcription.text)
  ```
</CodeGroup>

#### Cohere model — same surface, different model id

`cohere-transcribe-03-2026` supports the same
sync, stream, and async modes. Swap the `model` query parameter -- everything
else is identical:

<CodeGroup>
  ```bash cURL theme={null}
  curl -N -X POST "https://api.yto-01.edge.polargrid.ai/v1/audio/transcriptions?stream=true&model=cohere-transcribe-03-2026" \
    -H "Authorization: Bearer pg_your_api_key" \
    -F "file=@recording.mp3"
  ```

  ```javascript JavaScript theme={null}
  for await (const evt of client.transcribeStream({
    file,
    model: 'cohere-transcribe-03-2026',
  })) {
    if (evt.type === 'transcript.text.delta') console.log('[partial]', evt.delta);
    if (evt.type === 'transcript.text.done') console.log(evt.text);
  }
  ```

  ```python Python theme={null}
  async for evt in client.transcribe_stream(
      Path("recording.mp3"),
      TranscriptionRequest(model="cohere-transcribe-03-2026"),
  ):
      if evt.type == "transcript.text.delta":
          print(f"[partial] {evt.delta}")
      elif evt.type == "transcript.text.done":
          print(evt.text)
  ```
</CodeGroup>

Cohere covers 14 languages but does **not** auto-detect: it requires a language to decode and falls back to `en` when `language` is omitted. The audio is still transcribed correctly (the model is multilingual), but the `language` field in the response will report `en` rather than the spoken language. Pass `language` explicitly (e.g. `&language=fr`) whenever you need the response metadata to reflect the actual language.

### Live Streaming — WebSocket

```
wss://api.<node>.edge.polargrid.ai/v1/audio/transcriptions/ws
```

The upload modes above need the complete file before transcription starts.
The WebSocket surface transcribes **while you capture**: stream PCM frames
from the microphone and partial transcripts arrive during the utterance.

* Connect with `?token=<pg_* key or session JWT>`; optional `model`,
  `language`, `prompt`, `window_s` query params
* Send **binary** frames: 16 kHz mono little-endian 16-bit PCM
* Send `{"type":"stop"}` (text frame) to end the utterance
* Receive the same event vocabulary as SSE: `transcript.text.delta` per
  \~1 s window of new audio, then one authoritative `transcript.text.done`
  after `stop` (see [Delta semantics](#delta-semantics) — deltas are
  provisional display hints, not concatenable fragments)

```python theme={null}
import asyncio, json, websockets

async def live_transcribe(frames):
    url = "wss://api.yto-01.edge.polargrid.ai/v1/audio/transcriptions/ws" \
          "?token=pg_your_api_key&model=whisper-large-v3-turbo"
    async with websockets.connect(url) as ws:
        async def send():
            async for frame in frames:        # 16kHz mono int16 PCM chunks
                await ws.send(frame)
            await ws.send(json.dumps({"type": "stop"}))
        async def recv():
            async for msg in ws:
                ev = json.loads(msg)
                if ev["type"] == "transcript.text.delta":
                    print(f"[partial] {ev['delta']}")   # provisional; see Delta semantics
                elif ev["type"] == "transcript.text.done":
                    return ev["text"]                   # authoritative transcript
        _, text = await asyncio.gather(send(), recv())
        return text
```

Sessions are capped at 120 seconds of audio — this surface targets
conversational turns. For long recordings use the upload endpoint.

Measured on a production edge (100 runs, 5-clip 4.2–7.7 s corpus streamed at
real-time mic pace): first partial arrives \~1 s into the utterance (the first
window boundary), subsequent partials each window while audio is still
flowing, and the authoritative `done` lands **159 ms p50 / 205 ms p95 after
the `stop` marker** — the effective end-of-speech-to-transcript latency for a
voice agent on this surface. See the
[whisper-large-v3-turbo model card](/models/whisper-large-v3-turbo) for the
full table.

### SSE Event Types

| `type`                  | Fields                             | When                                                  |
| ----------------------- | ---------------------------------- | ----------------------------------------------------- |
| `transcript.text.delta` | `delta` (string)                   | Zero or more, provisional (see Delta semantics below) |
| `transcript.text.done`  | `text`, `duration` (s), `language` | Exactly once, before the stream closes                |
| `error`                 | `error` (string)                   | On failure — stream ends after                        |

Terminated by `data: [DONE]\n\n` (SSE surface only).

### Delta semantics

Applies to both the SSE and WebSocket surfaces, which share the same engine.
Each window re-transcribes the full audio buffered so far, producing a new
hypothesis. The `delta` field then carries **one of two things**:

* the **new suffix**, when the new hypothesis extends the previous one
  unchanged (e.g. `"  This is a short."`), or
* the **full revised hypothesis**, when re-decoding changed earlier text
  (e.g. `"Hello.  This is a short test phrase for"`).

The two cases are not distinguishable from the event alone, so **do not
concatenate deltas** (you will render duplicated text) and **do not replace
your display with a bare delta** (you will drop earlier text when the delta
is a suffix). Treat deltas as low-latency provisional output for live
display, and always take the final transcript from `done.text` — it is the
only authoritative value, produced by a fresh pass over the complete audio.

### Polling Responses

`GET /v1/audio/transcriptions?job_id=...` returns:

* `202` while the job is `accepted` / `processing` — `{ job_id, status, poll_interval_ms }`
* `200` when `completed` — formatted body, with `job_id` and `status: "completed"`
* `400` on `failed` / `cancelled`

## Supported Audio Formats

MP3, WAV, M4A, OGG, FLAC, WebM

## Limits

| Limit               | Value      | On exceed                                                           |
| ------------------- | ---------- | ------------------------------------------------------------------- |
| Maximum upload size | **100 MB** | `413` with a structured JSON error (`error.code: "file_too_large"`) |

```json theme={null}
{
  "message": "File too large. Maximum upload size is 100MB. For longer recordings, split the audio into segments and submit them as separate requests.",
  "error": {
    "message": "File too large. Maximum upload size is 100MB. ...",
    "type": "invalid_request_error",
    "code": "file_too_large"
  }
}
```

There is no separate duration limit — only size. As a rule of thumb, 100 MB
holds roughly 1.5 hours of 16 kHz mono WAV or many hours of MP3.

For recordings over the limit, split the audio into segments (for example
with `ffmpeg -f segment`), submit each segment as its own request — the
default async mode is built for this — and concatenate the transcripts.
Splitting on silence rather than fixed intervals avoids cutting words in
half.
