Skip to content

Thai ASR API

Thai speech-to-text: streaming and recorded audio.

For live Thai transcripts, inspect defaults, create a session, then stream PCM audio to the returned WebSocket URL. The streaming API uses Typhoon. File uploads can select Whisper, Typhoon or ElevenLabs Scribe v2 as documented below.

ElevenLabs Scribe v2: file-upload examples

GET/api/thai-asr/config

Inspect defaults and limits

Returns the current backend name, decode defaults, tenant information, and request/session/audio quota limits for the calling key.

POST/api/thai-asr/sessions

Create a streaming session

Returns a fresh `session_id`, the resolved `ws_url`, the accepted config, and current SLA target hints for the low-latency benchmark surface.

WS/api/thai-asr/stream

Stream audio and receive transcripts

After the server emits `ready`, send a `start` message, then raw PCM16LE mono audio chunks. Watch `partial`, `stabilized_partial`, and `final` events.

GET /api/thai-asr/config

Sample
curl -sS \
  -H "x-api-key: YOUR_API_KEY" \
  "https://kaleidovid.com/api/thai-asr/config"

Config response example

Sample
{
  "language": "th",
  "mode": "benchmark_spike",
  "backend": "persistent_decoder",
  "engine": "typhoon",
  "defaults": {
    "sample_rate": 16000,
    "frame_ms": 20,
    "stream_chunk_ms": 80,
    "partial_interval_ms": 40,
    "min_decode_audio_ms": 240,
    "decode_window_ms": 1600,
    "vad": true
  },
  "notes": [
    "This spike measures websocket, VAD, and partial transcript latency for Thai ASR on the 8x4090 host."
  ],
  "tenant": {
    "team_id": "team_cuid",
    "team_name": "Acme Team",
    "key_prefix": "abcd1234"
  },
  "limits": {
    "requests_per_minute": 120,
    "concurrent_sessions": 3,
    "daily_audio_seconds": 3600,
    "today_audio_seconds_used": 420
  }
}

Create a session

The session creation payload is JSON. You can omit fields to use the server defaults, but most clients should send the same values they expect to use on the WebSocket start message so there is no mismatch between planning and runtime.

FieldTypeRequiredNotes
sample_rateintegerNo8,000 to 48,000. Use 16,000 for the current Thai ASR defaults.
frame_msintegerNo10 to 200. The built-in smoke test uses 20 ms.
partial_interval_msintegerNo20 to 1,000. Controls how often the service tries to emit new partials.
min_decode_audio_msintegerNo100 to 5,000. Minimum buffered audio before partial decoding starts.
decode_window_msintegerNo200 to 15,000. Rolling audio window used for decode requests.
vadbooleanNoDefaults to true. Emits `speech_start` when voice activity is detected.
benchmark_labelstringNoOptional label up to 200 characters.

POST /api/thai-asr/sessions

Sample
curl -sS \
  -X POST \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_API_KEY" \
  -d '{
    "sample_rate": 16000,
    "frame_ms": 20,
    "partial_interval_ms": 40,
    "min_decode_audio_ms": 240,
    "decode_window_ms": 1600,
    "vad": true
  }' \
  "https://kaleidovid.com/api/thai-asr/sessions"

Session response example

Sample
{
  "session_id": "e6a4c4f6c69f4ca1aa8f9b3ad8d515f0",
  "mode": "benchmark_spike",
  "language": "th",
  "engine": "typhoon",
  "backend": "persistent_decoder",
  "ws_url": "wss://kaleidovid.com/api/thai-asr/stream?session_id=e6a4c4f6c69f4ca1aa8f9b3ad8d515f0",
  "config": {
    "sample_rate": 16000,
    "frame_ms": 20,
    "partial_interval_ms": 40,
    "min_decode_audio_ms": 240,
    "decode_window_ms": 1600,
    "vad": true,
    "benchmark_label": null
  },
  "sla_targets": {
    "speech_detected_p95_ms": 60,
    "first_partial_p95_ms": 250,
    "final_after_eou_p95_ms": 800
  },
  "tenant": {
    "team_id": "team_cuid",
    "team_name": "Acme Team",
    "key_prefix": "abcd1234"
  }
}

WebSocket flow

Use the exact ws_url returned by session creation. Browser clients typically append api_key to that URL because custom WebSocket headers are harder to set in the browser. Binary audio frames are the preferred transport.

Production caption rule: render one live caption from stabilized_partial when available, otherwise fall back to partial. Only persist the transcript after receiving final.

Client messages

FieldTypeRequiredNotes
startJSON messageYesSend once after the server emits `ready`. Carries the same config fields used in session creation.
binary audio frameraw bytesYesRecommended path. Send PCM16LE mono audio bytes only, without WAV headers.
audioJSON messageNoFallback JSON shape: `{ "type": "audio", "audio_b64": "..." }` where the payload is base64-encoded PCM16LE audio.
flushJSON messageNoForces the service to emit a best-effort partial from current buffered audio.
end / end_utteranceJSON messageYesFinalizes the utterance and triggers the `final` event.
resetJSON messageNoClears buffered state so another utterance can run on the same socket.

