# Paxa STT Lite Realtime transcribes Thai turn by turn, as it is spoken

Paxa STT Lite Realtime transcribes Thai over one open WebSocket, finds the end of each turn on its own, and bills the audio it receives.

Published 2026-09-24. Paxa Labs.

A Thai voice agent cannot answer until it knows the caller has finished. Thai gives it very little to go on. The writing carries no full stop, the spaces fall between phrases, and a speaker's pause lands mid-sentence about as often as it lands at the end of one. Until September 23, a team building on our API transcribed finished recordings and wrote that judgment itself.

Paxa STT Lite Realtime now transcribes speech as the audio arrives over one WebSocket, decides where each turn ended, and delivers one finished transcript for it. The model id is `paxa-stt-lite-realtime-v1-preview` and the endpoint is `WS /v1/stt/live`.

## What shipped

- A realtime model, `paxa-stt-lite-realtime-v1-preview`, served over `WS /v1/stt/live`. One connection carries a whole conversation.
- Turn detection inside the socket. `speech_started` opens a turn, `speech_ended` closes it, and one final `transcript` follows.
- Audio in signed 16-bit PCM at 16 or 48 kHz, or in telephony mu-law at 8 kHz.
- The language hint, word timestamps, the style, the convention, and the vocabulary of `POST /v1/stt`, all fixed for the connection at `start`.
- Billing on the audio received, silence included, at 12.5 credits a minute. A `charged` receipt lands as the conversation runs.
- A live mode in the [Transcribe tool](https://paxalabs.com/app/transcribe), which relays a browser microphone with no code at all.

## One session, unedited

The ledger below is a capture from 2026-09-23. Three call-centre prompts, generated with Paxa TTS Flash, were played into one connection with a pause between them. That is 29.95 seconds of audio. The turn boundaries, the transcripts, and the charges are what the socket reported, reproduced without edits.

| Turn | Seconds | Credits | Transcript |
| --- | --- | --- | --- |
| 1 | 0 to 2.7 | 0.57 | สวัสดีค่ะขอบคุณที่ติดต่อบริษัทของเรา |
| 2 | 2.7 to 8 | 1.1 | กด 1 เพื่อติดต่อฝ่ายขายกด 2 ฝ่ายบริการลูกค้าหรือกด 0 เพื่อติดต่อเจ้าหน้าที่ค่ะ |
| 3 | 9.11 to 18.05 | 2.1 | สวัสดีค่ะคุณสมชายยอดชำระของท่านจำนวน 1,250 บาทครบกำหนดวันที่ 15 สิงหาคมนี้ชำระผ่านแอปได้ตลอด 24 ชั่วโมงค่ะ |
| 4 | 19.16 to 22.9 | 1.01 | ขณะนี้อยู่นอกเวลาทำการเราเปิดให้บริการวันจันทร์ถึงศุกร์ |
| 5 | 22.9 to 23.71 | 0.16 | เวลา |
| 6 | 23.92 to 26.44 | 0.57 | 9 โมงเช้าถึง 6 โมงเย็น |
| 7 | 26.44 to 28.55 | 0.44 | ฝากข้อความหลังเสียงสัญญาณได้เลยค่ะ |

Read turns 4 through 6 together and they are one sentence about opening hours. The speaker paused before the word for time and again before the numbers, and the socket closed a turn at each pause. That is the Thai boundary problem showing up in a real capture. An agent holding its reply until the caller has been quiet for a moment reads all three and answers once.

The turns charged 5.95 credits between them. The connection kept listening for another 1.4 seconds after the last turn closed, which added 0.29. The total the socket reported was 6.24 credits.

## What travels on the socket

Three frames go up and seven events come down. Audio is binary and everything else is JSON, on the one connection.

turn 1 on the wire:

```
client -> {"type":"start","model":"paxa-stt-lite-realtime-v1-preview",
           "audio":{"encoding":"pcm_s16le","sample_rate":16000},
           "language":"th","convention":"written"}
server <- {"type":"started","connection":"7d9c2b7e","model":"paxa-stt-lite-realtime-v1-preview"}
client -> <binary audio frames>
server <- {"type":"speech_started","turn":1,"time":0.000}
server <- {"type":"transcript","turn":1,"is_final":false,"text":"สวัสดีค่ะ"}
server <- {"type":"speech_ended","turn":1,"time":2.700}
server <- {"type":"charged","seq":1,"seconds":2.700,"credits":0.57}
server <- {"type":"transcript","turn":1,"is_final":true,"text":"สวัสดีค่ะขอบคุณที่ติดต่อบริษัทของเรา"}
client -> {"type":"end"}
server <- {"type":"done","turns":7,"total_credits":6.24}
```

- `start` names the model and the audio encoding and carries every option for the session. It is the first frame, exactly one per connection.
- `playback` tells the socket when your agent's own voice is playing into the room the microphone is in.
- `end` closes the turn in progress, delivers its transcript, and asks for `done`.

The events coming down are the other half of the contract.

- `started` confirms the session and names the connection id. Quote that id in a support request.
- `speech_started` opens a turn and carries where the speech began on the audio clock.
- `transcript` with `is_final` false is an interim draft of the turn so far. It is replaced by the next one.
- `speech_ended` closes the turn.
- `charged` is a billing receipt for one period, with the seconds it covered and the running total.
- `transcript` with `is_final` true is the turn. This is the text a program acts on.
- `error` names a failed turn, or reports a fatal failure before the close.
- `done` carries the connection's totals and precedes a normal close.

## Turn detection, and the interruption signal

`speech_started` arrives once the socket has heard enough to be sure. The time it reports is therefore earlier than the moment it lands on your side. Treat the event as the interruption signal. An agent that is mid-sentence stops its own audio and cancels the reply it was generating.

`speech_ended` says the turn is closed and its final transcript is coming. A voice agent can show that it is thinking from here. The final follows once that turn's billing period has landed, which is what keeps a transcript from arriving ahead of its receipt.

> The `playback` frame is what keeps an agent from transcribing itself. Send `{"type":"playback","speaking":true}` when your agent's audio starts and `false` when it stops. While it is true, opening a turn takes more sustained speech from the caller, and a real interruption still opens one.

Turns count from 1 per connection, and a higher number is always a later turn. An agent that already answered turn 3 drops turn 3 and answers turn 4. That single comparison is the whole of the staleness rule.

## The options carry over

Everything `POST /v1/stt` takes as a request field, the socket takes once in `start` and holds for the life of the connection. None of it changes the charge.

| Field | What it does |
| --- | --- |
| `language` | A BCP 47 hint. Left out, the model detects the language from the audio and writes each language in its own script. |
| `timestamps` | `"word"` adds a span array to every final transcript, one entry per word on the connection's clock. Thai words are segmented for you. |
| `style` | `verbatim` writes fillers and false starts as they were said. `clean` drops them and reads more easily. Verbatim is the default and follows the audio most closely. |
| `convention` | `spoken` writes numbers as the caller said them. `written` puts them in digits with units and ๆ, which is the form a booking or a payment field reads. |
| `vocabulary` | Up to 50 terms of 50 characters the conversation is likely to contain, spelled the way they should appear. |

Speaker numbering and subtitle files stay on the recording endpoint. One pass over a whole recording is what numbers the speakers and cuts the cues, and a live connection has no whole recording to read.

## The audio the socket takes

| Encoding | Sample rate | Where it comes from |
| --- | --- | --- |
| `pcm_s16le` | 16 or 48 kHz | Signed 16-bit little-endian PCM, mono. A browser microphone through an audio worklet, or a capture device at its native rate. |
| `mulaw` | 8 kHz | G.711 mu-law, mono, as a telephony platform hands it over. No transcoding on your side. |

- Binary frames carry the bytes in any chunking, up to 64 KiB a frame. Twenty milliseconds is a common size for a microphone.
- Any cadence works. Sending faster than real time is fine, and the clock the events report is the audio clock either way.
- The bytes are read exactly as `start` declared them. A wrong sample rate transcribes noise.

> An API key never belongs in a browser. The upgrade is authenticated with the same `Authorization` header as every other endpoint, and a browser WebSocket cannot send one. Relay the microphone through your own server.

## What a session costs

A connection is billed for the audio it receives, silence included, at 12.5 credits a minute. That is 750 credits an hour, about $0.75. In smaller units it is one hundredth of a credit for every 48 milliseconds on the wire.

The charge is taken in periods. A period closes at the end of each turn, after a minute of audio with no turn in it, and at the close of the session. Each one arrives as a `charged` receipt. The 0.1-credit request floor applies once per connection, on the first receipt.

A turn the model could not transcribe costs nothing. Its audio is held back from the running total and an `error` event names the turn and the credits it returned. A connection that delivered no turn at all is refunded in full.

| Workload | Audio received | Credits | Cost |
| --- | --- | --- | --- |
| One 3-minute call | 3 minutes | 37.5 | $0.04 |
| 200 calls in a day | 10 hours | 7,500 | $7.50 |
| A 30-day month of them | 300 hours | 225,000 | $225 |

The 29.95-second capture above cost 6.24 credits. A new account's 100 free credits cover about 8 minutes of a live connection.

In [the request log](https://paxalabs.com/app/logs) the whole connection is one row, whatever it took in turns. The row carries the audio received, the credits charged, and the connection id the socket announced at `started`.

## When something fails

An `error` carrying a `turn` leaves the connection open. That turn delivers no transcript, its audio is held back from the bill, and the next turn proceeds. An agent can treat it the way it treats a caller it did not hear and ask again.

A fatal failure closes the socket with a code. Three of them are worth handling in code.

- `1001` is a restart on our side. Reconnect and keep going. Everything the delivered periods charged stays charged.
- `4429` means the account already holds too many live connections. Close one and reconnect.
- `4503` means the backend could not be reached at `start`. Nothing was charged. Retry.

The full close-code list, with every frame field, is in the [realtime reference](https://paxalabs.com/docs/api/stt-live). Every code an `error` event can carry is in the [error reference](https://paxalabs.com/docs/errors).

## Limits

- A connection accepts 4 hours of audio, then closes with `done` the way an `end` frame would.
- An open connection holds one of the plan's [concurrent transcription slots](https://paxalabs.com/docs/rate-limits) for as long as it stays connected.
- A binary frame is 64 KiB at most.
- A connection with no frame in and no event out for 300 seconds closes.

## Recording or realtime

The two models answer different questions. A file that already exists belongs on `POST /v1/stt`, which reads the whole recording before it answers and costs less a minute for doing so.

|  | Paxa STT Lite | Paxa STT Lite Realtime |
| --- | --- | --- |
| Transport | `POST /v1/stt` | `WS /v1/stt/live` |
| Input | A file up to 60 minutes and 25 MiB | Binary audio for up to 4 hours |
| Answer | One transcript for the whole recording | One final transcript per turn |
| Rate | 8.33 credits a minute | 12.5 credits a minute |
| Speaker numbering | Yes, counted from 0 | Recording endpoint only |
| Subtitle files | srt and vtt | Recording endpoint only |
| Charged | Once, before transcription | In periods, as the conversation runs |

Both models are a research preview. They are sold at the published rate while they are evaluated, and their output and options may change between releases. The ids stay stable for as long as they serve.

## Opening a session

One start frame names the model and the audio encoding. Everything after it is binary audio going up and events coming down, on a connection that stays open for the conversation.

TypeScript:

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

// Runs on Bun as-is; on Node, `import WebSocket from "ws"` accepts
// the same constructor options. Browsers cannot send this header, and
// API keys never ship to browsers anyway: relay the audio through your
// server.
const socket = new WebSocket("wss://api.paxalabs.com/v1/stt/live", {
  headers: { Authorization: `Bearer ${process.env.PAXA_API_KEY}` },
});

socket.onopen = async () => {
  socket.send(JSON.stringify({
    type: "start",
    model: "paxa-stt-lite-realtime-v1-preview",
    audio: { encoding: "pcm_s16le", sample_rate: 16000 },
    language: "th",
  }));
  // 16 kHz signed 16-bit mono is 32,000 bytes a second. A microphone
  // delivers frames at this pace on its own; a file has to be paced.
  const file = await open("caller.pcm");
  const frame = new Uint8Array(640); // 20 ms
  for (;;) {
    const { bytesRead } = await file.read(frame, 0, frame.length);
    if (bytesRead === 0) break;
    socket.send(frame.subarray(0, bytesRead));
    await new Promise((resolve) => setTimeout(resolve, 20));
  }
  socket.send(JSON.stringify({ type: "end" }));
};

socket.onmessage = (event) => {
  const message = JSON.parse(String(event.data));
  switch (message.type) {
    case "speech_started":
      // The user is talking: stop the agent's audio, cancel its reply.
      break;
    case "transcript":
      if (message.is_final) console.log(`turn ${message.turn}: ${message.text}`);
      break;
    case "done":
      socket.close();
  }
};
```

Python:

```python
import json
import os
import time

from websockets.sync.client import connect  # pip install websockets

with connect(
    "wss://api.paxalabs.com/v1/stt/live",
    additional_headers={"Authorization": f"Bearer {os.environ['PAXA_API_KEY']}"},
) as socket:
    socket.send(json.dumps({
        "type": "start",
        "model": "paxa-stt-lite-realtime-v1-preview",
        "audio": {"encoding": "pcm_s16le", "sample_rate": 16000},
        "language": "th",
    }))
    # 16 kHz signed 16-bit mono is 32,000 bytes a second; a file has to
    # be paced the way a microphone paces itself.
    with open("caller.pcm", "rb") as audio:
        while frame := audio.read(640):  # 20 ms
            socket.send(frame)
            time.sleep(0.02)
    socket.send(json.dumps({"type": "end"}))

    for message in socket:
        event = json.loads(message)
        if event["type"] == "transcript" and event["is_final"]:
            print(event["turn"], event["text"])
        elif event["type"] == "done":
            break
```

Server events:

```json
{"type":"started","connection":"7d9c2b7e-4a41-4c8e-9a35-1f26d8c04b7a","model":"paxa-stt-lite-realtime-v1-preview"}
{"type":"speech_started","turn":1,"time":0.3}
{"type":"speech_ended","turn":1,"time":12.252}
{"type":"charged","seq":1,"seconds":12.252,"credits":2.56,"total_credits":2.56}
{"type":"transcript","turn":1,"is_final":true,"text":"สวัสดีค่ะคุณสมชายยอดชำระของท่านจำนวน พันสองร้อยห้าสิบ บาทครบกำหนดวันที่ สิบห้า สิงหาคมนี้ชำระผ่านแอปได้ตลอด ยี่สิบสี่ ชั่วโมงค่ะ","start":0.3,"end":12.252}
{"type":"done","total_seconds":12.252,"turns":1,"total_credits":2.56}
```

## Before you write any code

[Transcribe](https://paxalabs.com/app/transcribe) has a live mode. It asks for the microphone, relays the audio through our server, and shows each turn as it closes with the running charge beside it. Speak into it and watch where the boundaries fall on your own Thai.

## Before you ship

- Act on finals. An interim transcript is a draft and the next one replaces it.
- Send `playback` around your agent's own audio. Without it the agent hears itself and opens a turn.
- Drop a turn you already answered. The turn number is the only ordering a caller needs.
- Declare the encoding the bytes actually carry. A wrong sample rate reaches the model as noise.
- Reconnect on `1001`. A deploy on our side should be invisible to the person on the line.
- Set a `vocabulary` for the names your callers say. Product names, Thai place names, and staff names are what it is for.
- Hold a reply until the caller has been quiet for a moment. Thai pauses at phrase breaks, and a turn can close mid-sentence.

## Next steps

- Open [Transcribe](https://paxalabs.com/app/transcribe), switch to live mode, and speak a sentence your callers would say.
- Read [where a turn ends](https://paxalabs.com/guides/realtime-thai-transcription-turns) for why Thai makes the boundary awkward, and what the interim text is good for.
- Read the [live transcription segment](https://paxalabs.com/use-cases/live-transcription) for what a call-centre workload costs.
- Read the [speech to text product page](https://paxalabs.com/speech-to-text) for this session beside the recording endpoint's own capture.

The contract itself lives in the docs.

- [Realtime transcription](https://paxalabs.com/docs/api/stt-live): Every frame field and close code
- [Transcribe recordings](https://paxalabs.com/docs/speech-to-text): The options, and what each one does
- [Rate limits and concurrency](https://paxalabs.com/docs/rate-limits): Connections a plan may hold at once
- [Credits, charges, and refunds](https://paxalabs.com/docs/credits): Periods, receipts, and refunds
