Developer platform
Loli 2.0 Speech-to-Text API
Public API contract for file transcription, NDJSON progress and real-time speech recognition over a WebSocket.
Endpoints
/api/v1/stt/transcriptionsTranscribes one audio file and returns the result as JSON.
/api/v1/stt/transcriptions?stream=trueTranscribes an audio file and returns progress and results as NDJSON lines.
/api/public/v1/stt/ws?token=<stt_key>Takes binary PCM16 and returns partial, segment and final results in real time.
/api/public/v1/auth/checkChecks the status of an STT API key.
Needs verification: public gateway configuration
File transcription
/api/v1/stt/transcriptionsSend multipart/form-data and receive the full transcription when it finishes.
Form fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
audio | file | Yes | - | Audio file, up to 100 MiB. file is accepted as an alias. |
language | string | No | auto | auto, one of the 30 single-language modes, or one of the three bilingual modes listed below. |
stream | boolean | No | false | Query parameter. Set true to receive NDJSON progressively. |
Containers recognised at present: WAV 16-bit PCM, MP3, WebM, OGG and MP4 or M4A. Decoded duration must be between about 0.2 and 1,800 seconds. Which encoded formats decode depends on the deployment, so do not rely on the filename extension alone.
Examples
curl -X POST "https://studio.evomlabs.com/api/v1/stt/transcriptions" \
-H "Authorization: Bearer stt_sk_live_YOUR_KEY" \
-F "audio=@recording.wav" \
-F "language=auto"import requests
with open("recording.wav", "rb") as audio:
response = requests.post(
"https://studio.evomlabs.com/api/v1/stt/transcriptions",
headers={"Authorization": "Bearer stt_sk_live_YOUR_KEY"},
files={"audio": audio},
data={"language": "auto"},
timeout=1800,
)
response.raise_for_status()
print(response.json()["text"])Response
{
"text": "Nแปi dung ฤรฃ phiรชn รขm",
"language": "vi",
"duration_ms": 1234,
"provider": "loli-asr",
"model": "Loli 2.0"
}Important
ok/data envelope; it returns the transcription object directly. duration_ms is processing latency, not the length of the audio.Language modes
| Mode | Languages accepted |
|---|---|
auto | Every supported language; the model detects it for you |
vi | ๐ป๐ณ Vietnamese |
en | ๐ฌ๐ง English |
zh | ๐จ๐ณ Chinese |
yue | ๐ญ๐ฐ Cantonese |
ja | ๐ฏ๐ต Japanese |
ko | ๐ฐ๐ท Korean |
th | ๐น๐ญ Thai |
id | ๐ฎ๐ฉ Indonesian |
ms | ๐ฒ๐พ Malay |
tl | ๐ต๐ญ Filipino |
hi | ๐ฎ๐ณ Hindi |
ar | ๐ธ๐ฆ Arabic |
fa | ๐ฎ๐ท Persian |
ru | ๐ท๐บ Russian |
mk | ๐ฒ๐ฐ Macedonian |
el | ๐ฌ๐ท Greek |
tr | ๐น๐ท Turkish |
de | ๐ฉ๐ช German |
fr | ๐ซ๐ท French |
es | ๐ช๐ธ Spanish |
pt | ๐ต๐น Portuguese |
it | ๐ฎ๐น Italian |
nl | ๐ณ๐ฑ Dutch |
pl | ๐ต๐ฑ Polish |
cs | ๐จ๐ฟ Czech |
ro | ๐ท๐ด Romanian |
hu | ๐ญ๐บ Hungarian |
sv | ๐ธ๐ช Swedish |
da | ๐ฉ๐ฐ Danish |
fi | ๐ซ๐ฎ Finnish |
en-vi | ๐ฌ๐ง / ๐ป๐ณ English and Vietnamese |
ko-en | ๐ฐ๐ท / ๐ฌ๐ง Korean and English |
ja-en | ๐ฏ๐ต / ๐ฌ๐ง Japanese and English |
Note
Full coverage on the Languages page.
NDJSON progress
/api/v1/stt/transcriptions?stream=trueEmits one complete JSON object per line as each chunk finishes, instead of waiting for the whole file. The response carries Content-Type application/x-ndjson.
curl -N -X POST "https://studio.evomlabs.com/api/v1/stt/transcriptions?stream=true" \
-H "Authorization: Bearer stt_sk_live_YOUR_KEY" \
-F "audio=@meeting.mp3" \
-F "language=vi"Response
{"type":"started","duration_seconds":42.5,"chunks_total":2,"provider":"loli-asr","model":"Loli 2.0"}
{"type":"chunk","index":1,"chunks_total":2,"text":"...","combined_text":"...","language":"vi"}
{"type":"chunk","index":2,"chunks_total":2,"text":"...","combined_text":"...","language":"vi"}
{"type":"final","text":"...","language":"vi","is_final":true,"provider":"loli-asr","model":"Loli 2.0","latency_ms":5230}Long audio is split into chunks of at most about 25 seconds, cut at a silent point near the boundary where possible. Read the type and chunks_total fields rather than deriving the number of chunks from the duration.
{
"type": "error",
"error": {
"code": "stt_upstream_error",
"message": "Error description"
}
}A stream can end with an error
Real-time WebSocket
/api/public/v1/stt/ws?token=<stt_key>&language=autoChoose automatic VAD or manual commits for a continuous PCM16 microphone stream.
wss://studio.evomlabs.com/api/public/v1/stt/ws?token=stt_sk_live_YOUR_KEY&language=autoStart message
{
"type": "start",
"mode": "auto",
"language": "auto",
"sample_rate": 48000,
"format": "pcm_s16le"
}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
type | string | Yes | - | Use start. |
mode | "auto" | "manual" | No | auto | auto uses server VAD; manual waits for a client commit. |
language | string | No | auto | One of the language modes listed above. |
sample_rate | integer | No | 16000 | Integer binary-input rate from 8,000 to 192,000 Hz. |
format | string | No | pcm_s16le | Exactly pcm_s16le: mono signed 16-bit little-endian PCM. |
session_id | string | No | Server generated | Optional client-provided identifier. |
In auto mode, send binary PCM16 mono continuously. The server resamples to the rate reported in started, detects speech and silence, sends replaceable partial events, and commits segment events at a silence boundary.
Send stop before closing
Manual mode for Push-to-Talk or client VAD
Start with mode manual. The server does not emit partials or detect silence. It buffers PCM after the previous commit and returns exactly one final segment when the client sends commit. A commit needs at least 0.2 seconds of audio; an empty or short commit produces a recoverable bad_request error, and a short buffer remains for more PCM. stop commits a sufficiently long trailing buffer, discards a shorter fragment, then sends final and closed.
// Start with: { "type": "start", "mode": "manual", ... }
// Send PCM16 frames, then commit exactly that buffered speech:
ws.send(JSON.stringify({ type: "commit" }));
// The server returns one final segment. Send commit again for the next turn,
// or stop to commit a sufficiently long trailing buffer and close the session.
ws.send(JSON.stringify({ type: "stop" }));Server events
// Server to client
{ "type": "started", "session_id": "session_id", "mode": "auto", "language": "auto",
"sample_rate": 16000, "provider": "loli-asr", "model": "Loli 2.0" }
{ "type": "partial", "index": 0, "text": "live window text",
"committed_text": "", "live_text": "live utterance text",
"combined_text": "live utterance text", "is_final": false,
"language": "vi", "latency_ms": 210 }
{ "type": "segment", "index": 0, "text": "committed utterance",
"committed_text": "committed utterance", "live_text": "",
"combined_text": "committed utterance", "is_final": true,
"language": "vi", "latency_ms": 350 }
{ "type": "final", "text": "Toร n bแป transcript", "language": "vi",
"is_final": true, "provider": "loli-asr", "model": "Loli 2.0",
"latency_ms": 4100 }
{ "type": "closed", "session_id": "session_id", "reason": "stopped" }
{ "type": "error", "session_id": "session_id-or-null",
"message": "Error description", "code": "bad_request" }Note
Examples
const ws = new WebSocket(
"wss://studio.evomlabs.com/api/public/v1/stt/ws" +
"?token=stt_sk_live_YOUR_KEY&language=auto"
);
ws.addEventListener("open", () => {
ws.send(JSON.stringify({
type: "start",
mode: "auto",
language: "auto",
sample_rate: 48000,
format: "pcm_s16le",
}));
});
ws.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.type === "partial" || message.type === "segment") {
renderTranscript(message.combined_text);
}
if (message.type === "final") {
renderTranscript(message.text);
}
if (message.type === "error") {
console.error(message.code, message.message);
}
});
// Send PCM16 frames with ws.send(arrayBuffer).
function stopTranscription() {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "stop" }));
}
}Security
A real-time session uses the same audio ceiling of 1,800 seconds.