Server events

FieldTypeRequiredNotes
readyserver eventAlwaysFirst event on a successful connection. Includes team info, backend, limits, and sample rate.
startedserver eventAlwaysConfirms the stream is configured and ready for audio frames.
speech_startserver eventOptionalEmitted when VAD first detects speech. Includes `offset_ms` and `latency_ms`.
partialserver eventOptionalBest-effort rolling transcript. Includes `text`, `segments`, `audio_ms`, `latency_ms`, `queue_ms`, and `decode_ms`.
stabilized_partialserver eventOptionalA partial that repeated enough times to look stable. Use this for live captions when available.
finalserver eventAlways on successFinal transcript with segments and latency metrics. Persist only this event in production workflows.
errorserver eventOn failureCarries `code` and `error` for issues such as timeout, decode failures, or stream failures.
reset_okserver eventOptionalAcknowledges a successful `reset` command.

JavaScript integration example

Sample
const API_KEY = "YOUR_API_KEY";
const HTTP_BASE = "https://kaleidovid.com";

const config = await fetch(`${HTTP_BASE}/api/thai-asr/config`, {
  headers: { "x-api-key": API_KEY },
}).then(async (response) => {
  if (!response.ok) throw new Error(await response.text());
  return response.json();
});

const session = await fetch(`${HTTP_BASE}/api/thai-asr/sessions`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "x-api-key": API_KEY,
  },
  body: JSON.stringify({
    sample_rate: 16000,
    frame_ms: 20,
    partial_interval_ms: 40,
    min_decode_audio_ms: 240,
    decode_window_ms: 1600,
    vad: true,
  }),
}).then(async (response) => {
  if (!response.ok) throw new Error(await response.text());
  return response.json();
});

const wsUrl = `${session.ws_url}&api_key=${encodeURIComponent(API_KEY)}`;
const socket = new WebSocket(wsUrl);

socket.onmessage = (event) => {
  const payload = JSON.parse(event.data);
  switch (payload.type) {
    case "ready":
      socket.send(JSON.stringify({
        type: "start",
        sample_rate: 16000,
        frame_ms: 20,
        partial_interval_ms: 40,
        min_decode_audio_ms: 240,
        decode_window_ms: 1600,
        vad: true,
      }));
      break;
    case "partial":
    case "stabilized_partial":
      console.log("live", payload.text);
      break;
    case "final":
      console.log("final", payload.text, payload.segments);
      break;
    case "error":
      console.error(payload.code, payload.error);
      break;
  }
};

// `pcmChunks` must contain raw PCM16LE mono audio frames.
// Do not send WAV headers after the socket is started.
for (const chunk of pcmChunks) {
  socket.send(chunk);
}

socket.send(JSON.stringify({ type: "flush" }));
socket.send(JSON.stringify({ type: "end" }));

`final` event example

Sample
{
  "type": "final",
  "session_id": "e6a4c4f6c69f4ca1aa8f9b3ad8d515f0",
  "engine": "typhoon",
  "backend": "persistent_decoder",
  "text": "สวัสดีครับ นี่คือการทดสอบระบบถอดเสียงภาษาไทยแบบหน่วงต่ำ",
  "segments": [
    {
      "start": 0.0,
      "end": 2.84,
      "text": "สวัสดีครับ นี่คือการทดสอบระบบถอดเสียงภาษาไทยแบบหน่วงต่ำ"
    }
  ],
  "audio_ms": 2840,
  "latency_ms": 134,
  "queue_ms": 0,
  "decode_ms": 134,
  "processing_time_ms": 133,
  "audio_duration_ms": 2840
}

File uploads: choose an ASR engine

Whisper, Typhoon and ElevenLabs Scribe v2

Choose an engine per recording and check X-ASR-Engine in the response. ElevenLabs Scribe v2 uses engine=elevenlabs_scribe and requires a server-side ElevenLabs API key. Available engines depend on your AI-service deployment.

POST/api/ai/transcribe-audio

Transcribe a completed recording

Send multipart/form-data with a file and an optional engine field. Choose whisper, typhoon or elevenlabs_scribe for each request. Omitting engine keeps Whisper as the default. This endpoint returns a completed transcript, without streaming partial results.

Set AI_SERVICE_URL to the AI-service base URL supplied for your deployment, and use any authentication required by its gateway. This is separate from the public /api/thai-asr streaming base URL. The streaming API remains Typhoon-only; this engine field applies to file uploads.

FieldTypeRequiredNotes
filefileYesAudio recording, such as WAV or FLAC. Keep the multipart field name file.
enginestringNowhisper (default), typhoon or elevenlabs_scribe (ElevenLabs Scribe v2). Send this lowercase value as a multipart form field. Unsupported values return 422.

Choose Typhoon

