# Realtime Thai transcription, and where a turn ends

Thai writes no full stop, and a live transcriber has to decide where the speaker finished anyway. What a turn is, what interim text is for, and what a live session costs.

Published 2026-09-24. Paxa Labs.

Thai does not close a sentence with a mark. The writing puts spaces at phrase and clause breaks and runs the rest together, and speech carries no punctuation at all. A transcriber working on a finished recording can read the whole file before it commits to anything. A transcriber working live has to commit while the person is still talking.

Paxa STT Lite Realtime settles that by working in turns. The socket watches the audio, decides where a stretch of speech ended, and sends one finished transcript for it. What follows covers the turn itself, why Thai makes the boundary awkward, what the interim text before it is good for, and what a session costs.

## What a turn is

A turn is one continuous stretch of one speaker's audio, bounded by the moment they started and the moment they stopped. The socket announces both. Between them it sends interim transcripts, each a fuller draft of the same turn. After the end it sends one final transcript, and the final is the text a program acts on.

A caller can always take words as they are recognized and decide for itself where a thought landed. That decision is the hard part of a live pipeline, and in Thai it is harder than the recognition.

turn 2:

```
speech_started  turn 2
transcript      turn 2  interim  ผม
transcript      turn 2  interim  ผมโอนไปแล้ว
transcript      turn 2  interim  ผมโอนไปแล้วเมื่อเช้า
speech_ended    turn 2
transcript      turn 2  final    ผมโอนไปแล้วเมื่อเช้านี้ครับ
```

The word that arrives last in that turn is ครับ, and it carries no information about the payment at all. It marks that the speaker is done. An agent acting on the third interim would have answered a sentence that was still being built.

## Where Thai makes the boundary awkward

Three properties of Thai speech work against an endpoint rule written for English.

- There is no full stop. A Thai sentence ends because it is finished, and the page marks that with a space at most. A rule waiting for a terminal mark waits forever.
- A polite utterance closes on a word. ครับ and ค่ะ end a turn, and so do นะคะ and แล้วครับ. They arrive after the content, which means the informative half of the sentence is complete before the speaker is.
- A pause lands at a phrase break. Thai puts its spaces between phrases, and a speaker stops at those same places in the middle of a sentence. The gap sits well short of the end of the thought.

The third one is what breaks a boundary rule built on silence alone. The cut lands between a noun and its classifier, or between a verb and the amount it governs. Each half of the split reads as a fragment that means less than the sentence did, and a downstream model answers the fragment it was handed.

Code-switching sharpens the same problem. Thai speakers switch into English inside a clause. Any gap that opens around the English word sits in the middle of a sentence that is still running. The transcript has to keep both scripts in one line and count that gap as part of the turn.

## What the interim text is for

Interim transcripts are drafts. Each one replaces the one before it, and any of them can change as more audio arrives. Put them on a screen where a person is watching a caption move. Keep them out of anything that acts: a booking made from an interim transcript is a booking made from half a sentence.

The final transcript is the commitment. It carries the turn number, the text, and where the turn sat in the audio. A voice agent answers on the final. A supervisor's keyword alert fires on the final. A live caption paints the interims and settles on the final.

> A turn boundary is also the signal to stop talking. The moment the socket reports speech_started, a voice agent cancels the reply it is speaking, which is how a caller talks over an assistant and is heard.

## What a live session costs

Billing follows the audio the socket receives, silence included, at 12.5 credits a minute. The connection is cut into periods at each turn and charged as it runs, which is what lets a long session report a running total. A turn the model fails to transcribe costs nothing, and the total reported already accounts for it.

In the request log the whole connection is one line, whatever it took in turns. Periods are how the money is counted; the session is what the caller asked for.

A recording that already exists belongs on the buffered endpoint. Paxa STT Lite takes a file up to 60 minutes, answers once with the whole transcript, and costs less a minute because nothing has to keep pace with a speaker.

## Opening the socket

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}
```

## Next steps

- Read the [realtime reference](https://paxalabs.com/docs/api/stt-live) for the frames, the events, and the close codes.
- Read the [transcription guide](https://paxalabs.com/docs/speech-to-text) for the options a start frame carries.
- Read the [live transcription segment](https://paxalabs.com/use-cases/live-transcription) for what a session costs at a call-centre workload.
- Open the [transcribe tool](https://paxalabs.com/app/transcribe) and switch to live mode. The browser relays its microphone and each turn lands as it closes.