Sample
AI_SERVICE_URL="https://your-ai-service.example"

curl -i "$AI_SERVICE_URL/api/ai/transcribe-audio" \
  -F "file=@recording.flac" \
  -F "engine=typhoon"

Choose Whisper

Sample
curl -i "$AI_SERVICE_URL/api/ai/transcribe-audio" \
  -F "file=@recording.wav" \
  -F "engine=whisper"

Use the same AI_SERVICE_URL for all examples. Removing the engine field also uses Whisper. For Scribe v2, require X-ASR-Engine: elevenlabs_scribe in the successful response. API credentials stay on the server.

JavaScript file upload

Sample
// Use the AI-service URL supplied for your deployment.
const AI_SERVICE_URL = "https://your-ai-service.example";
const body = new FormData();
body.append("file", audioFile);
body.append("engine", "typhoon");

const response = await fetch(`${AI_SERVICE_URL}/api/ai/transcribe-audio`, {
  method: "POST",
  body,
  // Add any authentication required by your service gateway.
  // The browser sets Content-Type and the multipart boundary.
});
if (!response.ok) throw new Error(`ASR failed: ${response.status}`);
if (response.headers.get("X-ASR-Engine") !== "typhoon") {
  throw new Error("The service did not confirm Typhoon; check its rollout.");
}
const { text, segments, language } = await response.json();

Successful Typhoon response

Sample
HTTP/1.1 200 OK
Content-Type: application/json
X-ASR-Engine: typhoon

{
  "text": "สวัสดี",
  "segments": [
    {"start": 0.0, "end": 1.0, "text": "สวัสดี"}
  ],
  "language": "th"
}

The JSON response keeps text, segments and language. Segment and word timestamps are preserved when supplied by the engine. X-ASR-Engine identifies the selected engine and is readable by browser clients. X-Request-ID is forwarded to the selected upstream service. Typhoon and Scribe failures never silently fall back to Whisper.

ElevenLabs Scribe v2

Upload a completed recording to your AI service's POST /api/ai/transcribe-audio endpoint with file and engine=elevenlabs_scribe. The adapter uses the fixed ElevenLabs model scribe_v2 for Thai transcription. Do not send model_id: it is selected on the server. This engine selection applies to file uploads, not /api/thai-asr streaming sessions or the Reading Score API.

Choose ElevenLabs Scribe v2

Sample
AI_SERVICE_URL="https://your-ai-service.example"

curl -i "$AI_SERVICE_URL/api/ai/transcribe-audio" \
  -F "file=@recording.flac" \
  -F "engine=elevenlabs_scribe"

Successful Scribe v2 response

Sample
HTTP/1.1 200 OK
Content-Type: application/json
X-ASR-Engine: elevenlabs_scribe
Access-Control-Expose-Headers: X-ASR-Engine

{
  "text": "สวัสดี",
  "segments": [
    {
      "start": 0.2,
      "end": 0.8,
      "text": "สวัสดี",
      "words": [
        {
          "word": "สวัสดี",
          "start": 0.2,
          "end": 0.8
        }
      ]
    }
  ],
  "language": "th"
}

Require X-ASR-Engine: elevenlabs_scribe before accepting a successful response. The JSON contains text, language (th), and segments. Word timestamps are in segments[].words[] as word, start, and end; times are seconds from the start of the recording. The adapter preserves the transcript in one segment spanning the returned words, or returns an empty segments array when no words are returned. It does not return the raw ElevenLabs response or speaker labels.

JavaScript: Scribe v2 upload and verification

Sample
const AI_SERVICE_URL = "https://your-ai-service.example";
const engine = "elevenlabs_scribe";
const body = new FormData();
body.append("file", audioFile);
body.append("engine", engine);

const response = await fetch(`${AI_SERVICE_URL}/api/ai/transcribe-audio`, {
  method: "POST",
  body,
  // Add authentication required by your AI-service gateway, if any.
  // Let the browser set the multipart Content-Type and boundary.
});
if (!response.ok) throw new Error(`ASR failed: ${response.status}`);
if (response.headers.get("X-ASR-Engine") !== engine) {
  throw new Error("The service did not confirm ElevenLabs Scribe v2.");
}
const { text, segments, language } = await response.json();
const words = segments.flatMap((segment) => segment.words ?? []);
console.log(text, language, words);

The service operator configures ELEVENLABS_API_KEY on the AI service. Use a real secret API key, not an API Key ID; never put it in browser code. Use an HTTPS AI-service gateway with CORS for browser uploads, and supply only that gateway's required authentication. A missing or rejected provider key returns 503. Scribe errors never silently fall back to another engine; the common error codes below apply.

StatusMeaning
422Invalid engine, missing file, or invalid/unsupported audio.
429The selected provider is rate limited. Honor Retry-After when present.
502The upstream request or response failed.
503The selected provider is not configured or rejected its credentials. Contact the service operator.
504Audio conversion or transcription timed out.